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.Shorts.*; 020import static org.apache.juneau.commons.utils.StringUtils.*; 021import static org.apache.juneau.commons.utils.ThrowableUtils.*; 022 023import java.io.*; 024import java.util.*; 025 026import javax.xml.stream.*; 027 028import org.apache.juneau.commons.io.*; 029import org.apache.juneau.commons.lang.*; 030 031/** 032 * XML utility methods. 033 * 034 * <h5 class='section'>See Also:</h5><ul> 035 * <li class='link'><a class="doclink" href="https://juneau.apache.org/docs/topics/XmlSupport">XML</a> 036 037 * </ul> 038 */ 039@SuppressWarnings({ 040 "java:S115", // Constants use UPPER_snakeCase convention (e.g., CONST_x0000) 041 "java:S1192", // Duplicated literals read more clearly inline than as constants. 042 "java:S3776", // encodeElementNameInner(), encodeAttrName(), encodeText() and findNamespace() apply XML name/text escaping and namespace rules char-by-char in one pass 043 "resource" // Resource management handled externally 044}) 045public class XmlUtils { 046 047 /** 048 * Prevents instantiation. 049 */ 050 private XmlUtils() {} 051 052 // @formatter:off 053 private static final AsciiMap REPLACE_TEXT = new AsciiMap() 054 .append('&', "&") 055 .append('<', "<") 056 .append('>', ">") 057 .append((char)0x09, "	") 058 .append((char)0x0A, "
") 059 .append((char)0x0D, "
"); 060 061 private static final AsciiMap REPLACE_ATTR_VAL = new AsciiMap() 062 .append('&', "&") 063 .append('<', "<") 064 .append('>', ">") 065 .append('"', """) 066 .append('\'', "'") 067 .append((char)0x09, "	") 068 .append((char)0x0A, "
") 069 .append((char)0x0D, "
"); 070 // @formatter:on 071 072 /** 073 * Given a list of Strings and other Objects, combines Strings that are next to each other in the list. 074 * 075 * @param value The list of text nodes to collapse. 076 * <br>Must not be <jk>null</jk>. 077 * @return The same list. 078 */ 079 public static List<Object> collapseTextNodes(List<Object> value) { 080 081 StringBuilder prev = null; 082 for (ListIterator<Object> i = value.listIterator(); i.hasNext();) { 083 Object o = i.next(); 084 if (o instanceof String o2) { 085 if (prev == null) 086 prev = new StringBuilder(o2); 087 else { 088 prev.append(o2); 089 i.remove(); 090 i.previous(); 091 i.remove(); 092 i.add(prev.toString()); 093 } 094 } else { 095 prev = null; 096 } 097 } 098 return value; 099 } 100 101 /** 102 * Translates any _x####_ sequences (introduced by the various encode methods) back into their original characters. 103 * 104 * @param value The string being decoded. 105 * <br>Can be <jk>null</jk> (returns <jk>null</jk>). 106 * @param sb The string builder to use as a scratch pad. 107 * <br>Can be <jk>null</jk> (a new one is created). 108 * @return The decoded string, or <jk>null</jk> if the input was <jk>null</jk> or represented a <jk>null</jk> value. 109 */ 110 @SuppressWarnings({ 111 "java:S127" // Loop counter advances to skip _xXXXX_ escape sequences 112 }) 113 public static String decode(String value, StringBuilder sb) { 114 if (value == null) 115 return null; 116 if (value.isEmpty() || value.indexOf('_') == -1) 117 return value; 118 if (sb == null) 119 sb = new StringBuilder(value.length()); 120 121 for (var i = 0; i < value.length(); i++) { 122 var c = value.charAt(i); 123 if (c == '_' && isEscapeSequence(value, i)) { 124 125 var x = Integer.parseInt(value.substring(i + 2, i + 6), 16); 126 127 // If we find _x0000_, then that means a null. 128 // If we find _xE000_, then that means an empty string. 129 if (x == 0) 130 return null; 131 else if (x != 0xE000) 132 sb.append((char)x); 133 134 i += 6; 135 } else { 136 sb.append(c); 137 } 138 } 139 return sb.toString(); 140 } 141 142 /** 143 * Serializes and encodes the specified object as valid XML attribute name. 144 * 145 * @param w The writer to send the output to. 146 * <br>Must not be <jk>null</jk>. 147 * @param value The object being serialized. 148 * <br>Can be <jk>null</jk> (written as the <c>_x0000_</c> null marker). 149 * @return This object. 150 * @throws IOException If a problem occurred. 151 */ 152 public static Writer encodeAttrName(Writer w, Object value) throws IOException { 153 if (value == null) 154 return w.append("_x0000_"); 155 var s = value.toString(); 156 157 if (needsAttrNameEncoding(s)) { 158 for (var i = 0; i < s.length(); i++) { 159 var c = s.charAt(i); 160 if (i == 0) { 161 if (c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c == ':') 162 w.append(c); 163 else if (c == '_' && ! isEscapeSequence(s, i)) 164 w.append(c); 165 else 166 appendPaddedHexChar(w, c); 167 } else { 168 if ((c >= '0' && c <= '9' || c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c == ':')) 169 w.append(c); 170 else if (c == '_' && ! isEscapeSequence(s, i)) 171 w.append(c); 172 else 173 appendPaddedHexChar(w, c); 174 } 175 } 176 } else { 177 w.append(s); 178 } 179 180 return w; 181 } 182 183 /** 184 * Encodes the specified attribute value and sends the results to the specified writer. 185 * 186 * <p> 187 * Encodes any invalid XML text characters to <c>_x####_</c> sequences and sends the response to the specified 188 * writer. 189 * <br>Encodes <js>'&'</js>, <js>'<'</js>, <js>'>'</js>, <js>'"'</js>, and <js>'\''</js> as XML entities. 190 * <br>Encodes invalid XML text characters to <c>_x####_</c> sequences. 191 * 192 * @param w The writer to send the output to. 193 * <br>Must not be <jk>null</jk>. 194 * @param value The object being encoded. 195 * <br>Can be <jk>null</jk> (written as the <c>_x0000_</c> null marker). 196 * @param trim 197 * Trim the text before serializing it. 198 * If <jk>true</jk>, leading and trailing whitespace characters will be encoded. 199 * @return The same writer passed in. 200 */ 201 public static Writer encodeAttrValue(Writer w, Object value, boolean trim) { 202 try { 203 if (value == null) 204 return w.append("_x0000_"); 205 var s = value.toString(); 206 if (s.isEmpty()) 207 return w; 208 if (trim) 209 s = s.trim(); 210 211 if (needsAttrValueEncoding(s)) { 212 var len = s.length(); 213 for (var i = 0; i < len; i++) { 214 var c = s.charAt(i); 215 if ((i == 0 || i == len - 1) && Character.isWhitespace(c)) 216 appendPaddedHexChar(w, c); 217 else if (REPLACE_ATTR_VAL.contains(c)) 218 w.append(REPLACE_ATTR_VAL.get(c)); 219 else if (c == '_' && isEscapeSequence(s, i)) 220 appendPaddedHexChar(w, c); 221 else if (isValidXmlCharacter(c)) 222 w.append(c); 223 else 224 appendPaddedHexChar(w, c); 225 } 226 } else { 227 w.append(s); 228 } 229 } catch (IOException e) { 230 throw toRex(e); 231 } 232 233 return w; 234 } 235 236 /** 237 * Encodes any invalid XML element name characters to <c>_x####_</c> sequences. 238 * 239 * @param value The object being encoded. 240 * <br>Can be <jk>null</jk> (returns the <c>_x0000_</c> null marker). 241 * @return The encoded element name string. 242 */ 243 public static String encodeElementName(Object value) { 244 if (value == null) 245 return "_x0000_"; 246 var s = value.toString(); 247 if (s.isEmpty()) 248 return "_xE000_"; 249 250 try { 251 if (needsElementNameEncoding(s)) 252 try (var w = new StringBuilderWriter(s.length() * 2)) { 253 return encodeElementNameInner(w, s).toString(); 254 } 255 } catch (IOException e) { 256 throw toRex(e); // Never happens 257 } 258 259 return s; 260 } 261 262 /** 263 * Encodes any invalid XML element name characters to <c>_x####_</c> sequences. 264 * 265 * @param w The writer to send the output to. 266 * <br>Must not be <jk>null</jk>. 267 * @param value The object being encoded. 268 * <br>Can be <jk>null</jk> (written as the <c>_x0000_</c> null marker). 269 * @return The same writer passed in. 270 */ 271 public static Writer encodeElementName(Writer w, Object value) { 272 try { 273 if (value == null) 274 return w.append("_x0000_"); 275 var s = value.toString(); 276 if (needsElementNameEncoding(s)) 277 return encodeElementNameInner(w, s); 278 w.append(s); 279 } catch (IOException e) { 280 throw toRex(e); 281 } 282 return w; 283 } 284 285 /** 286 * Encodes the specified element text and sends the results to the specified writer. 287 * 288 * <p> 289 * Encodes any invalid XML text characters to <c>_x####_</c> sequences and sends the response to the specified 290 * writer. 291 * <br>Encodes <js>'&'</js>, <js>'<'</js>, and <js>'>'</js> as XML entities. 292 * <br>Encodes invalid XML text characters to <c>_x####_</c> sequences. 293 * 294 * @param w The writer to send the output to. 295 * <br>Must not be <jk>null</jk>. 296 * @param value The object being encoded. 297 * <br>Can be <jk>null</jk> (written as the <c>_x0000_</c> null marker). 298 * @param trim Trim the text before serializing it. 299 * @param preserveWhitespace 300 * Specifies whether we're in preserve-whitespace mode. 301 * (e.g. {@link XmlFormat#MIXED_PWS} or {@link XmlFormat#TEXT_PWS}. 302 * If <jk>true</jk>, leading and trailing whitespace characters will be encoded. 303 * @return The same writer passed in. 304 */ 305 public static Writer encodeText(Writer w, Object value, boolean trim, boolean preserveWhitespace) { 306 307 try { 308 if (value == null) 309 return w.append("_x0000_"); 310 var s = value.toString(); 311 if (s.isEmpty()) 312 return w.append("_xE000_"); 313 if (trim) 314 s = s.trim(); 315 316 if (needsTextEncoding(s)) { 317 var len = s.length(); 318 for (var i = 0; i < len; i++) { 319 var c = s.charAt(i); 320 if ((i == 0 || i == len - 1) && Character.isWhitespace(c) && ! preserveWhitespace) 321 appendPaddedHexChar(w, c); 322 else if (REPLACE_TEXT.contains(c)) 323 w.append(REPLACE_TEXT.get(c)); 324 else if (c == '_' && isEscapeSequence(s, i)) 325 appendPaddedHexChar(w, c); 326 else if (isValidXmlCharacter(c)) 327 w.append(c); 328 else 329 appendPaddedHexChar(w, c); 330 } 331 } else { 332 w.append(s); 333 } 334 } catch (IOException e) { 335 throw toRex(e); 336 } 337 338 return w; 339 } 340 341 /** 342 * Escapes invalid XML text characters to <c>_x####_</c> sequences. 343 * 344 * @param value The object being encoded. 345 * <br>Can be <jk>null</jk> (returns the <c>_x0000_</c> null marker). 346 * @return The encoded string. 347 */ 348 public static String escapeText(Object value) { 349 if (value == null) 350 return "_x0000_"; 351 var s = value.toString(); 352 353 try { 354 if (! needsTextEncoding(s)) 355 return s; 356 var len = s.length(); 357 var sw = new StringWriter(s.length() * 2); 358 for (var i = 0; i < len; i++) { 359 var c = s.charAt(i); 360 if ((i == 0 || i == len - 1) && Character.isWhitespace(c)) 361 appendPaddedHexChar(sw, c); 362 else if (c == '_' && isEscapeSequence(s, i)) 363 appendPaddedHexChar(sw, c); 364 else if (isValidXmlCharacter(c)) 365 sw.append(c); 366 else 367 appendPaddedHexChar(sw, c); 368 } 369 return sw.toString(); 370 } catch (IOException e) { 371 throw toRex(e); // Never happens 372 } 373 } 374 375 /** 376 * Find the namespace given a list of <ja>@Xml</ja> and <ja>@XmlSchema</ja> annotations. 377 * 378 * <p> 379 * The annotations should be a parent-to-child ordering of annotations found on a class or method. 380 * 381 * @param xmls The list of <ja>@Xml</ja> annotations. 382 * <br>Must not be <jk>null</jk>. 383 * @param schemas The list of <ja>@XmlSchema</ja> annotations. 384 * <br>Must not be <jk>null</jk>. 385 * @return The namespace, or <jk>null</jk> if it couldn't be found. 386 */ 387 public static Namespace findNamespace(List<Xml> xmls, List<XmlSchema> schemas) { 388 389 for (var i = xmls.size() - 1; i >= 0; i--) { 390 var xml = xmls.get(i); 391 var ns = findNamespace(xml.prefix(), xml.namespace(), xmls, schemas); 392 if (nn(ns)) 393 return ns; 394 } 395 396 for (var i = schemas.size() - 1; i >= 0; i--) { 397 var schema = schemas.get(i); 398 var ns = findNamespace(schema.prefix(), schema.namespace(), null, schemas); 399 if (nn(ns)) 400 return ns; 401 } 402 403 return null; 404 } 405 406 /** 407 * Utility method that converts the current event on the XML stream to something human-readable for debug purposes. 408 * 409 * @param r The XML stream reader whose current event is to be converted to a readable string. 410 * <br>Must not be <jk>null</jk>. 411 * @return The event in human-readable form. 412 */ 413 public static String toReadableEvent(XMLStreamReader r) { 414 int t = r.getEventType(); 415 if (t == 1) 416 return "<" + r.getLocalName() + ">"; 417 if (t == 2) 418 return "</" + r.getLocalName() + ">"; 419 if (t == 3) 420 return "PROCESSING_INSTRUCTION"; 421 if (t == 4) 422 return "CHARACTERS=[" + r.getText() + "]"; 423 if (t == 5) 424 return "COMMENTS=[" + r.getText() + "]"; 425 if (t == 6) 426 return "SPACE=[" + r.getText() + "]"; 427 if (t == 7) 428 return "START_DOCUMENT"; 429 if (t == 8) 430 return "END_DOCUMENT"; 431 if (t == 9) 432 return "ENTITY_REFERENCE"; 433 if (t == 10) 434 return "ATTRIBUTE"; 435 if (t == 11) 436 return "DTD"; 437 if (t == 12) 438 return "CDATA=[" + r.getText() + "]"; 439 if (t == 13) 440 return "NAMESPACE"; 441 if (t == 14) 442 return "NOTATION_DECLARATION"; 443 if (t == 15) 444 return "ENTITY_DECLARATION"; 445 return "UNKNOWN"; 446 } 447 448 // Converts an integer to a hexadecimal string padded to 4 places. 449 private static Writer appendPaddedHexChar(Writer out, int num) throws IOException { 450 out.append("_x"); 451 for (var c : toHex4(num)) 452 out.append(c); 453 return out.append('_'); 454 } 455 456 private static Writer encodeElementNameInner(Writer w, String s) throws IOException { 457 for (var i = 0; i < s.length(); i++) { 458 var c = s.charAt(i); 459 // @formatter:off 460 if ((c >= 'A' && c <= 'Z') 461 || (c == '_' && ! isEscapeSequence(s,i)) 462 || (c >= 'a' && c <= 'z') 463 || (i != 0 && ( 464 c == '-' 465 || c == '.' 466 || (c >= '0' && c <= '9') 467 || c == '\u00b7' 468 || (c >= '\u0300' && c <= '\u036f') 469 || (c >= '\u203f' && c <= '\u2040') 470 )) 471 || (c >= '\u00c0' && c <= '\u00d6') 472 || (c >= '\u00d8' && c <= '\u00f6') 473 || (c >= '\u00f8' && c <= '\u02ff') 474 || (c >= '\u0370' && c <= '\u037d') 475 || (c >= '\u037f' && c <= '\u1fff') 476 || (c >= '\u200c' && c <= '\u200d') 477 || (c >= '\u2070' && c <= '\u218f') 478 || (c >= '\u2c00' && c <= '\u2fef') 479 || (c >= '\u3001' && c <= '\ud7ff') 480 || (c >= '\uf900' && c <= '\ufdcf') 481 || (c >= '\ufdf0' && c <= '\ufffd')) { 482 // @formatter:on 483 w.append(c); 484 } else { 485 appendPaddedHexChar(w, c); 486 } 487 } 488 return w; 489 } 490 491 private static Namespace findNamespace(String prefix, String ns, List<Xml> xmls, List<XmlSchema> schemas) { 492 493 // If both prefix and namespace specified, use that Namespace mapping. 494 if (! (prefix.isEmpty() || ns.isEmpty())) 495 return Namespace.of(prefix, ns); 496 497 // If only prefix specified, need to search for namespaceURI. 498 if (! prefix.isEmpty()) { 499 if (nn(xmls)) 500 for (var xml2 : xmls) 501 if (xml2.prefix().equals(prefix) && ! xml2.namespace().isEmpty()) 502 return Namespace.of(prefix, xml2.namespace()); 503 for (var schema : schemas) { 504 if (schema.prefix().equals(prefix) && ! schema.namespace().isEmpty()) 505 return Namespace.of(prefix, schema.namespace()); 506 for (var xmlNs : schema.xmlNs()) 507 if (xmlNs.prefix().equals(prefix)) 508 return Namespace.of(prefix, xmlNs.namespaceURI()); 509 } 510 throw brex("Found @Xml.prefix annotation with no matching URI. prefix='%s'", prefix); 511 } 512 513 // If only namespaceURI specified, need to search for prefix. // HTT: reached only via XmlSerializer when @Xml has namespace but no prefix; not exercised by unit tests 514 if (! ns.isEmpty()) { 515 if (nn(xmls)) 516 for (var xml2 : xmls) 517 if (xml2.namespace().equals(ns) && ! xml2.prefix().isEmpty()) 518 return Namespace.of(xml2.prefix(), ns); 519 for (var schema : schemas) { 520 if (schema.namespace().equals(ns) && ! schema.prefix().isEmpty()) 521 return Namespace.of(schema.prefix(), ns); 522 for (var xmlNs : schema.xmlNs()) 523 if (xmlNs.namespaceURI().equals(ns)) 524 return Namespace.of(xmlNs.prefix(), ns); 525 } 526 } 527 528 return null; 529 } 530 531 // Returns true if the string at the specified position is of the form "_x####_" 532 // where '#' are hexadecimal characters. 533 private static boolean isEscapeSequence(String s, int i) { 534 // @formatter:off 535 return s.length() > i+6 536 && s.charAt(i) == '_' 537 && s.charAt(i+1) == 'x' 538 && isHexCharacter(s.charAt(i+2)) 539 && isHexCharacter(s.charAt(i+3)) 540 && isHexCharacter(s.charAt(i+4)) 541 && isHexCharacter(s.charAt(i+5)) 542 && s.charAt(i+6) == '_'; 543 // @formatter:on 544 } 545 546 // Returns true if the character is a hexadecimal character 547 private static boolean isHexCharacter(char c) { 548 return (c >= '0' && c <= '9') || (c >= 'A' && c <= 'F'); 549 } 550 551 // Returns true if the specified character can safely be used in XML text or an attribute. 552 private static boolean isValidXmlCharacter(char c) { 553 return (c >= 0x20 && c <= 0xD7FF) /*|| c == 0xA || c == 0xD*/ || (c >= 0xE000 && c <= 0xFFFD); 554 } 555 556 private static boolean needsAttrNameEncoding(String value) { 557 // Note that this doesn't need to be perfect, just fast. 558 for (var i = 0; i < value.length(); i++) { 559 var c = value.charAt(i); 560 if (! (c >= '0' && c <= '9' || c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z') || (i == 0 && ! (c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z'))) 561 return true; 562 } 563 return false; 564 } 565 566 private static boolean needsAttrValueEncoding(String value) { 567 // See if we need to convert the string. 568 // Conversion is somewhat expensive, so make sure we need to do so before hand. 569 var len = value.length(); 570 for (var i = 0; i < len; i++) { 571 var c = value.charAt(i); 572 if ((i == 0 || i == len - 1) && Character.isWhitespace(c)) 573 return true; 574 if (REPLACE_ATTR_VAL.contains(c) || ! isValidXmlCharacter(c) || (c == '_' && isEscapeSequence(value, i))) 575 return true; 576 } 577 return false; 578 } 579 580 private static boolean needsElementNameEncoding(String value) { 581 // Note that this doesn't need to be perfect, just fast. 582 for (var i = 0; i < value.length(); i++) { 583 var c = value.charAt(i); 584 if (! (c >= '0' && c <= '9' || c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z') || (i == 0 && (c >= '0' && c <= '9'))) 585 return true; 586 } 587 return false; 588 } 589 590 private static boolean needsTextEncoding(String value) { 591 // See if we need to convert the string. 592 // Conversion is somewhat expensive, so make sure we need to do so before hand. 593 var len = value.length(); 594 for (var i = 0; i < len; i++) { 595 var c = value.charAt(i); 596 if ((i == 0 || i == len - 1) && Character.isWhitespace(c)) 597 return true; 598 if (REPLACE_TEXT.contains(c) || ! isValidXmlCharacter(c) || (c == '_' && isEscapeSequence(value, i))) 599 return true; 600 } 601 return false; 602 } 603}