├── .codeclimate ├── .editorconfig ├── .eslintignore ├── .forceignore ├── .gitignore ├── LICENSE ├── README.md ├── config └── project-scratch-def.json ├── force-app └── main │ └── default │ └── classes │ ├── XML.cls │ ├── XML.cls-meta.xml │ ├── XMLTest.cls │ └── XMLTest.cls-meta.xml ├── jest.config.js ├── manifest └── package.xml ├── package.json ├── sfdc-xml-parser.iml └── sfdx-project.json /.codeclimate: -------------------------------------------------------------------------------- 1 | version: 2 2 | checks: 3 | argument-count: 4 | enabled: true 5 | complex-logic: 6 | enabled: true 7 | file-lines: 8 | enabled: true 9 | method-complexity: 10 | enabled: true 11 | method-count: 12 | enabled: true 13 | method-lines: 14 | enabled: true 15 | nested-control-flow: 16 | enabled: true 17 | return-statements: 18 | enabled: true 19 | similar-code: 20 | enabled: true 21 | identical-code: 22 | enabled: true 23 | plugins: 24 | apexmetrics: 25 | enabled: true -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_size = 4 7 | indent_style = space 8 | insert_final_newline = false 9 | max_line_length = 120 10 | tab_width = 4 11 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | **/lwc/**/*.css 2 | **/lwc/**/*.html 3 | **/lwc/**/*.json 4 | **/lwc/**/*.svg 5 | **/lwc/**/*.xml 6 | **/aura/**/*.auradoc 7 | **/aura/**/*.cmp 8 | **/aura/**/*.css 9 | **/aura/**/*.design 10 | **/aura/**/*.evt 11 | **/aura/**/*.json 12 | **/aura/**/*.svg 13 | **/aura/**/*.tokens 14 | **/aura/**/*.xml 15 | **/aura/**/*.app 16 | .sfdx 17 | -------------------------------------------------------------------------------- /.forceignore: -------------------------------------------------------------------------------- 1 | # List files or directories below to ignore them when running force:source:push, force:source:pull, and force:source:status 2 | # More information: https://developer.salesforce.com/docs/atlas.en-us.sfdx_dev.meta/sfdx_dev/sfdx_dev_exclude_source.htm 3 | # 4 | 5 | package.xml 6 | 7 | # LWC configuration files 8 | **/jsconfig.json 9 | **/.eslintrc.json 10 | 11 | # LWC Jest 12 | **/__tests__/** -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # This file is used for Git repositories to specify intentionally untracked files that Git should ignore. 2 | # If you are not using git, you can delete this file. For more information see: https://git-scm.com/docs/gitignore 3 | # For useful gitignore templates see: https://github.com/github/gitignore 4 | 5 | # Salesforce cache 6 | .sf/ 7 | .sfdx/ 8 | .localdevserver/ 9 | deploy-options.json 10 | 11 | # LWC VSCode autocomplete 12 | **/lwc/jsconfig.json 13 | 14 | # LWC Jest coverage reports 15 | coverage/ 16 | 17 | # Logs 18 | logs 19 | *.log 20 | npm-debug.log* 21 | yarn-debug.log* 22 | yarn-error.log* 23 | 24 | # Dependency directories 25 | node_modules/ 26 | 27 | # Eslint cache 28 | .eslintcache 29 | 30 | # MacOS system files 31 | .DS_Store 32 | 33 | # Windows system files 34 | Thumbs.db 35 | ehthumbs.db 36 | [Dd]esktop.ini 37 | $RECYCLE.BIN/ 38 | 39 | # Local environment variables 40 | .env 41 | 42 | # Added by Illuminated Cloud 43 | /IlluminatedCloud/ 44 | /out/ 45 | target/ 46 | .idea/ -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 zabroseric 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SFDC XML Parser 2 | 3 | Built Status: 4 | ![coverage](https://img.shields.io/badge/coverage-100%25-yellowgreen) 5 | ![build](https://img.shields.io/badge/build-passing-success) 6 | [![Maintainability](https://api.codeclimate.com/v1/badges/7dbda30d4ea9ddf96974/maintainability)](https://codeclimate.com/github/zabroseric/sfdc-xml-parser/maintainability) 7 | 8 | ![sfdc package](https://img.shields.io/badge/sfdc%20package-53.0-blue) 9 | [![GitHub license](https://img.shields.io/github/license/zabroseric/sfdc-xml-parser.svg)](https://github.com/zabroseric/sfdc-xml-parser/blob/master/LICENSE) 10 | 11 | 12 | | Deploy to Salesforce Org | 13 | | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | 14 | | [![Deploy](https://raw.githubusercontent.com/afawcett/githubsfdeploy/master/deploy.png)](https://githubsfdeploy.herokuapp.com/?owner=zabroseric&repo=sfdc-xml-parser&ref=master) | 15 | 16 | ## Table of Contents 17 | 18 | - [SFDC XML Parser](#sfdc-xml-parser) 19 | - [Table of Contents](#table-of-contents) 20 | - [Features](#features) 21 | - [Overview](#overview) 22 | - [Getting Started](#getting-started) 23 | - [Usage - Serialization](#usage---serialization) 24 | - [SObject](#sobject) 25 | - [SObject List](#sobject-list) 26 | - [Objects](#objects) 27 | - [Maps](#maps) 28 | - [Usage - Deserialization](#usage---deserialization) 29 | - [SObject](#sobject-1) 30 | - [SObject List](#sobject-list-1) 31 | - [Objects](#objects-1) 32 | - [Maps](#maps-1) 33 | - [References - Serialization](#references---serialization) 34 | - [Summary](#summary) 35 | - [toString](#tostring) 36 | - [toBase64](#tobase64) 37 | - [debug](#debug) 38 | - [showNulls (default) / suppressNulls](#shownulls-default--suppressnulls) 39 | - [minify (default) / beautify](#minify-default--beautify) 40 | - [hideEncoding (default) / showEncoding](#hideencoding-default--showencoding) 41 | - [addRootAttribute / setRootAttributes](#addrootattribute--setrootattributes) 42 | - [addNamespace / setNamespaces](#addnamespace--setnamespaces) 43 | - [setRootNodeName](#setrootnodename) 44 | - [splitAttributes (default) / embedAttributes](#splitattributes-default--embedattributes) 45 | - [References - Deserialization](#references---deserialization) 46 | - [Summary](#summary-1) 47 | - [toObject](#toobject) 48 | - [setType](#settype) 49 | - [toString](#tostring-1) 50 | - [debug](#debug-1) 51 | - [setReservedWordSuffix](#setreservedwordsuffix) 52 | - [filterNamespace](#filternamespace) 53 | - [showNamespaces (default) / hideNamespaces](#shownamespaces-default--hidenamespaces) 54 | - [addArrayNode / setArrayNodes](#addarraynode--setarraynodes) 55 | - [setRootNode](#setrootnode) 56 | - [sanitize (default) / unsanitize](#sanitize-default--unsanitize) 57 | - [Other Cool Things](#other-cool-things) 58 | - [Deserialization Interfaces](#deserialization-interfaces) 59 | - [Self Keyword](#self-keyword) 60 | - [Node Name Sanatization](#node-name-sanatization) 61 | - [Value Encoding](#value-encoding) 62 | - [Limitations](#limitations) 63 | - [Contributing](#contributing) 64 | 65 | ## Features 66 | 67 | * Serialize / Deserialize SObjects 68 | * Serialize / Deserialize Apex Classes 69 | * Function Chaining 70 | * SObject Node Detection 71 | * Node Name and Value Sanitization 72 | * Clark Notations 73 | * Deserialization Interfaces 74 | * Namespace Filtering 75 | * Reserved Word Management 76 | 77 | ## Overview 78 | 79 | Apex does not currently support XML serialization and deserialization. This functionality is useful when communicating with other systems that support only an XML format, storing files, or even generating HTML. The XML parser bridges this gap by managing the encoding by automatically mapping SObject fields, handling special characters and providing a wide range of flexibility during the encoding processes. 80 | 81 | **Why not create something?** 82 | 83 | Simple - By using a pre-built library like this, no additional development work is needed on your end. Future requirements are met, and the solution has been tested over a wide range of use-cases. Plus we use the solution ourselves in multiple projects. This means that as we or other community members require more functionality, the library is updated. Additionally, as edge cases are found during use, these are fixed. 84 | 85 | ## Getting Started 86 | 87 | The XML Parser uses function chaining to change how the serialization/deserialization is handled. For example, you may want to format the XML in a pretty format with spacing and newlines to help with debugging. To do this, we can simply call the **beautify()** method as per the below: 88 | 89 | ```java 90 | XML.serialize(contact).beautify().toString(); 91 | ``` 92 | 93 | The result of serialization is as follows: 94 | 95 | ```xml 96 | 97 | 98 | Contact 99 | /services/data/v53.0/sobjects/Contact/0035j00000I09JaAAJ 100 | 101 | First1 Last1 102 | 0035j00000I09JaAAJ 103 | 104 | ``` 105 | 106 | The usage section covers common use-cases of these, whereas a list of these can be seen in the [references](#references-serialization) section at the bottom of the readme. 107 | 108 | ## Usage - Serialization 109 | 110 | Examples can be seen below of common serialization use-cases from handling SObjects, to lists and various functions that can be used. 111 | 112 | ### SObject 113 | 114 | The root node is automatically detected, and attributes are added. 115 | 116 | ```java 117 | Contact contact = new Contact( 118 | FirstName = 'First', 119 | LastName = 'Last' 120 | ); 121 | insert contact; 122 | 123 | String xmlString = XML.serialize(contact).beautify().toString(); 124 | ``` 125 | 126 | Result 127 | 128 | ```xml 129 | 130 | 131 | Contact 132 | /services/data/v48.0/sobjects/Contact/0032w000005DrR2AAK 133 | 134 | First 135 | Last 136 | 0032w000005DrR2AAK 137 | 138 | ``` 139 | 140 | ### SObject List 141 | 142 | The root node name is converted to a plural that contains child nodes as per the single SObject serialization. 143 | 144 | ```java 145 | List contacts = new List{ 146 | new Contact( 147 | FirstName = 'First1', 148 | LastName = 'Last1' 149 | ), 150 | new Contact( 151 | FirstName = 'First2', 152 | LastName = 'Last2' 153 | ) 154 | }; 155 | insert contacts; 156 | 157 | String xmlString = XML.serialize(contacts).beautify().toString(); 158 | ``` 159 | 160 | Result 161 | 162 | ```xml 163 | 164 | 165 | 166 | Contact 167 | /services/data/v48.0/sobjects/Contact/0032w000005DrQxAAK 168 | 169 | First1 170 | Last1 171 | 0032w000005DrQxAAK 172 | 173 | 174 | 175 | Contact 176 | /services/data/v48.0/sobjects/Contact/0032w000005DrQyAAK 177 | 178 | First2 179 | Last2 180 | 0032w000005DrQyAAK 181 | 182 | 183 | ``` 184 | 185 | ### Objects 186 | 187 | Classes / Objects can be serialized. If the root node name is not set, this will default to either **element** or **elements** depending on if we have a list of objects. 188 | 189 | ```java 190 | Library libraryObject = new Library( 191 | new Catalog( 192 | new Books( 193 | new List{ 194 | new Book('title1', new Authors(new List{'Name1', 'Name2'}), '23.00'), 195 | new Book('title1', new Authors(new List{'Name3', 'Name4'}), '23.00') 196 | } 197 | ) 198 | ) 199 | ); 200 | 201 | String xmlString = XML.serialize(libraryObject).setRootNodeName('library').beautify().toString(); 202 | ``` 203 | 204 | Result 205 | 206 | ```xml 207 | 208 | 209 | 210 | 211 | title1 212 | 23.00 213 | 214 | Name1 215 | Name2 216 | 217 | 218 | 219 | title1 220 | 23.00 221 | 222 | Name3 223 | Name4 224 | 225 | 226 | 227 | 228 | 229 | ``` 230 | 231 | ### Maps 232 | 233 | If we are wanting to work with a map/list of primitive types this operates similar to that of objects. 234 | 235 | ```java 236 | String xmlString = XML.serialize(new Map{ 237 | 'key1' => 'val1', 238 | 'key2' => 'val2' 239 | }).beautify().debug().toString(); 240 | ``` 241 | 242 | Result 243 | 244 | ```xml 245 | 246 | val2 247 | val1 248 | 249 | ``` 250 | 251 | ## Usage - Deserialization 252 | 253 | ### SObject 254 | 255 | All fields that are common between the XML and SObject are deserialized. 256 | 257 | ```java 258 | Contact contact = (Contact) XML.deserialize('Contact/services/data/v48.0/sobjects/Contact/0032w000005DrR2AAKFirstLast0032w000005DrR2AAK') 259 | .setType(Contact.class).toObject(); 260 | ``` 261 | 262 | ### SObject List 263 | 264 | A list of SObjects are deserialized if the type is set as a **List<SObject>.class** 265 | 266 | ```java 267 | List contactResult = (List) XML.deserialize('Contact/services/data/v48.0/sobjects/Contact/0032w000005DrQxAAKFirst1Last10032w000005DrQxAAKContact/services/data/v48.0/sobjects/Contact/0032w000005DrQyAAKFirst2Last20032w000005DrQyAAK') 268 | .setType(List.class).toObject(); 269 | ``` 270 | 271 | ### Objects 272 | 273 | Classes and objects can be deserialized in cases that models are used instead of objects. 274 | 275 | ```java 276 | Library library = XML.deserialize('title123.00Name1Name2title123.00Name3Name4', Library.class) 277 | .toObject(); 278 | ``` 279 | 280 | ### Maps 281 | 282 | Similarly to serialization, a map/list of primitive types can be deserialized. 283 | 284 | ```java 285 | Map objectMap = (Map) XML.deserialize('val2val1') 286 | .setArrayNode('elements').toObject(); 287 | ``` 288 | 289 | ## References - Serialization 290 | 291 | ### Summary 292 | 293 | - [toString](#tostring) 294 | - [toBase64](#tobase64) 295 | - [debug](#debug) 296 | - [showNulls (default) / suppressNulls](#shownulls-default--suppressnulls) 297 | - [minify (default) / beautify](#minify-default--beautify) 298 | - [hideEncoding (default) / showEncoding](#hideencoding-default--showencoding) 299 | - [addRootAttribute / setRootAttributes](#addrootattribute--setrootattributes) 300 | - [addNamespace / setNamespaces](#addnamespace--setnamespaces) 301 | - [setRootNodeName](#setrootnodename) 302 | - [splitAttributes (default) / embedAttributes](#splitattributes-default--embedattributes) 303 | 304 | ### toString 305 | 306 | Combines the other functions in the chain sequence to provide the resulting XML in string format. 307 | 308 | ```java 309 | Contact contact = new Contact( 310 | FirstName = 'First', 311 | LastName = 'Last' 312 | ); 313 | 314 | String xmlString = XML.serialize(contact) 315 | .setRootNodeName('NewNodeName') // function 1 316 | .showEncoding() // function 2 317 | .beautify() // function 3 318 | .toString(); // Result 319 | ``` 320 | 321 | The result in the **xmlString** variable is as follows: 322 | 323 | ```xml 324 | 325 | 326 | 327 | Contact 328 | 329 | First 330 | Last 331 | 332 | ``` 333 | 334 | ### toBase64 335 | 336 | Combines the other functions in the chain sequence as like the **toString** method, and encodes the XML result in base64 format. 337 | 338 | ```java 339 | Contact contact = new Contact( 340 | FirstName = 'First', 341 | LastName = 'Last' 342 | ); 343 | 344 | String xmlString = XML.serialize(contact) 345 | .setRootNodeName('NewNodeName') // function 1 346 | .showEncoding() // function 2 347 | .beautify() // function 3 348 | .toBase64(); // Result 349 | ``` 350 | 351 | The result in the **xmlString** variable is as follows: 352 | 353 | ```plaintext 354 | PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4NCjxOZXdUYWc+DQogIDxhdHRyaWJ1dGVzPg0KICAgIDx0eXBlPkNvbnRhY3Q8L3R5cGU+DQogIDwvYXR0cmlidXRlcz4NCiAgPEZpcnN0TmFtZT5GaXJzdDwvRmlyc3ROYW1lPg0KICA8TGFzdE5hbWU+TGFzdDwvTGFzdE5hbWU+DQo8L05ld1RhZz4= 355 | ``` 356 | 357 | ### debug 358 | 359 | Prints the XML string to the console using the functions executed previously in the chain. Multiple debugs can be called in the same chain, with each executing independently of the other. 360 | 361 | ```java 362 | Contact contact = new Contact( 363 | FirstName = 'First', 364 | LastName = 'Last' 365 | ); 366 | 367 | String xmlString = XML.serialize(contact) 368 | .debug() // Debug 1 369 | .showEncoding().beautify().debug() // Debug 2 370 | .toString(); 371 | ``` 372 | 373 | Debug 1 374 | 375 | ```xml 376 | ContactFirstLast 377 | ``` 378 | 379 | Debug 2 380 | 381 | ```xml 382 | 383 | 384 | 385 | Contact 386 | 387 | First 388 | Last 389 | 390 | ``` 391 | 392 | ### showNulls (default) / suppressNulls 393 | 394 | When there are empty or null value node values, by default the value will be rendered within the respective XML node. However, if we want to hide null or empty values, it is possible to use the **suppressNulls** method. 395 | 396 | The result is that any empty nodes are removed until all nodes have values in them. 397 | 398 | ```java 399 | Library library = new Library( 400 | new Catalog( 401 | new Books( 402 | new List{ 403 | new Book('title1', new Authors(new List{'Name1', 'Name2'}), '23.00'), 404 | new Book('title5', new Authors(new List{}), null) 405 | } 406 | ) 407 | ) 408 | ); 409 | 410 | XML.serialize(library).suppressNulls().setRootNodeName('library').beautify().debug(); 411 | ``` 412 | 413 | In the example, the second book does not have any authors. The result is that the author, authors nodes are suppressed alongside the price of the book. 414 | 415 | ```xml 416 | 417 | 418 | 419 | 420 | title1 421 | 23.00 422 | 423 | Name1 424 | Name2 425 | 426 | 427 | 428 | title5 429 | 430 | 431 | 432 | 433 | ``` 434 | 435 | ### minify (default) / beautify 436 | 437 | By default the resulting XML has no spaces or new lines between nodes to help with readability. The default behaviour can be overridden by calling the **beautify** method to nicely format the resulting string. 438 | 439 | ```java 440 | Contact contact = new Contact( 441 | FirstName = 'First', 442 | LastName = 'Last' 443 | ); 444 | 445 | String xmlStringNormal = XML.serialize(contact).toString(); 446 | String xmlStringBeautify = XML.serialize(contact).beautify().toString(); 447 | ``` 448 | 449 | The result is as follows: 450 | 451 | xmlStringNormal 452 | 453 | ```xml 454 | ContactFirstLast 455 | ``` 456 | 457 | xmlStringBeautify 458 | 459 | ```xml 460 | 461 | 462 | Contact 463 | 464 | First 465 | Last 466 | 467 | ``` 468 | 469 | ### hideEncoding (default) / showEncoding 470 | 471 | By default the header of the XML is omitted and only the body is present. However when needing to show the header and encoding, this can be done as per the example below: 472 | 473 | ```java 474 | Contact contact = new Contact( 475 | FirstName = 'First', 476 | LastName = 'Last' 477 | ); 478 | 479 | String xmlString = XML.serialize(contact).showEncoding().beautify().toString(); 480 | ``` 481 | 482 | ```xml 483 | 484 | 485 | 486 | Contact 487 | 488 | First 489 | Last 490 | 491 | ``` 492 | 493 | ### addRootAttribute / setRootAttributes 494 | 495 | When needing to provide additional attributes this can be set one at a time via the **addRootAttribute** method, or several at a time using the **setRootAttributes** method. 496 | 497 | By default attributes are stored as a child attributes node, however, this can be overridden by the **embedAttributes** method. 498 | 499 | ```java 500 | Contact contact = new Contact( 501 | FirstName = 'First', 502 | LastName = 'Last' 503 | ); 504 | 505 | String xmlString = XML.serialize(contact).addRootAttribute('key1', 'value1').addRootAttribute('key2', 'value2').beautify().toString(); 506 | ``` 507 | 508 | The result is two additional elements within the attributes node are present. 509 | 510 | ```xml 511 | 512 | 513 | Contact 514 | value1 515 | value2 516 | 517 | First 518 | Last 519 | 520 | ``` 521 | 522 | ### addNamespace / setNamespaces 523 | 524 | Clark notations support the ability to specify both the XML namespace and 'local name'. 525 | For more information please see the link [here](http://www.jclark.com/xml/xmlns.htm). 526 | 527 | An example of this can be seen below: 528 | 529 | ```java 530 | XML.serialize(new List{ 531 | new Map{ 532 | '{http://example.org}localname1' => 'val1', 533 | '{http://example.org}localname2' => 'val2' 534 | } 535 | }).addNamespace('http://example.org', 'b').beautify().debug(); 536 | ``` 537 | 538 | The result gets transformed to valid xml: 539 | 540 | ```xml 541 | 542 | val2 543 | val1 544 | 545 | ``` 546 | 547 | ### setRootNodeName 548 | 549 | Node names are automatically detected for SObjects. For all other situations of serialization, the default for this is **element**. 550 | 551 | To override this functionality, a root node name can be specified. 552 | 553 | ```java 554 | Contact contact = new Contact( 555 | FirstName = 'First', 556 | LastName = 'Last' 557 | ); 558 | 559 | String xmlString = XML.serialize(contact).setRootNodeName('MyNode').beautify().toString(); 560 | ``` 561 | 562 | ```xml 563 | 564 | 565 | Contact 566 | 567 | First 568 | Last 569 | 570 | ``` 571 | 572 | ### splitAttributes (default) / embedAttributes 573 | 574 | By default, attributes are created as seperate child nodes on the parent. This is to support expected behaviour when serializing SObjects. 575 | 576 | When overriding this default functionality, attributes will be stored as proper node attributes. 577 | 578 | ```java 579 | Contact contact = new Contact( 580 | FirstName = 'First', 581 | LastName = 'Last' 582 | ); 583 | 584 | String xmlString = XML.serialize(contact).embedAttributes().beautify().toString(); 585 | ``` 586 | 587 | ```xml 588 | 589 | First 590 | Last 591 | 592 | ``` 593 | 594 | Further to this, any fields with the called **attributes** with a type of Map will be automatically embedded as attributes on the current node. 595 | 596 | ## References - Deserialization 597 | 598 | ### Summary 599 | 600 | - [toObject](#toobject) 601 | - [setType](#settype) 602 | - [toString](#tostring-1) 603 | - [debug](#debug-1) 604 | - [setReservedWordSuffix](#setreservedwordsuffix) 605 | - [filterNamespace](#filternamespace) 606 | - [showNamespaces](#shownamespaces-default--hidenamespaces) 607 | - [hideNamespaces](#shownamespaces-default--hidenamespaces) 608 | - [addArrayNode](#addarraynode--setarraynodes) 609 | - [setArrayNodes](#addarraynode--setarraynodes) 610 | - [setRootNode](#setrootnode) 611 | - [sanitize (default) / unsanitize](#sanitize-default--unsanitize) 612 | 613 | ### toObject 614 | 615 | Combines the result of the previous functions in the chain sequence to produce an object specified in the **toType** method. The return result will need to be cast manually. 616 | 617 | ```java 618 | Contact contact = (Contact) XML.deserialize('ContactFirstLast').setType(Contact.class).toObject(); 619 | 620 | // => Contact:{FirstName=First, LastName=Last} 621 | ``` 622 | 623 | ### setType 624 | 625 | Deserializes the XML to a specified type, whether this is an SObject, Object, List, Map ..etc. If any errors occur during the mapping process relevant exceptions will be thrown. 626 | 627 | ```java 628 | Contact contact = (Contact) XML.deserialize('ContactFirstLast').setType(Contact.class).toObject(); 629 | 630 | // => Contact:{FirstName=First, LastName=Last} 631 | 632 | Contact contact = (Contact) XML.deserialize('ContactFirstLast').setType(Integer.class).toObject(); 633 | 634 | // => System.XmlException: Can not deserialize: unexpected array at [line:1, column:1] 635 | ``` 636 | 637 | ### toString 638 | 639 | Deserializes the XML string to the specified type and calls the objects toString method. 640 | 641 | ```java 642 | String str = XML.deserialize('ContactFirstLast').setType(Contact.class).toString(); 643 | 644 | // => Contact:{FirstName=First, LastName=Last} 645 | ``` 646 | 647 | ### debug 648 | 649 | Prints the current object using its toString method to the console using the functions executed previously in the chain. Multiple debugs can be called in the same chain, with executing independently of the other. 650 | 651 | ```java 652 | class CompleteDate { 653 | public Date date_xyz; 654 | public Time time_xyz; 655 | } 656 | 657 | CompleteDate completeDate = (CompleteDate) XML.deserialize( 658 | '' + 659 | ' 2019-01-28' + 660 | ' ' + 661 | '' 662 | ).setType(CompleteDate.class) 663 | .debug() // Debug 1 664 | .setReservedWordSuffix('_xyz') 665 | .debug() // Debug 2 666 | .toObject(); 667 | 668 | // => CompleteDate:[date_xyz=null, time_xyz=null] 669 | // => CompleteDate:[date_xyz=2019-01-28 00:00:00, time_xyz=11:00:09.000Z] 670 | ``` 671 | 672 | ### setReservedWordSuffix 673 | 674 | When the XML data contains reserved words in Apex, the default suffix of **_x** is added. However, if you would like to add your own custom suffix, you can do so via the following: 675 | 676 | ```java 677 | class CompleteDate { 678 | public Date date_xyz; 679 | public Time time_xyz; 680 | } 681 | 682 | CompleteDate completeDate = (CompleteDate) XML.deserialize( 683 | '' + 684 | ' 2019-01-28' + 685 | ' ' + 686 | '' 687 | ).setType(CompleteDate.class) 688 | .debug() 689 | .setReservedWordSuffix('_xyz') 690 | .debug() 691 | .toObject(); 692 | ``` 693 | 694 | For a list of these, please see the reserved word table [here](https://developer.salesforce.com/docs/atlas.en-us.apexref.meta/apexref/apex_reserved_words.htm). 695 | 696 | ### filterNamespace 697 | 698 | Clark notations can be used to specify namespaces and local names. These nodes can be filtered by defining the local names you would like to keep. 699 | 700 | For more information on Clark notation please see the link [here](http://www.jclark.com/xml/xmlns.htm). 701 | 702 | ```java 703 | Map> embeddedMap = (Map>) XML.deserialize( 704 | '' + 705 | ' val2' + 706 | ' val1' + 707 | '' 708 | ) 709 | .setType(Map>.class) 710 | .filterNamespace('localname2') 711 | .toObject(); 712 | 713 | // => {element={{http://example.org}localname2=val2}} 714 | ``` 715 | 716 | ### showNamespaces (default) / hideNamespaces 717 | 718 | Namespaces are by default preserved when deserializing. If these are required to be hidden, this can be done by calling the **hideNamespaces** method. 719 | 720 | ```java 721 | Map> embeddedMap = (Map>) XML.deserialize( 722 | '' + 723 | ' val2' + 724 | ' val1' + 725 | '' 726 | ) 727 | .setType(Map>.class) 728 | .hideNamespaces() 729 | .toObject(); 730 | 731 | // => {element={localname1=val1, localname2=val2}} 732 | ``` 733 | 734 | ### addArrayNode / setArrayNodes 735 | 736 | As reflection is not fully supported in Apex, the library cannot detect if a element should be treated as a List or Map. As a solution, we can specify what nodes should be treated as an array when deserialized. 737 | 738 | In the below example, the books node is detected as a map as there is only one child node. If the **setArrayNodes**, the deserialization will treat the books node as an array. 739 | 740 | ```java 741 | Library library = (Library) XML.deserialize( 742 | '' + 743 | ' ' + 744 | ' ' + 745 | ' ' + 746 | ' title5' + 747 | ' ' + 748 | ' ' + 749 | ' ' + 750 | ' ' + 751 | ' ' + 752 | '', Library.class) 753 | .setArrayNodes(new Set{'book'}).toObject(); 754 | ``` 755 | 756 | 757 | ### setRootNode 758 | 759 | In the situations there are nodes that we want to ignore, we can specify a Xpath decendant to start from. 760 | 761 | In the below example, the books node is detected as a map as there is only one child node. If the **setArrayNodes**, the deserialization will treat the books node as an array. 762 | 763 | ```java 764 | Map objElements = (Map) XML.deserialize( 765 | '' + 766 | ' ' + 767 | ' ' + 768 | ' First' + 769 | ' Last' + 770 | ' ' + 771 | ' ' + 772 | '' 773 | ) 774 | .setRootNode('/Response/Body/Fields') 775 | .toObject(); 776 | 777 | // => {element1=First, element2=Last} 778 | ``` 779 | 780 | 781 | ### sanitize (default) / unsanitize 782 | 783 | When reading an XML string that is very large it's possible to disable the sanization in order to overcome regex expression limits, and help reduce heap and CPU limits. 784 | 785 | Note: This should only be changed if you are confident that both the XML string is minified and the node names do not contain any reserved words. 786 | 787 | An example of how this can be changed, can be seen below: 788 | 789 | ```java 790 | Map objElements = (Map) XML.deserialize(myVeryBigXMLString) 791 | .unsanitize() 792 | .toObject(); 793 | ``` 794 | 795 | 796 | ## Other Cool Things 797 | 798 | ### Deserialization Interfaces 799 | 800 | By default, deserialization is handled through the native Apex JSON functionality. However, if the apex object extends the XML.Deserialize interface, the default behaviour will be overridden and the xmlDeserialize method is called. 801 | 802 | The method will be passed either a list, map or primitive data type based on what is located inside current the XML node. 803 | 804 | An example of this can be seen below: 805 | 806 | ```java 807 | public class Book implements XML.Deserializable { 808 | public String title; 809 | public String price; 810 | 811 | public Book xmlDeserialize(Object obj) 812 | { 813 | title = (String) ((Map) objMap).get('title'); 814 | price = (String) ((Map) objMap).get('price'); 815 | return this; 816 | } 817 | } 818 | 819 | Book book = (Book) XML.deserialize('Title ABC23.00', Book.class); 820 | ``` 821 | 822 | ### Self Keyword 823 | 824 | When needing to serialize a text node with attributes, this is possible using a class with both the attributes and self variables. 825 | In the example below, we are creating part of an html table. Here we call the embed attribute method and set self as an Object, however, this can be any type. 826 | 827 | ```java 828 | class TableRow { 829 | List td = new List{new TableCell(123), new TableCell('abc')}; 830 | } 831 | 832 | class TableCell { 833 | Map attributes = new Map{ 834 | 'style' => 'padding:0;' 835 | }; 836 | Object self; 837 | 838 | public TableCell(Object value) { 839 | this.self = value; 840 | } 841 | } 842 | 843 | String xmlString = XML.serialize(new TableRow()).setRootNodeName('tr').embedAttributes().beautify().toString(); 844 | System.debug(xmlString); 845 | 846 | ``` 847 | 848 | The result is a table row containing cells with attributes. 849 | 850 | ```xml 851 | 852 | 123 853 | abc 854 | 855 | ``` 856 | 857 | ### Node Name Sanatization 858 | 859 | There are a lot of requirements when it comes to handling XML encoding both within the names and values themselves. In the example of node names, these cannot start with a number. To prevent errors, keys starting with numbers are automatically prefixed with an underscore. 860 | 861 | ```apex 862 | String xmlString = XML.serialize(new Map{ 863 | '12345' => 'value' 864 | }).beautify().toString(); 865 | ``` 866 | 867 | ```xml 868 | 869 | <_12345>value 870 | 871 | ``` 872 | 873 | ### Value Encoding 874 | 875 | In addition to node name sanitization, text values containing special characters are required to be encoded. 876 | 877 | Please see an example of this working: 878 | 879 | ```java 880 | String xmlString = XML.serialize(new Map{ 881 | 'key' => ' 887 | &_lt;value&_amp; 888 | 889 | ``` 890 | 891 | ## Limitations 892 | 893 | Unfortunately, Apex does not fully support class reflection. This limits the ability to abstract and support additional functionality that would otherwise be possible in other languages. However, as updates are being made the time, the library will be updated accordingly. 894 | 895 | ## Contributing 896 | 897 | If you would like to extend or make changes to the library, it would be great to share it with others. 898 | Just make sure that any changes follow the current formatting standards, are covered under unit tested and are well documented. 899 | -------------------------------------------------------------------------------- /config/project-scratch-def.json: -------------------------------------------------------------------------------- 1 | { 2 | "orgName": "Demo company", 3 | "edition": "Developer", 4 | "features": ["EnableSetPasswordInApi"], 5 | "settings": { 6 | "lightningExperienceSettings": { 7 | "enableS1DesktopEnabled": true 8 | }, 9 | "mobileSettings": { 10 | "enableS1EncryptedStoragePref2": false 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /force-app/main/default/classes/XML.cls: -------------------------------------------------------------------------------- 1 | /* 2 | MIT License 3 | 4 | Copyright (c) 2020 zabroseric 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | */ 24 | 25 | /** 26 | * An Apex library that works to solve all xml serialization and deserialization issues. 27 | */ 28 | public class XML { 29 | 30 | private static final String DEFAULT_ROOT_NODE_NAME = 'elements'; 31 | private static final String DEFAULT_NODE_NAME = 'element'; 32 | private static final List RESERVED_WORDS = new List{ 33 | 'abstract', 'activate', 'and', 'any', 'array', 'as', 'asc', 'autonomous', 'begin', 'bigdecimal', 'blob', 'boolean', 'break', 'bulk', 'by', 'byte', 'case', 'cast', 'catch', 'char', 'class', 'collect', 'commit', 'const', 'continue', 'currency', 'date', 'datetime', 'decimal', 'default', 'delete', 'desc', 'do', 'double', 'else', 'end', 'enum', 'exception', 'exit', 'export', 'extends', 'false', 'final', 'finally', 'float', 'for', 'from', 'global', 'goto', 'group', 'having', 'hint', 'if', 'implements', 'import', 'in', 'inner', 'insert', 'instanceof', 'int', 'integer', 'interface', 'into', 'join', 'like', 'limit', 'list', 'long', 'loop', 'map', 'merge', 'new', 'not', 'null', 'nulls', 'number', 'object', 'of', 'on', 'or', 'outer', 'override', 'package', 'parallel', 'pragma', 'private', 'protected', 'public', 'retrieve', 'return', 'rollback', 'select', 'set', 'short', 'sObject', 'sort', 'static', 'string', 'super', 'switch', 'synchronized', 'system', 'testmethod', 'then', 'this', 'throw', 'time', 'transaction', 'trigger', 'true', 'try', 'undelete', 'update', 'upsert', 'using', 'virtual', 'void', 'webservice', 'when', 'where', 'while' 34 | }; 35 | 36 | /** 37 | * Initialises the writer class and exposes the methods provided 38 | * for serialization of an object. 39 | * 40 | * @param obj Any object that is needing to be serialized 41 | * 42 | * @return An internal Writer class that provides access to additional functionality via method chaining 43 | */ 44 | public static Writer serialize(Object obj) 45 | { 46 | return (new Writer()).write(obj); 47 | } 48 | 49 | /** 50 | * Initialises the reader class and exposes the methods provided 51 | * for deserialization of a string. 52 | * 53 | * @param xmlString An xml string that is needing to be deserialized 54 | * 55 | * @return An internal Reader class that provides access to additional functionality via method chaining 56 | */ 57 | public static Reader deserialize(String xmlString) 58 | { 59 | return (new Reader()).read(xmlString); 60 | } 61 | 62 | /** 63 | * Initialises the reader class and exposes the methods provided 64 | * for deserialization of a string. 65 | * 66 | * The output will be casted to the apex type / class provided. 67 | * 68 | * @param xmlString An xml string that is needing to be deserialized 69 | * @param apexType The type of object that the deserialization will be cast as 70 | * 71 | * @return An internal Reader class that provides access to additional functionality via method chaining 72 | */ 73 | public static Reader deserialize(String xmlString, Type apexType) 74 | { 75 | return (new Reader()).read(xmlString).setType(apexType); 76 | } 77 | 78 | /** 79 | * =============================================================================== 80 | * The entry for the XML Writer class that serializes sobject, objects, lists, maps 81 | * and other types to a string. 82 | * 83 | * The class uses function chaining to write to the variables and does 84 | * not expose these directly. 85 | * =============================================================================== 86 | */ 87 | public class Writer { 88 | 89 | private Boolean suppressNulls = false; 90 | private String rootNodeName = DEFAULT_ROOT_NODE_NAME; 91 | private String elementNodeName = DEFAULT_NODE_NAME; 92 | private Boolean showEncoding = false; 93 | private Map rootAttributes = new Map(); 94 | private final Map namespaces = new Map(); 95 | private Boolean detectRootNodeName = true; 96 | private Boolean beautify = false; 97 | private Boolean embedAttributes = false; 98 | 99 | private ObjectWrapper obj; 100 | 101 | /** 102 | * Pass the object for serialization. 103 | * 104 | * @param obj Any object that is needing to be serialized 105 | * 106 | * @return An internal Writer class that provides access to additional functionality via method chaining 107 | */ 108 | public Writer write(Object obj) 109 | { 110 | this.obj = (new ObjectWrapper(obj)); 111 | return this; 112 | } 113 | 114 | /** 115 | * Converts the object to a string. 116 | * 117 | * @return An internal Writer class that provides access to additional functionality via method chaining 118 | */ 119 | public override String toString() 120 | { 121 | Dom.Document document = parse(); 122 | String xmlString = document.toXmlString(); 123 | 124 | // If we chose to hide the encoding, remove it. 125 | if (!showEncoding) { 126 | xmlString = xmlString.replace('', ''); 127 | } 128 | 129 | // Flatten the elements if possible, so Elements / Element => Element 130 | if (document.getRootElement().getChildElements().size() == 1 && rootNodeName == DEFAULT_ROOT_NODE_NAME) { 131 | xmlString = xmlString 132 | .replaceAll('<(/?)(' + rootNodeName + '|' + elementNodeName + ')([^>]*)>' + 133 | '\\s*(<(/?)(' + rootNodeName + '|' + elementNodeName + ')([^>]*)>)?', 134 | '<$1' + elementNodeName + '$3>'); 135 | } 136 | 137 | // If there are rootAttributes in the closing nodes, remove them. 138 | xmlString = xmlString.replaceAll(' ]+) [^>]+="[^>]+">', ''); 139 | 140 | return beautify ? formatString(xmlString) : xmlString; 141 | } 142 | 143 | /** 144 | * Converts the object to a base 64 encoded string. 145 | * 146 | * @return An internal Writer class that provides access to additional functionality via method chaining 147 | */ 148 | public String toBase64() 149 | { 150 | return EncodingUtil.base64Encode(Blob.valueOf(toString())); 151 | } 152 | 153 | /** 154 | * Debugs the resulting xml string. 155 | * 156 | * @return An internal Writer class that provides access to additional functionality via method chaining 157 | */ 158 | public Writer debug() 159 | { 160 | System.debug('\r\n' + toString()); 161 | return this; 162 | } 163 | 164 | /** 165 | * Ensure any nulls values are omitted in the resulting xml string. 166 | * 167 | * @return An internal Writer class that provides access to additional functionality via method chaining 168 | */ 169 | public Writer suppressNulls() 170 | { 171 | this.suppressNulls = true; 172 | return this; 173 | } 174 | 175 | /** 176 | * Ensure any nulls values are kept in the resulting xml string. 177 | * 178 | * @return An internal Writer class that provides access to additional functionality via method chaining 179 | */ 180 | public Writer showNulls() 181 | { 182 | this.suppressNulls = false; 183 | return this; 184 | } 185 | 186 | /** 187 | * Sets the output to be in a nicely formatted string. 188 | * 189 | * @return An internal Writer class that provides access to additional functionality via method chaining 190 | */ 191 | public Writer beautify() 192 | { 193 | this.beautify = true; 194 | return this; 195 | } 196 | 197 | /** 198 | * Sets the output to be in a minified string. 199 | * 200 | * @return An internal Writer class that provides access to additional functionality via method chaining 201 | */ 202 | public Writer minify() 203 | { 204 | this.beautify = false; 205 | return this; 206 | } 207 | 208 | /** 209 | * Shows xml encoding at the beginning of the xml. 210 | * 211 | * @return An internal Writer class that provides access to additional functionality via method chaining 212 | */ 213 | public Writer showEncoding() 214 | { 215 | this.showEncoding = true; 216 | return this; 217 | } 218 | 219 | /** 220 | * Hides xml encoding at the beginning of the xml. 221 | * 222 | * @return An internal Writer class that provides access to additional functionality via method chaining 223 | */ 224 | public Writer hideEncoding() 225 | { 226 | this.showEncoding = false; 227 | return this; 228 | } 229 | 230 | /** 231 | * Sets an attribute on the root node. 232 | * 233 | * @param key The attribute key 234 | * @param value The attribute value that is placed in quotes 235 | * 236 | * @return An internal Writer class that provides access to additional functionality via method chaining 237 | */ 238 | public Writer addRootAttribute(String key, String value) 239 | { 240 | this.rootAttributes.put(key, value); 241 | return this; 242 | } 243 | 244 | /** 245 | * Sets attributes on the root node. 246 | * 247 | * @param rootAttributes A map of attributes with key, value pairs 248 | * 249 | * @return An internal Writer class that provides access to additional functionality via method chaining 250 | */ 251 | public Writer setRootAttributes(Map rootAttributes) 252 | { 253 | this.rootAttributes = rootAttributes; 254 | return this; 255 | } 256 | 257 | /** 258 | * Sets a namespace to be used. 259 | * 260 | * @param uri The namespace uri that will be set in the root node 261 | * @param prefix The prefix set on all nodes that mention the uri 262 | * 263 | * @return An internal Writer class that provides access to additional functionality via method chaining 264 | */ 265 | public Writer addNamespace(String uri, String prefix) 266 | { 267 | this.namespaces.put(uri, prefix); 268 | this.rootAttributes.put('xmlns:' + prefix, uri); 269 | return this; 270 | } 271 | 272 | /** 273 | * Sets namespace to be used. 274 | * 275 | * @param namespaces A map of namespaces with uri, prefix pairs 276 | * 277 | * @return An internal Writer class that provides access to additional functionality via method chaining 278 | */ 279 | public Writer setNamespaces(Map namespaces) 280 | { 281 | for (String uri : namespaces.keySet()) { 282 | addNamespace(uri, namespaces.get(uri)); 283 | } 284 | return this; 285 | } 286 | 287 | /** 288 | * Sets the root node name for the XML. 289 | * Note: Setting this will disable the automatic detection of the objects. 290 | * 291 | * @param rootNode The name of the root node that will be set 292 | * 293 | * @return An internal Writer class that provides access to additional functionality via method chaining 294 | */ 295 | public Writer setRootNodeName(String rootNode) 296 | { 297 | this.rootNodeName = rootNode; 298 | this.detectRootNodeName = false; 299 | return this; 300 | } 301 | 302 | /** 303 | * Sets any attributes to be serialized into the node itself. 304 | * 305 | * @return An internal Writer class that provides access to additional functionality via method chaining 306 | */ 307 | public Writer embedAttributes() 308 | { 309 | this.embedAttributes = true; 310 | return this; 311 | } 312 | 313 | /** 314 | * Sets any attributes nodes to be serialized into a separate child node. 315 | * 316 | * @return An internal Writer class that provides access to additional functionality via method chaining 317 | */ 318 | public Writer splitAttributes() 319 | { 320 | this.embedAttributes = false; 321 | return this; 322 | } 323 | 324 | /** 325 | * Formats the xml string. 326 | * 327 | * @param xmlString An xml encoded string 328 | * 329 | * @return A formatted version of the xml encoded string 330 | */ 331 | private String formatString(String xmlString) 332 | { 333 | List xmlStringSplit = xmlString.split('><'); 334 | String xmlStringFormatted = ''; 335 | Integer indents = 0; 336 | 337 | // Iterate over the individual pieces of the split nodes 338 | // detecting how many indents are required on every line. 339 | for (String split : xmlStringSplit) { 340 | if (split.left(1) == '/') { 341 | indents--; 342 | } 343 | 344 | for (Integer i = 0; i < indents; i++) { 345 | xmlStringFormatted += ' '; 346 | } 347 | xmlStringFormatted += '<' + split + '>\r\n'; 348 | 349 | if (!split.contains('>') && !split.contains(' values, Dom.XmlNode xmlNode) 450 | { 451 | Boolean isEmptyChild = parseMulti(new List(values.keySet()), values.values(), xmlNode); 452 | if (isEmptyChild && xmlNode.getParent() != null) { 453 | xmlNode.getParent().removeChild(xmlNode); 454 | } 455 | return isEmptyChild; 456 | } 457 | 458 | /** 459 | * Parse a list for iteration, if we have chosen to empty nodes 460 | * and the resulting node from children are empty, remove them. 461 | * 462 | * @param values Values from the object that was parsed 463 | * @param key A key from which the object was set 464 | * @param xmlNode The existing XML document that has so far been created 465 | * 466 | * @return A boolean if the list is empty 467 | */ 468 | private Boolean parseList(List values, String key, Dom.XmlNode xmlNode) 469 | { 470 | Boolean isEmptyChild = parseMulti(new List{ 471 | key 472 | }, values, xmlNode); 473 | if (isEmptyChild && xmlNode.getParent() != null) { 474 | xmlNode.getParent().removeChild(xmlNode); 475 | } 476 | return isEmptyChild; 477 | } 478 | 479 | /** 480 | * Parse any type for iteration, this will iterate over the other parsing 481 | * methods for each of the nested elements. 482 | * 483 | * @param keys Keys from the object that was parsed 484 | * @param values Values from the object that was parsed 485 | * @param xmlNode The existing XML document that has so far been created 486 | * 487 | * @return A boolean if the list is empty 488 | */ 489 | private Boolean parseMulti(List keys, List values, Dom.XmlNode xmlNode) 490 | { 491 | Integer notEmptyNumber = 0; 492 | 493 | for (Integer i = 0; i < values.size(); i++) { 494 | // If we have a list, keep using the same key. 495 | String key = setNamespace(keys.get(keys.size() - 1 > i ? i : keys.size() - 1)); 496 | 497 | // If the node name starts with a numeric value, put an underscore at the beginning 498 | // This is useful when passing object maps so that the xml remains valid. 499 | key = key.left(1).isNumeric() ? '_' + key : key; 500 | 501 | ObjectWrapper obj = new ObjectWrapper(values.get(i)); 502 | Dom.XmlNode childNode; 503 | 504 | // If we have chosen to embed attributes, add them to the node. 505 | if (embedAttributes && key == 'attributes') { 506 | continue; 507 | } 508 | 509 | // VALIDATION: 510 | // Ensure we only have one either text or a child node when using the self keyword. 511 | if ((key == 'self' && !xmlNode.getChildren().isEmpty()) || ((obj.instanceOfMap() || obj.instanceOfList()) && !String.isEmpty(xmlNode.getText()))) { 512 | System.debug(xmlNode.getText()); 513 | throw new XmlException('The object contains both the self keyword, and other child elements, please remove on of these.'); 514 | } 515 | 516 | // If we have an empty list and have chosen not to suppress 517 | // nulls, ensure this is added to the xml. 518 | if (obj.instanceOfList() && obj.isEmpty() && !suppressNulls) { 519 | childNode = xmlNode.addChildElement(key, null, null); 520 | } 521 | 522 | // Convert the lists / maps, iterate over these and determine if the 523 | // result was empty. 524 | else if (obj.instanceOfList()) { 525 | notEmptyNumber += parseList(obj.toList(), key, xmlNode) ? 0 : 1; 526 | } else if (obj.instanceOfMap()) { 527 | childNode = xmlNode.addChildElement(key, null, null); 528 | notEmptyNumber += parseMap(obj.toMap(), childNode) ? 0 : 1; 529 | } else if (!obj.isEmpty() && key == 'self' && xmlNode.getChildren().isEmpty()) { 530 | childNode = xmlNode.addTextNode(parsePrimitive(obj)); 531 | notEmptyNumber += 1; 532 | } 533 | // If we have a value don't iterate and just add the node. 534 | else if (!obj.isEmpty()) { 535 | childNode = xmlNode 536 | .addChildElement(key, null, null) 537 | .addTextNode(parsePrimitive(obj)); 538 | notEmptyNumber += 1; 539 | } 540 | 541 | // If we have chosen not to suppress nulls, ensure an empty node is there. 542 | else if (!suppressNulls) { 543 | childNode = xmlNode.addChildElement(key, null, null); 544 | notEmptyNumber += 1; 545 | } 546 | 547 | // If we have chosen to embed attributes, add them to the node we have just added. 548 | if (embedAttributes && obj.hasAttributes()) { 549 | for (String attrKey : obj.getAttributes().keySet()) { 550 | childNode.setAttribute(attrKey, String.valueOf(obj.getAttributes().get(attrKey))); 551 | } 552 | } 553 | } 554 | 555 | // Return if the result was empty or not. 556 | return notEmptyNumber == 0 && suppressNulls; 557 | } 558 | 559 | /** 560 | * Replaces the node name with the required prefix. 561 | * 562 | * @param nodeName The original name of the node 563 | * 564 | * @return A string of the new node name after considering the namespaces 565 | */ 566 | private String setNamespace(String nodeName) 567 | { 568 | if (!nodeName.contains('{')) { 569 | return nodeName; 570 | } 571 | 572 | for (String uri : namespaces.keySet()) { 573 | if (nodeName.contains('{' + uri + '}')) { 574 | return nodeName.replace('{' + uri + '}', namespaces.get(uri) + ':'); 575 | } 576 | } 577 | 578 | // If we can't find the node, just remove the clark notation. 579 | return nodeName.substringAfter('}'); 580 | } 581 | } 582 | 583 | /** 584 | * =============================================================================== 585 | * The entry for the XML Reader class that deserializes to objects, lists, maps 586 | * and other types from a string. 587 | * 588 | * The class uses function chaining to write to the variables and does 589 | * not expose these directly. 590 | * =============================================================================== 591 | */ 592 | public class Reader { 593 | 594 | private final Utils utils = new Utils(); 595 | private String xmlString; 596 | private Type apexType; 597 | private String apexTypeName; 598 | private Set arrayNodes = new Set(); 599 | private Boolean showNamespaces = true; 600 | private Boolean sanitize = true; 601 | private List rootNode = new List(); 602 | private String filterNamespace; 603 | private String reservedWordSuffix = '_x'; 604 | 605 | /** 606 | * Pass the xml string for deserialization. 607 | * 608 | * @param xmlString An XML encoded string 609 | * 610 | * @return An internal Reader class that provides access to additional functionality via method chaining 611 | */ 612 | public Reader read(String xmlString) 613 | { 614 | if (xmlString == null) { 615 | return this; 616 | } 617 | 618 | this.xmlString = xmlString; 619 | return this; 620 | } 621 | 622 | /** 623 | * Converts the xml string to an Object. 624 | * 625 | * @return An internal Reader class that provides access to additional functionality via method chaining 626 | */ 627 | public Object toObject() 628 | { 629 | try { 630 | return toObjectInternal(); 631 | } catch (JSONException e) { 632 | if (e.getMessage().contains('Expected List')) { 633 | throw new XmlException('An array node has not been correctly set by value ' + 634 | e.getMessage().replaceAll('.+(".+").+', '$1') 635 | ); 636 | } 637 | throw new XmlException(e.getMessage()); 638 | } 639 | } 640 | 641 | /** 642 | * Converts the xml string to an Object. 643 | * 644 | * @return An internal Reader class that provides access to additional functionality via method chaining 645 | */ 646 | private Object toObjectInternal() 647 | { 648 | Map objectMap; 649 | 650 | try { 651 | objectMap = parse(); 652 | } catch (XmlPropagateException e) { 653 | throw e; 654 | } catch (Exception e) { 655 | throw new XmlException('The XML string is invalid, value: ' + xmlString); 656 | } 657 | 658 | // If we haven't passed a type, return the map we have. 659 | if (apexType == null) { 660 | return objectMap; 661 | } 662 | 663 | // If the type is deserializable and we have found the type as a key in the map, 664 | // return the value of this key. 665 | else if (((Type) Deserializable.class).isAssignableFrom(apexType) && utils.containsCaseInsensitive(objectMap, apexTypeName)) { 666 | return ((Deserializable) apexType.newInstance()).xmlDeserialize((Object) utils.getObjCaseInsensitive(objectMap, apexTypeName)); 667 | } 668 | 669 | // Otherwise if the type is deserializable, pass the entire map. 670 | else if (((Type) Deserializable.class).isAssignableFrom(apexType)) { 671 | return ((Deserializable) apexType.newInstance()).xmlDeserialize(objectMap); 672 | } 673 | 674 | // If the apex type is a partial key with the map use that, this is useful for nested classes. 675 | else if (utils.containsCaseInsensitive(objectMap, apexTypeName)) { 676 | return JSON.deserialize(JSON.serialize(utils.getObjCaseInsensitive(objectMap, apexTypeName)), apexType); 677 | } 678 | 679 | // If the apex type we have is a list, get the list from the map. 680 | else if (apexType.toString().startsWith('List')) { 681 | return JSON.deserialize('[' + JSON.serialize(objectMap).substringAfter('[').substringBeforeLast(']') + ']', apexType); 682 | } 683 | 684 | // Otherwise just use the base deserialization. 685 | else { 686 | return JSON.deserialize(JSON.serialize(objectMap), apexType); 687 | } 688 | } 689 | 690 | /** 691 | * Converts the xml string to an Object and return this as a string. 692 | * 693 | * @return An internal Reader class that provides access to additional functionality via method chaining 694 | */ 695 | public override String toString() 696 | { 697 | return String.valueOf(toObject()); 698 | } 699 | 700 | /** 701 | * Converts the xml string to an Object and debug it. 702 | * 703 | * @return An internal Reader class that provides access to additional functionality via method chaining 704 | */ 705 | public Reader debug() 706 | { 707 | System.debug(toString()); 708 | return this; 709 | } 710 | 711 | /** 712 | * When one of the nodes contains a reserved word, this node will be renamed and a suffix 713 | * added so that it can be correctly managed. 714 | * 715 | * @param reservedWordSuffix A suffix string 716 | * 717 | * @return An internal Reader class that provides access to additional functionality via method chaining 718 | */ 719 | public Reader setReservedWordSuffix(String reservedWordSuffix) 720 | { 721 | this.reservedWordSuffix = reservedWordSuffix; 722 | System.debug(this.reservedWordSuffix); 723 | return this; 724 | } 725 | 726 | /** 727 | * Sets the root node that the deserialization should pass from. 728 | * 729 | * @param rootNode The name of the root node that will be set 730 | * 731 | * @return An internal Reader class that provides access to additional functionality via method chaining 732 | */ 733 | public Reader setRootNode(String rootNode) 734 | { 735 | List rootNodeArray = (rootNode != null ? rootNode : '').split('/'); 736 | for (Integer i = rootNodeArray.size() - 1; i >= 0; i--) { 737 | if (String.isBlank(rootNodeArray.get(i))) { 738 | rootNodeArray.remove(i); 739 | } 740 | } 741 | 742 | this.rootNode = rootNodeArray; 743 | return this; 744 | } 745 | 746 | /** 747 | * Set the type of object the xml string will be converted to. 748 | * If not provided, the object will be untyped. 749 | * 750 | * @param apexType Any object that we want converted to 751 | * 752 | * @return An internal Reader class that provides access to additional functionality via method chaining 753 | */ 754 | public Reader setType(Type apexType) 755 | { 756 | this.apexType = apexType; 757 | 758 | if (apexType != null) {//fixes cases with namespace namespace.class.innerclass 759 | apexTypeName = apexType.getName(); 760 | if (apexTypeName.contains('.')) apexTypeName = apexTypeName.substringAfterLast('.'); 761 | } 762 | 763 | return this; 764 | } 765 | 766 | /** 767 | * Set xml nodes that should be treated as an array, even 768 | * if they have not been explicitly detected as an array. 769 | * 770 | * @param arrayNodes A set of node names 771 | * 772 | * @return An internal Reader class that provides access to additional functionality via method chaining 773 | */ 774 | public Reader setArrayNodes(Set arrayNodes) 775 | { 776 | this.arrayNodes = arrayNodes; 777 | return this; 778 | } 779 | 780 | /** 781 | * Set an xml node that should be treated as an array, even 782 | * if they have not been explicitly detected as an array. 783 | * 784 | * @param node A string containing one node name 785 | * 786 | * @return An internal Reader class that provides access to additional functionality via method chaining 787 | */ 788 | public Reader addArrayNode(String node) 789 | { 790 | this.arrayNodes.add(node); 791 | return this; 792 | } 793 | 794 | /** 795 | * Filter the namespaces so that either namespaces that are empty 796 | * or that do not have a namespace are returned. 797 | * 798 | * @param filterNamespace A string value of the namespace 799 | * 800 | * @return An internal Reader class that provides access to additional functionality via method chaining 801 | */ 802 | public Reader filterNamespace(String filterNamespace) 803 | { 804 | this.filterNamespace = filterNamespace; 805 | return this; 806 | } 807 | 808 | /** 809 | * Shows namespaces as clark notations. 810 | * 811 | * @return An internal Reader class that provides access to additional functionality via method chaining 812 | */ 813 | public Reader showNamespaces() 814 | { 815 | this.showNamespaces = true; 816 | return this; 817 | } 818 | 819 | /** 820 | * Hide namespaces and show only the node name. 821 | * 822 | * @return An internal Reader class that provides access to additional functionality via method chaining 823 | */ 824 | public Reader hideNamespaces() 825 | { 826 | this.showNamespaces = false; 827 | return this; 828 | } 829 | 830 | /** 831 | * Ensures the xml string is formatted correctly for the XMLReader class 832 | * and that all reserved words are correctly replaced with a suffix. 833 | * 834 | * @return An internal Reader class that provides access to additional functionality via method chaining 835 | */ 836 | public Reader sanitize() 837 | { 838 | this.sanitize = true; 839 | return this; 840 | } 841 | 842 | /** 843 | * Disables sanitization for larger XML strings. 844 | * 845 | * Note: This should only be set if you are confident that the XML string is both minified and does not 846 | * contain reserved words. 847 | * 848 | * @return An internal Reader class that provides access to additional functionality via method chaining 849 | */ 850 | public Reader unsanitize() 851 | { 852 | this.sanitize = false; 853 | return this; 854 | } 855 | 856 | /** 857 | * Entry points for the reader toObject method. 858 | * 859 | * @return The resulting map from parsing an XML object 860 | */ 861 | private Map parse() 862 | { 863 | // Sanitize the xml string before we start passing it. 864 | String xmlString = this.xmlString; 865 | 866 | if (sanitize) { 867 | xmlString = utils 868 | .replaceXMLReservedWords(xmlString, reservedWordSuffix) 869 | .replaceAll('>\\s+<', '><'); 870 | } 871 | 872 | XmlStreamReader xmlStreamReader = new XmlStreamReader(xmlString); 873 | xmlStreamReader.setCoalescing(true); 874 | return (Map) getNestedNodeValue(parseNested(xmlStreamReader, null), this.rootNode, 0); 875 | } 876 | 877 | /** 878 | * Recursively calls itself to drill into a nested value within an object. 879 | * 880 | * @param node The current node that is needing to be accessed. 881 | * @param path A list of paths comma delimited. 882 | * @param index The current index of the path 883 | * 884 | * @return The resulting object that has been parsed 885 | */ 886 | private Map getNestedNodeValue(Object node, List path, Integer index) 887 | { 888 | ObjectWrapper objectWrapper = new ObjectWrapper(node); 889 | Boolean isOutOfBounds = index >= path.size(); 890 | String pathCurrent = ''; 891 | 892 | if (!isOutOfBounds) { 893 | pathCurrent = path.get(index); 894 | } else if (index > 0) { 895 | pathCurrent = path.get(index - 1); 896 | } else { 897 | pathCurrent = DEFAULT_NODE_NAME; 898 | } 899 | 900 | 901 | // If the path is blank we are at our last entry. 902 | if (isOutOfBounds) { 903 | if (objectWrapper.instanceOfMap()) { 904 | return (Map) node; 905 | } 906 | return new Map{ 907 | pathCurrent => node 908 | }; 909 | } 910 | 911 | // Otherwise drill into the object further. 912 | if (objectWrapper.instanceOfMap()) { 913 | return getNestedNodeValue(((Map) node).get(pathCurrent), path, index + 1); 914 | } 915 | 916 | throw new XmlPropagateException('Cannot drill into the node "' + pathCurrent + '" set by the root node path /' + String.join(this.rootNode, '/')); 917 | } 918 | 919 | /** 920 | * Uses to iterate over the current reader pointer and recursively call 921 | * itself as we go down the DOM tree. 922 | * 923 | * @param reader The existing xml reader class and current position 924 | * @param elementName The current element that is being read 925 | * 926 | * @return The resulting object that has been parsed 927 | */ 928 | private Object parseNested(XmlStreamReader reader, String elementName) 929 | { 930 | Map objectMap = new Map(); 931 | Object objectCurrent; 932 | String nodeName; 933 | 934 | Boolean hasNext = reader.hasNext(); 935 | if (hasNext) { 936 | reader.next(); 937 | } 938 | 939 | while (hasNext) { 940 | // Add the clark notation to the node name if we have a namespace. 941 | nodeName = (showNamespaces && reader.getNamespace() != null ? '{' + reader.getNamespace() + '}' : '') + reader.getLocalName(); 942 | 943 | // If we have a start element add this. 944 | if (reader.getEventType() == XmlTag.START_ELEMENT) { 945 | // Add the attributes as a separate node to support deserialization. 946 | Map attributeMap = new Map(); 947 | if (reader.getAttributeCount() > 0) { 948 | for (Integer i = 0; i < reader.getAttributeCount(); i++) { 949 | attributeMap.put(reader.getAttributeLocalName(i), reader.getAttributeValueAt(i)); 950 | } 951 | } 952 | 953 | objectCurrent = parseNested(reader, nodeName); 954 | 955 | // If we have a map as our current node, add the attributes node / merge the values. 956 | if (objectCurrent instanceof Map && !attributeMap.isEmpty()) { 957 | Map objectCurrentMap = ((Map) objectCurrent); 958 | if (objectCurrentMap.containsKey('attributes')) { 959 | ((Map) objectCurrentMap.get('attributes')).putAll(attributeMap); 960 | } else { 961 | objectCurrentMap.put('attributes', attributeMap); 962 | } 963 | } 964 | 965 | // If we haven't come across this node, and we should make it array, create one. 966 | if (!objectMap.containsKey(nodeName) && arrayNodes.contains(nodeName)) { 967 | objectMap.put(nodeName, new List{ 968 | objectCurrent 969 | }); 970 | } 971 | // If we haven't come across this node, by default create a map. 972 | else if (!objectMap.containsKey(nodeName)) { 973 | objectMap.put(nodeName, objectCurrent); 974 | } 975 | // If we have this node, and it's a list add to it. 976 | else if (objectMap.get(nodeName) instanceof List) { 977 | ((List) objectMap.get(nodeName)).add(objectCurrent); 978 | } 979 | // Otherwise turn our map into a list. 980 | else { 981 | objectMap.put(nodeName, new List{ 982 | objectMap.get(nodeName), 983 | objectCurrent 984 | }); 985 | } 986 | 987 | // If we have chosen to filter the namespace remove the node we generated. 988 | // This way we still loop through the reader. 989 | if (filterNamespace != null && nodeName.contains('{') && !nodeName.contains(filterNamespace)) { 990 | objectMap.remove(nodeName); 991 | } 992 | 993 | } 994 | 995 | // We we are ending our starting node break this loop. 996 | else if (reader.getEventType() == XmlTag.END_ELEMENT && elementName == reader.getLocalName()) { 997 | break; 998 | } 999 | 1000 | // If we have text return and the namespace is valid, return it. 1001 | else if (reader.getEventType() == XmlTag.CHARACTERS) { 1002 | return parseString(reader.getText()); 1003 | } 1004 | 1005 | hasNext = reader.hasNext(); 1006 | if (hasNext) { 1007 | reader.next(); 1008 | } 1009 | } 1010 | return objectMap.isEmpty() ? null : objectMap; 1011 | } 1012 | 1013 | /** 1014 | * Provides a way to intercept the parsing of strings and overwrite it. 1015 | * 1016 | * @param value A string containing any value. 1017 | * 1018 | * @return An evaluated string value from the object wrapper 1019 | */ 1020 | private Object parseString(String value) 1021 | { 1022 | switch on new XML.ObjectWrapper(value).getTypeName() { 1023 | when 'Boolean' { 1024 | return value == 'true'; 1025 | } 1026 | when 'Null' { 1027 | return null; 1028 | } 1029 | when else { 1030 | return value; 1031 | } 1032 | } 1033 | } 1034 | } 1035 | 1036 | /** 1037 | * =============================================================================== 1038 | * An internal exception class that allows propagation of errors from inner 1039 | * methods. 1040 | * =============================================================================== 1041 | */ 1042 | class XmlPropagateException extends Exception { 1043 | 1044 | } 1045 | 1046 | /** 1047 | * =============================================================================== 1048 | * The Deserializable interface is used when passing the apex type. 1049 | * This allows the parsing of an map, list or string into the method. 1050 | * =============================================================================== 1051 | */ 1052 | public interface Deserializable { 1053 | Object xmlDeserialize(Object objMap); 1054 | } 1055 | 1056 | /** 1057 | * =============================================================================== 1058 | * The Utils class is a set of useful functions that can be used without 1059 | * injecting another library. 1060 | * =============================================================================== 1061 | */ 1062 | private class Utils { 1063 | 1064 | /** 1065 | * Determines if the key is contained within the map via a case-insensitive comparison. 1066 | * 1067 | * @param objs A map of objects being searched 1068 | * @param key The search value 1069 | * 1070 | * @return A boolean if the value has been found 1071 | */ 1072 | public Boolean containsCaseInsensitive(Map objs, String key) 1073 | { 1074 | return this.getObjCaseInsensitive(objs, key) != null; 1075 | } 1076 | 1077 | /** 1078 | * Returns the object of a map via a key that is insensitive. 1079 | * 1080 | * @param objs A map of objects being searched 1081 | * @param key The search value 1082 | * 1083 | * @return The object that has been found, otherwise null will be returned 1084 | */ 1085 | public Object getObjCaseInsensitive(Map objs, String key) 1086 | { 1087 | return objs.get(this.getKeyCaseInsensitive(objs, key)); 1088 | } 1089 | 1090 | /** 1091 | * Returns the key of a map via a key that is insensitive. 1092 | * 1093 | * @param objs A map of objects being searched 1094 | * @param key The search value 1095 | * 1096 | * @return The key of the object that has been found, otherwise null will be returned 1097 | */ 1098 | public String getKeyCaseInsensitive(Map objs, String key) 1099 | { 1100 | return this.getKeySetCaseMap(objs).get(key.toLowerCase()); 1101 | } 1102 | 1103 | /** 1104 | * Returns a key set map in the following format. 1105 | * key lower case => key 1106 | * 1107 | * @param objs A map of objects being searched 1108 | * 1109 | * @return The map that has been parsed with keys lowercase 1110 | */ 1111 | public Map getKeySetCaseMap(Map objs) 1112 | { 1113 | Map keySetCaseMap = new Map(); 1114 | for (String key : objs.keySet()) { 1115 | keySetCaseMap.put(key.toLowerCase(), key); 1116 | } 1117 | return keySetCaseMap; 1118 | } 1119 | 1120 | /** 1121 | * Returns a new XML string that replaces reserved words with a suffix. 1122 | * 1123 | * @param str An xml encoded string 1124 | * @param suffix A string that will be added to the end of the node names 1125 | * 1126 | * @return An xml encoded string with reserved words replaced 1127 | */ 1128 | public String replaceXMLReservedWords(String str, String suffix) 1129 | { 1130 | if (str == null || suffix == null) { 1131 | return str; 1132 | } 1133 | 1134 | for (String reservedWord : RESERVED_WORDS) { 1135 | str = str.replaceAll('(?i)<(/?(' + reservedWord + '))>', '<$1' + suffix + '>'); 1136 | } 1137 | return str; 1138 | } 1139 | } 1140 | 1141 | /** 1142 | * =============================================================================== 1143 | * The ObjectWrapper class is used to apply useful functions on top of the 1144 | * object such as converting the type, detecting the type of object and sanitization. 1145 | * =============================================================================== 1146 | */ 1147 | @TestVisible 1148 | private class ObjectWrapper { 1149 | 1150 | private final Object obj; 1151 | 1152 | /** 1153 | * Parse the object for serialization. 1154 | * 1155 | * @param obj The original object that we want wrapped 1156 | */ 1157 | public ObjectWrapper(Object obj) 1158 | { 1159 | this.obj = obj; 1160 | } 1161 | 1162 | /** 1163 | * Returns true if the current object has list of attributes that are in the form 1164 | * attributes => 1165 | * key => value 1166 | * key => value 1167 | * 1168 | * @return A boolean if the map has an attributes key 1169 | */ 1170 | public Boolean hasAttributes() 1171 | { 1172 | return !getAttributes().isEmpty(); 1173 | } 1174 | 1175 | /** 1176 | * Returns a map if the current object has list of attributes that are in the form 1177 | * attributes => 1178 | * key => value 1179 | * key => value 1180 | * 1181 | * @return A map of all attributes 1182 | */ 1183 | public Map getAttributes() 1184 | { 1185 | if (!instanceOfMap()) { 1186 | return new Map(); 1187 | } 1188 | 1189 | Map objMap = (Map) obj; 1190 | if (objMap.containsKey('attributes') && new ObjectWrapper(objMap.get('attributes')).instanceOfMap()) { 1191 | return (Map) objMap.get('attributes'); 1192 | } 1193 | return new Map(); 1194 | } 1195 | 1196 | /** 1197 | * Returns if the current object is a list. 1198 | * 1199 | * @return A boolean if the object is a list 1200 | */ 1201 | public Boolean instanceOfList() 1202 | { 1203 | return obj instanceof List; 1204 | } 1205 | 1206 | /** 1207 | * Returns if the current object is a map. 1208 | * 1209 | * @return A boolean if the object is a map 1210 | */ 1211 | public Boolean instanceOfMap() 1212 | { 1213 | return obj instanceof Map; 1214 | } 1215 | 1216 | /** 1217 | * Returns if the current object is a sobject list. 1218 | * 1219 | * @return A boolean if the object is a list of sobjects 1220 | */ 1221 | public Boolean instanceOfSObjectList() 1222 | { 1223 | return obj instanceof List; 1224 | } 1225 | 1226 | /** 1227 | * Returns if the current object is a sobject map. 1228 | * 1229 | * @return A boolean if the object is a map 1230 | */ 1231 | public Boolean instanceOfSObjectMap() 1232 | { 1233 | return obj instanceof Map; 1234 | } 1235 | 1236 | /** 1237 | * Returns if the current object is an sobject. 1238 | * 1239 | * @return A boolean if the object is an sobject 1240 | */ 1241 | public Boolean instanceOfSObject() 1242 | { 1243 | return obj instanceof SObject; 1244 | } 1245 | 1246 | /** 1247 | * If an sobject list, map, single sobject have been detected this can be used 1248 | * to return the describe of this so we can get its name and other details. 1249 | * 1250 | * @return A describe of the sobject 1251 | */ 1252 | public DescribeSObjectResult getSObjectDescribe() 1253 | { 1254 | if (instanceOfSObjectList()) { 1255 | return toSObjectList().getSObjectType().getDescribe(); 1256 | } else if (instanceOfSObjectMap()) { 1257 | return toSObjectMap().values().getSObjectType().getDescribe(); 1258 | } else { 1259 | return toSObject().getSObjectType().getDescribe(); 1260 | } 1261 | } 1262 | 1263 | /** 1264 | * Returns if the object we have is empty regardless of its type. 1265 | * 1266 | * @return A boolean of if the object is empty 1267 | */ 1268 | public Boolean isEmpty() 1269 | { 1270 | if (obj instanceof Map) { 1271 | return ((Map) obj).isEmpty(); 1272 | } 1273 | if (obj instanceof List) { 1274 | return ((List) obj).isEmpty(); 1275 | } 1276 | return obj == null || obj == ''; 1277 | } 1278 | 1279 | /** 1280 | * Converts the current object to a list. 1281 | * 1282 | * @return The object cast as a map 1283 | */ 1284 | public Map toMap() 1285 | { 1286 | return (Map) obj; 1287 | } 1288 | 1289 | /** 1290 | * Converts the current object to a list. 1291 | * 1292 | * @return The object cast as a list 1293 | */ 1294 | public List toList() 1295 | { 1296 | return (List) obj; 1297 | } 1298 | 1299 | /** 1300 | * Converts the current object to an sobject map. 1301 | * 1302 | * @return The object cast as an sobject map 1303 | */ 1304 | public Map toSObjectMap() 1305 | { 1306 | return (Map) obj; 1307 | } 1308 | 1309 | /** 1310 | * Converts the current object to a sobject list. 1311 | * 1312 | * @return The object cast as an sobject list 1313 | */ 1314 | public List toSObjectList() 1315 | { 1316 | return (List) obj; 1317 | } 1318 | 1319 | /** 1320 | * Converts the current to an sobject. 1321 | * 1322 | * @return The object cast as an sobject 1323 | */ 1324 | public SObject toSObject() 1325 | { 1326 | return (SObject) obj; 1327 | } 1328 | 1329 | /** 1330 | * Converts the object to a string value. 1331 | * 1332 | * @return The object cast as a string 1333 | */ 1334 | public override String toString() 1335 | { 1336 | if (isEmpty()) { 1337 | return ''; 1338 | } 1339 | return String.valueOf(obj); 1340 | } 1341 | 1342 | /** 1343 | * Converts the object to a datetime value. 1344 | * 1345 | * @return The object cast as a datetime 1346 | */ 1347 | public Datetime toDatetime() 1348 | { 1349 | return (Datetime) JSON.deserialize('"' + toString().replace(' ', 'T') + '"', Datetime.class); 1350 | } 1351 | 1352 | /** 1353 | * Converts the object to a time value. 1354 | * 1355 | * @return The object cast as a time 1356 | */ 1357 | public Time toTime() 1358 | { 1359 | return (Time) JSON.deserialize('"' + toString() + '"', Time.class); 1360 | } 1361 | 1362 | /** 1363 | * Converts the object to a date value. 1364 | * 1365 | * @return The object cast as a date 1366 | */ 1367 | public Date toDate() 1368 | { 1369 | return (Date) JSON.deserialize('"' + toString().replace('00:00:00', '').trim() + '"', Date.class); 1370 | } 1371 | 1372 | /** 1373 | * Converts the object to a boolean value. 1374 | * 1375 | * @return The object cast as a boolean 1376 | */ 1377 | public Boolean toBoolean() 1378 | { 1379 | return obj.toString() == 'true'; 1380 | } 1381 | 1382 | /** 1383 | * Converts the object to a id value. 1384 | * 1385 | * @return The object cast as an id 1386 | */ 1387 | public Id toId() 1388 | { 1389 | return Id.valueOf(toString()); 1390 | } 1391 | 1392 | /** 1393 | * Converts the object to a decimal value. 1394 | * 1395 | * @return The object cast as a decimal 1396 | */ 1397 | public Decimal toDecimal() 1398 | { 1399 | return Decimal.valueOf(toString()); 1400 | } 1401 | 1402 | /** 1403 | * Converts the object to a integer value. 1404 | * 1405 | * @return The object cast as a integer 1406 | */ 1407 | public Integer toInteger() 1408 | { 1409 | return Integer.valueOf(toString()); 1410 | } 1411 | 1412 | /** 1413 | * Converts the object to an untyped objected. 1414 | * 1415 | * @return The ObjectWrapper class 1416 | */ 1417 | public ObjectWrapper toUntyped() 1418 | { 1419 | return new ObjectWrapper(JSON.deserializeUntyped(JSON.serialize(obj, false))); 1420 | } 1421 | 1422 | /** 1423 | * Gets the type of an object by looking at the structure of object and string. 1424 | * The purpose of this detection, is due to the conversion of everything to a XML object, and converting primitive types to a string. 1425 | * 1426 | * Note: This does not detect the following types: 1427 | * - Blob 1428 | * - Long (set as integer) 1429 | * - Double (set as integer / decimal depending on presence of decimal places) 1430 | * 1431 | * @return A string of the typename 1432 | */ 1433 | public String getTypeName() 1434 | { 1435 | ObjectWrapper objUntyped = toUntyped(); 1436 | 1437 | // Cannot detect type from null. 1438 | if (obj == null) { 1439 | return 'Null'; 1440 | } 1441 | 1442 | String objString = objUntyped.toString(); 1443 | 1444 | // Any primitive types. 1445 | if (objString.deleteWhitespace() == '') { 1446 | return 'Null'; 1447 | } 1448 | if (String.isBlank(objString.replaceFirst('True|true|False|false', ''))) { 1449 | return 'Boolean'; 1450 | } 1451 | if (String.isBlank(objString.replaceFirst('[0-9]{4}-[0-9]{2}-[0-9]{2}', ''))) { 1452 | return 'Date'; 1453 | } 1454 | if (String.isBlank(objString.replaceFirst('[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}.[0-9]{3}((\\+[0-9]{4})|Z)', ''))) { 1455 | return 'Datetime'; 1456 | } 1457 | if (String.isBlank(objString.replaceFirst('[0-9A-Za-z]{18}|[0-9A-Za-z]{15}', ''))) { 1458 | return 'Id'; 1459 | } 1460 | if (String.isBlank(objString.replaceFirst('[0-9]{2}:[0-9]{2}:[0-9]{2}.[0-9]{3}Z', ''))) { 1461 | return 'Time'; 1462 | } 1463 | if (String.isBlank(objString.replaceFirst('-?[0-9]+', ''))) { 1464 | return 'Integer'; 1465 | } 1466 | if (String.isBlank(objString.replaceFirst('-?[0-9]+\\.[0-9]+', ''))) { 1467 | return 'Decimal'; 1468 | } 1469 | 1470 | // Types we can detect. 1471 | if (obj instanceof Map) { 1472 | return 'Map'; 1473 | } 1474 | if (obj instanceof List) { 1475 | return 'List'; 1476 | } 1477 | 1478 | return 'String'; 1479 | } 1480 | } 1481 | } 1482 | -------------------------------------------------------------------------------- /force-app/main/default/classes/XML.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 57.0 4 | Active 5 | 6 | -------------------------------------------------------------------------------- /force-app/main/default/classes/XMLTest.cls: -------------------------------------------------------------------------------- 1 | /* 2 | MIT License 3 | 4 | Copyright (c) 2020 zabroseric 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | */ 24 | @IsTest 25 | private class XMLTest { 26 | 27 | /* 28 | --------------------------------------------- 29 | -- Type Tests 30 | --------------------------------------------- 31 | */ 32 | @IsTest 33 | private static void typeBooleanTrue() 34 | { 35 | Boolean value = true; 36 | XML.ObjectWrapper objectWrapper = new XML.ObjectWrapper(value); 37 | 38 | System.assertEquals('Boolean', objectWrapper.getTypeName()); 39 | System.assert(objectWrapper.toBoolean()); 40 | } 41 | 42 | @IsTest 43 | private static void typeBooleanFalse() 44 | { 45 | Boolean value = false; 46 | XML.ObjectWrapper objectWrapper = new XML.ObjectWrapper(value); 47 | 48 | System.assertEquals('Boolean', objectWrapper.getTypeName()); 49 | System.assert(!objectWrapper.toBoolean()); 50 | } 51 | 52 | @IsTest 53 | private static void typeString() 54 | { 55 | String value = '123abc'; 56 | XML.ObjectWrapper objectWrapper = new XML.ObjectWrapper(value); 57 | 58 | System.assertEquals('String', objectWrapper.getTypeName()); 59 | System.assertEquals(value, objectWrapper.toString()); 60 | } 61 | 62 | @IsTest 63 | private static void typeStringNull() 64 | { 65 | String value = null; 66 | XML.ObjectWrapper objectWrapper = new XML.ObjectWrapper(value); 67 | 68 | System.assertEquals('Null', objectWrapper.getTypeName()); 69 | System.assertEquals('', objectWrapper.toString()); 70 | } 71 | 72 | @IsTest 73 | private static void typeDate() 74 | { 75 | Date value = Date.newInstance(2022, 01, 01); 76 | XML.ObjectWrapper objectWrapper = new XML.ObjectWrapper(value); 77 | 78 | System.assertEquals('Date', objectWrapper.getTypeName()); 79 | System.assertEquals(0, objectWrapper.toDate().daysBetween(value)); 80 | } 81 | 82 | @IsTest 83 | private static void typeDatetime() 84 | { 85 | Datetime value = Datetime.newInstance(2022, 01, 01, 1, 2, 3); 86 | XML.ObjectWrapper objectWrapper = new XML.ObjectWrapper(value); 87 | 88 | System.assertEquals('Datetime', objectWrapper.getTypeName()); 89 | System.assertEquals(value.format(), objectWrapper.toDatetime().format()); 90 | } 91 | 92 | @IsTest 93 | private static void typeRecordDatetime() 94 | { 95 | Contact contact = new Contact( 96 | FirstName = 'First', 97 | LastName = 'Last' 98 | ); 99 | insert contact; 100 | 101 | Contact contactQuery = [SELECT CreatedDate FROM Contact LIMIT 1]; 102 | XML.ObjectWrapper objectWrapper = new XML.ObjectWrapper(contactQuery.CreatedDate); 103 | 104 | System.assertEquals('Datetime', objectWrapper.getTypeName()); 105 | System.assertEquals(contactQuery.CreatedDate.format(), objectWrapper.toDatetime().format()); 106 | } 107 | 108 | @IsTest 109 | private static void typeTime() 110 | { 111 | Time value = Time.newInstance(1, 2, 3, 444); 112 | XML.ObjectWrapper objectWrapper = new XML.ObjectWrapper(value); 113 | 114 | System.assertEquals('Time', objectWrapper.getTypeName()); 115 | System.assertEquals(value.millisecond(), objectWrapper.toTime().millisecond()); 116 | } 117 | 118 | @IsTest 119 | private static void typeId() 120 | { 121 | Contact contact = new Contact( 122 | FirstName = 'First', 123 | LastName = 'Last' 124 | ); 125 | insert contact; 126 | 127 | XML.ObjectWrapper objectWrapper = new XML.ObjectWrapper(contact.Id); 128 | 129 | System.assertEquals('Id', objectWrapper.getTypeName()); 130 | System.assertEquals(contact.Id, objectWrapper.toId()); 131 | } 132 | 133 | @IsTest 134 | private static void typeIntegerPositive() 135 | { 136 | Integer value = 123456; 137 | XML.ObjectWrapper objectWrapper = new XML.ObjectWrapper(value); 138 | 139 | System.assertEquals('Integer', objectWrapper.getTypeName()); 140 | System.assertEquals(value, objectWrapper.toInteger()); 141 | } 142 | 143 | @IsTest 144 | private static void typeIntegerNegative() 145 | { 146 | Integer value = -123456; 147 | XML.ObjectWrapper objectWrapper = new XML.ObjectWrapper(value); 148 | 149 | System.assertEquals('Integer', objectWrapper.getTypeName()); 150 | System.assertEquals(value, objectWrapper.toInteger()); 151 | } 152 | 153 | @IsTest 154 | private static void typeDecimalPositive() 155 | { 156 | Decimal value = 123.456; 157 | XML.ObjectWrapper objectWrapper = new XML.ObjectWrapper(value); 158 | 159 | System.assertEquals('Decimal', objectWrapper.getTypeName()); 160 | System.assertEquals(value, objectWrapper.toDecimal()); 161 | } 162 | 163 | @IsTest 164 | private static void typeDecimalNegative() 165 | { 166 | Decimal value = -123.456; 167 | XML.ObjectWrapper objectWrapper = new XML.ObjectWrapper(value); 168 | 169 | System.assertEquals('Decimal', objectWrapper.getTypeName()); 170 | System.assertEquals(value, objectWrapper.toDecimal()); 171 | } 172 | 173 | @IsTest 174 | private static void typeMap() 175 | { 176 | Map value = new Map{ 177 | 'key1' => 'val1', 178 | 'key2' => 'val2' 179 | }; 180 | XML.ObjectWrapper objectWrapper = new XML.ObjectWrapper(value); 181 | 182 | System.assertEquals('Map', objectWrapper.getTypeName()); 183 | System.assertEquals(value, objectWrapper.toMap()); 184 | } 185 | 186 | @IsTest 187 | private static void typeList() 188 | { 189 | List value = new List{ 190 | 'val1', 'val2' 191 | }; 192 | XML.ObjectWrapper objectWrapper = new XML.ObjectWrapper(value); 193 | 194 | System.assertEquals('List', objectWrapper.getTypeName()); 195 | System.assertEquals(value, objectWrapper.toList()); 196 | } 197 | 198 | /* 199 | --------------------------------------------- 200 | -- SObject Tests 201 | --------------------------------------------- 202 | */ 203 | @IsTest 204 | private static void serializeSObjectNullEntry() 205 | { 206 | Contact contact = new Contact( 207 | Id = null, 208 | FirstName = 'First', 209 | LastName = 'Last' 210 | ); 211 | String xmlString = XML.serialize(contact).toString(); 212 | String contactLabel = Contact.getSObjectType().getDescribe().label; 213 | System.assertEquals('<{contactLabel}>ContactFirstLast'.replaceAll('\\{contactLabel\\}', contactLabel), xmlString); 214 | } 215 | 216 | @IsTest 217 | private static void serializeSObject() 218 | { 219 | Contact contact = new Contact( 220 | FirstName = 'First', 221 | LastName = 'Last' 222 | ); 223 | String xmlString = XML.serialize(contact).toString(); 224 | String contactLabel = Contact.getSObjectType().getDescribe().label; 225 | System.assertEquals('<{contactLabel}>ContactFirstLast'.replaceAll('\\{contactLabel\\}', contactLabel), xmlString); 226 | } 227 | 228 | @IsTest 229 | private static void serializeSObjectRootNodeName() 230 | { 231 | Contact contact = new Contact( 232 | FirstName = 'First', 233 | LastName = 'Last' 234 | ); 235 | String xmlString = XML.serialize(contact).setRootNodeName('NewNode').toString(); 236 | System.assertEquals('ContactFirstLast', xmlString); 237 | } 238 | 239 | @IsTest 240 | private static void serializeSObjectRootAttributes() 241 | { 242 | Contact contact = new Contact( 243 | FirstName = 'First', 244 | LastName = 'Last' 245 | ); 246 | String xmlString = XML.serialize(contact).addRootAttribute('key1', 'value1').addRootAttribute('key2', 'value2').toString(); 247 | String contactLabel = Contact.getSObjectType().getDescribe().label; 248 | System.assertEquals('<{contactLabel}>Contactvalue1value2FirstLast'.replaceAll('\\{contactLabel\\}', contactLabel), xmlString); 249 | } 250 | 251 | @IsTest 252 | private static void serializeSObjects() 253 | { 254 | List contacts = new List{ 255 | new Contact( 256 | FirstName = 'First1', 257 | LastName = 'Last1' 258 | ), 259 | new Contact( 260 | FirstName = 'First2', 261 | LastName = 'Last2' 262 | ) 263 | }; 264 | 265 | String xmlString = XML.serialize(contacts).toString(); 266 | String contactLabel = Contact.getSObjectType().getDescribe().label; 267 | String contactsLabel = Contact.getSObjectType().getDescribe().labelPlural; 268 | System.assertEquals('<{contactsLabel}><{contactLabel}>ContactFirst1Last1<{contactLabel}>ContactFirst2Last2'.replaceAll('\\{contactsLabel\\}', contactsLabel).replaceAll('\\{contactLabel\\}', contactLabel), xmlString); 269 | } 270 | 271 | @IsTest 272 | private static void serializeSObjectsRootNodeName() 273 | { 274 | List contacts = new List{ 275 | new Contact( 276 | FirstName = 'First1', 277 | LastName = 'Last1' 278 | ), 279 | new Contact( 280 | FirstName = 'First2', 281 | LastName = 'Last2' 282 | ) 283 | }; 284 | 285 | String xmlString = XML.serialize(contacts).setRootNodeName('NewNode').toString(); 286 | System.assertEquals('ContactFirst1Last1ContactFirst2Last2', xmlString); 287 | } 288 | 289 | @IsTest 290 | private static void serializeSObjectsRootAttributes() 291 | { 292 | List contacts = new List{ 293 | new Contact( 294 | FirstName = 'First1', 295 | LastName = 'Last1' 296 | ), 297 | new Contact( 298 | FirstName = 'First2', 299 | LastName = 'Last2' 300 | ) 301 | }; 302 | 303 | String xmlString = XML.serialize(contacts).addRootAttribute('key1', 'value1').addRootAttribute('key2', 'value2').toString(); 304 | String contactLabel = Contact.getSObjectType().getDescribe().label; 305 | String contactsLabel = Contact.getSObjectType().getDescribe().labelPlural; 306 | System.assertEquals('<{contactsLabel}><{contactLabel}>ContactFirst1Last1<{contactLabel}>ContactFirst2Last2value1value2'.replaceAll('\\{contactsLabel\\}', contactsLabel).replaceAll('\\{contactLabel\\}', contactLabel), xmlString); 307 | } 308 | 309 | @IsTest 310 | private static void serializeSObjectMap() 311 | { 312 | List contacts = new List{ 313 | new Contact( 314 | FirstName = 'First1', 315 | LastName = 'Last1' 316 | ), 317 | new Contact( 318 | FirstName = 'First2', 319 | LastName = 'Last2' 320 | ) 321 | }; 322 | insert contacts; 323 | Id contactId1 = contacts.get(1).Id; 324 | Id contactId2 = contacts.get(0).Id; 325 | 326 | String xmlString = XML.serialize(new Map(contacts)).toString(); 327 | String contactsLabel = Contact.getSObjectType().getDescribe().labelPlural; 328 | System.assertEquals(('<{contactsLabel}><_' + contactId1 + '>Contact/services/data//sobjects/Contact/' + contactId1 + 'First2Last2' + contactId1 + '<_' + contactId2 + '>Contact/services/data//sobjects/Contact/' + contactId2 + 'First1Last1' + contactId2 + '').replaceAll('\\{contactsLabel\\}', contactsLabel), xmlString.replaceAll('v[0-9]{2}\\.[0-9]', '')); 329 | } 330 | 331 | @IsTest 332 | private static void serializeSObjectMapStringId() 333 | { 334 | Id contactId1 = '0032w000004p2AbAAI'; 335 | Id contactId2 = '0032w000004p2AbAAJ'; 336 | 337 | List contacts = new List{ 338 | new Contact( 339 | FirstName = 'First1', 340 | LastName = 'Last1', 341 | Id = contactId2 342 | ), 343 | new Contact( 344 | FirstName = 'First2', 345 | LastName = 'Last2', 346 | Id = contactId1 347 | ) 348 | }; 349 | 350 | String xmlString = XML.serialize(new Map(contacts)).toString(); 351 | String contactsLabel = Contact.getSObjectType().getDescribe().labelPlural; 352 | System.assertEquals(('<{contactsLabel}><_' + contactId1 + '>Contact/services/data//sobjects/Contact/' + contactId1 + 'First2Last2' + contactId1 + '<_' + contactId2 + '>Contact/services/data//sobjects/Contact/' + contactId2 + 'First1Last1' + contactId2 + '').replaceAll('\\{contactsLabel\\}', contactsLabel), xmlString.replaceAll('v[0-9]{2}\\.[0-9]', '')); 353 | } 354 | 355 | @IsTest 356 | private static void serializeSObjectBase64() 357 | { 358 | Contact contact = new Contact( 359 | FirstName = 'First', 360 | LastName = 'Last' 361 | ); 362 | 363 | String xmlString = XML.serialize(contact).toBase64(); 364 | String contactLabel = Contact.getSObjectType().getDescribe().label; 365 | System.assertEquals(EncodingUtil.base64Encode(Blob.valueOf('<{contactLabel}>ContactFirstLast'.replaceAll('\\{contactLabel\\}', contactLabel))), xmlString); 366 | } 367 | 368 | @IsTest 369 | private static void serializeSObjectAttributesEmbedded() 370 | { 371 | Contact contact = new Contact( 372 | FirstName = 'First', 373 | LastName = 'Last' 374 | ); 375 | 376 | String xmlString = XML.serialize(contact).embedAttributes().toString(); 377 | String contactLabel = Contact.getSObjectType().getDescribe().label; 378 | System.assertEquals('<{contactLabel} type="Contact">FirstLast'.replaceAll('\\{contactLabel\\}', contactLabel), xmlString); 379 | } 380 | 381 | @IsTest 382 | private static void serializeSObjectsAttributesEmbedded() 383 | { 384 | List contacts = new List{ 385 | new Contact( 386 | FirstName = 'First1', 387 | LastName = 'Last1' 388 | ), 389 | new Contact( 390 | FirstName = 'First2', 391 | LastName = 'Last2' 392 | ) 393 | }; 394 | 395 | insert contacts; 396 | Id contactId1 = contacts.get(0).Id; 397 | Id contactId2 = contacts.get(1).Id; 398 | 399 | String xmlString = XML.serialize(contacts).embedAttributes().toString(); 400 | String contactLabel = Contact.getSObjectType().getDescribe().label; 401 | String contactsLabel = Contact.getSObjectType().getDescribe().labelPlural; 402 | System.assertEquals(('<{contactsLabel}><{contactLabel} type="Contact" url="/services/data//sobjects/Contact/' + contactId1 + '">First1Last1' + contactId1 + '<{contactLabel} type="Contact" url="/services/data//sobjects/Contact/' + contactId2 + '">First2Last2' + contactId2 + '').replaceAll('\\{contactLabel\\}', contactLabel).replaceAll('\\{contactsLabel\\}', contactsLabel), xmlString.replaceAll('v[0-9]{2}\\.[0-9]', '')); 403 | } 404 | 405 | @IsTest 406 | private static void serializeEmptyContactList() 407 | { 408 | String xmlString = XML.serialize(new List()).toString(); 409 | String contactsLabel = Contact.getSObjectType().getDescribe().labelPlural; 410 | System.assertEquals('<{contactsLabel}>'.replaceAll('\\{contactsLabel\\}', contactsLabel), xmlString); 411 | } 412 | 413 | @IsTest 414 | private static void deserializeSObjectNullEntry() 415 | { 416 | Contact contact = new Contact( 417 | Id = null, 418 | FirstName = 'First', 419 | LastName = 'Last' 420 | ); 421 | Contact contactResult = (Contact) XML.deserialize(XML.serialize(contact).toString()).setType(Contact.class).toObject(); 422 | System.assertEquals(contact, contactResult); 423 | } 424 | 425 | @IsTest 426 | private static void deserializeReservedWordStandard() 427 | { 428 | SpecialBookDateTime_x specialBook = (SpecialBookDateTime_x) XML.deserialize( 429 | '2019-01-28' 430 | ).setType(SpecialBookDateTime_x.class).toObject(); 431 | 432 | System.assertEquals(Date.newInstance(2019, 1, 28), specialBook.date_x); 433 | System.assertEquals(Time.newInstance(11, 0, 9, 0), specialBook.time_x); 434 | } 435 | 436 | @IsTest 437 | private static void deserializeReservedWordCustom() 438 | { 439 | SpecialBookDateTime_AnotherSuffix specialBook = (SpecialBookDateTime_AnotherSuffix) XML.deserialize( 440 | '2019-01-28' 441 | ).setType(SpecialBookDateTime_AnotherSuffix.class).setReservedWordSuffix('_AnotherSuffix').toObject(); 442 | 443 | System.assertEquals(Date.newInstance(2019, 1, 28), specialBook.date_AnotherSuffix); 444 | System.assertEquals(Time.newInstance(11, 0, 9, 0), specialBook.time_AnotherSuffix); 445 | } 446 | 447 | @IsTest 448 | private static void deserializeSObject() 449 | { 450 | Contact contact = new Contact( 451 | FirstName = 'First', 452 | LastName = 'Last' 453 | ); 454 | Contact contactResult = (Contact) XML.deserialize(XML.serialize(contact).toString()).setType(Contact.class).toObject(); 455 | System.assertEquals(contact, contactResult); 456 | } 457 | 458 | @IsTest 459 | private static void deserializeSObjects() 460 | { 461 | List contacts = new List{ 462 | new Contact( 463 | FirstName = 'First1', 464 | LastName = 'Last1' 465 | ), 466 | new Contact( 467 | FirstName = 'First2', 468 | LastName = 'Last2' 469 | ) 470 | }; 471 | 472 | List contactResult = (List) XML.deserialize(XML.serialize(contacts).toString()).setType(List.class).toObject(); 473 | System.assertEquals(contacts, contactResult); 474 | } 475 | @IsTest 476 | private static void deserializeSObjectWithIds() 477 | { 478 | List contacts = new List{ 479 | new Contact( 480 | FirstName = 'First1', 481 | LastName = 'Last1' 482 | ), 483 | new Contact( 484 | FirstName = 'First2', 485 | LastName = 'Last2' 486 | ) 487 | }; 488 | insert contacts; 489 | 490 | List contactResult = (List) XML.deserialize(XML.serialize(contacts).toString()).setType(List.class).toObject(); 491 | System.assertEquals(contacts, contactResult); 492 | } 493 | 494 | /* 495 | --------------------------------------------- 496 | -- Generic Tests 497 | --------------------------------------------- 498 | */ 499 | @IsTest 500 | private static void serializeLargeSize() 501 | { 502 | String xmlString = ''; 503 | for (Integer i = 0; i < 10000; i++) { 504 | xmlString += 'abcdefghijklmnopqrstuvwxyzvalue'; 505 | } 506 | 507 | XML.deserialize('' + xmlString + '').toObject(); 508 | } 509 | 510 | @IsTest 511 | private static void serializeEmptyString() 512 | { 513 | String xmlString = XML.serialize('').toString(); 514 | System.assertEquals('', xmlString); 515 | } 516 | 517 | @IsTest 518 | private static void serializeEmptyStringShowNulls() 519 | { 520 | String xmlString = XML.serialize('').showNulls().toString(); 521 | System.assertEquals('', xmlString); 522 | } 523 | 524 | @IsTest 525 | private static void serializeEmptyStringSuppressNulls() 526 | { 527 | String xmlString = XML.serialize('').suppressNulls().toString(); 528 | System.assertEquals('', xmlString); 529 | } 530 | 531 | @IsTest 532 | private static void serializeNull() 533 | { 534 | String xmlString = XML.serialize(null).toString(); 535 | System.assertEquals('', xmlString); 536 | } 537 | 538 | @IsTest 539 | private static void serializeNullSuppressNulls() 540 | { 541 | String xmlString = XML.serialize(null).suppressNulls().toString(); 542 | System.assertEquals('', xmlString); 543 | } 544 | 545 | @IsTest 546 | private static void serializeDatetime() 547 | { 548 | Datetime value = Datetime.newInstanceGmt(2022, 01, 01, 1, 2, 3); 549 | String xmlString = XML.serialize(value).toString(); 550 | 551 | System.assertEquals('2022-01-01T01:02:03.000Z', xmlString); 552 | } 553 | 554 | @IsTest 555 | private static void serializeDate() 556 | { 557 | Date value = Date.newInstance(2022, 01, 01); 558 | String xmlString = XML.serialize(value).toString(); 559 | 560 | System.assertEquals('2022-01-01', xmlString); 561 | } 562 | 563 | @IsTest 564 | private static void serializeTime() 565 | { 566 | Time value = Time.newInstance(13, 1, 2, 3); 567 | String xmlString = XML.serialize(value).toString(); 568 | 569 | System.assertEquals('13:01:02.003Z', xmlString); 570 | } 571 | 572 | @IsTest 573 | private static void serializeEmptyList() 574 | { 575 | String xmlString = XML.serialize(new List()).toString(); 576 | System.assertEquals('', xmlString); 577 | } 578 | 579 | @IsTest 580 | private static void serializeEmptyListSuppressNulls() 581 | { 582 | String xmlString = XML.serialize(new List()).suppressNulls().toString(); 583 | System.assertEquals('', xmlString); 584 | } 585 | 586 | @IsTest 587 | private static void serializeChangeRootNodeName() 588 | { 589 | String xmlString = XML.serialize(new List()).setRootNodeName('None').toString(); 590 | System.assertEquals('', xmlString); 591 | } 592 | 593 | @IsTest 594 | private static void serializeChangeRootNodeNameSuppressNulls() 595 | { 596 | String xmlString = XML.serialize(new List()).suppressNulls().setRootNodeName('None').toString(); 597 | System.assertEquals('', xmlString); 598 | } 599 | 600 | @IsTest 601 | private static void serializeSetXMLHeaderAttributes() 602 | { 603 | String xmlString = XML.serialize(new List()).addRootAttribute('Attr', 'No Attributes').embedAttributes().toString(); 604 | System.assertEquals('', xmlString); 605 | } 606 | 607 | @IsTest 608 | private static void serializeSetXMLHeaderAttributesSuppressNulls() 609 | { 610 | String xmlString = XML.serialize(new List()).embedAttributes().setRootAttributes(new Map{ 611 | 'Attr' => 'No Attributes' 612 | }).suppressNulls().toString(); 613 | System.assertEquals('', xmlString); 614 | } 615 | 616 | @IsTest 617 | private static void serializeShowEncoding() 618 | { 619 | String xmlString = XML.serialize(new List()).showEncoding().toString(); 620 | System.assertEquals('', xmlString); 621 | } 622 | 623 | @IsTest 624 | private static void serializeHideEncoding() 625 | { 626 | String xmlString = XML.serialize(new List()).hideEncoding().toString(); 627 | System.assertEquals('', xmlString); 628 | } 629 | 630 | @IsTest 631 | private static void serializeShowEncodingOneElement() 632 | { 633 | String xmlString = XML.serialize(new List{ 634 | 'a' 635 | }).showEncoding().toString(); 636 | System.assertEquals('a', xmlString); 637 | } 638 | 639 | @IsTest 640 | private static void serializeMapElementsNode() 641 | { 642 | String xmlString = XML.serialize(new Map{ 643 | 'key1' => 'val1', 644 | 'key2' => 'val2' 645 | }).toString(); 646 | System.assertEquals('val2val1', xmlString); 647 | } 648 | 649 | @IsTest 650 | private static void serializeMapElementNode() 651 | { 652 | String xmlString = XML.serialize(new Map{ 653 | 'key1' => 'val1' 654 | }).toString(); 655 | System.assertEquals('val1', xmlString); 656 | } 657 | 658 | @IsTest 659 | private static void deserializeEmptyNewLine() 660 | { 661 | Object obj = XML.deserialize('\r\n').toObject(); 662 | System.assertEquals(new Map{ 663 | 'element' => null 664 | }, obj); 665 | } 666 | 667 | @IsTest 668 | private static void deserializeOneElement() 669 | { 670 | Object obj = XML.deserialize('a').toObject(); 671 | System.assertEquals(new Map{ 672 | 'element' => 'a' 673 | }, obj); 674 | } 675 | 676 | @IsTest 677 | private static void deserializeSpecialCharacters() 678 | { 679 | Object obj = XML.deserialize('<>').toObject(); 680 | System.assertEquals(new Map{ 681 | 'element' => '<>' 682 | }, obj); 683 | } 684 | 685 | @IsTest 686 | private static void deserializeBooleanTrue() 687 | { 688 | Object obj = XML.deserialize('true').toObject(); 689 | System.assertEquals(new Map{ 690 | 'element' => true 691 | }, obj); 692 | } 693 | 694 | @IsTest 695 | private static void deserializeBooleanFalse() 696 | { 697 | Object obj = XML.deserialize('false').toObject(); 698 | System.assertEquals(new Map{ 699 | 'element' => false 700 | }, obj); 701 | } 702 | 703 | @IsTest 704 | private static void deserializeBooleanTrueCapital() 705 | { 706 | Object obj = XML.deserialize('True').toObject(); 707 | System.assertEquals(new Map{ 708 | 'element' => true 709 | }, obj); 710 | } 711 | 712 | @IsTest 713 | private static void deserializeBooleanFalseCapital() 714 | { 715 | Object obj = XML.deserialize('False').toObject(); 716 | System.assertEquals(new Map{ 717 | 'element' => false 718 | }, obj); 719 | } 720 | 721 | @IsTest 722 | private static void deserializeEmptyList() 723 | { 724 | Object obj = XML.deserialize('').toObject(); 725 | System.assertEquals(new Map{ 726 | 'elements' => null 727 | }, obj); 728 | } 729 | 730 | @IsTest 731 | private static void deserializeEmptyListArrayNode() 732 | { 733 | Object obj = XML.deserialize('').addArrayNode('elements').toObject(); 734 | System.assertEquals(new Map{ 735 | 'elements' => new List{ 736 | null 737 | } 738 | }, obj); 739 | } 740 | 741 | @IsTest 742 | private static void deserializeEmptyListArrayNodeChild() 743 | { 744 | Object obj = XML.deserialize('').addArrayNode('element').toObject(); 745 | System.assertEquals(new Map{ 746 | 'elements' => new Map{ 747 | 'element' => new List{ 748 | null 749 | } 750 | } 751 | }, obj); 752 | } 753 | 754 | @IsTest 755 | private static void deserializeEmptyListArrayNodes() 756 | { 757 | Object obj = XML.deserialize('123').setArrayNodes(new Set{ 758 | 'element', 'elements' 759 | }).toObject(); 760 | System.assertEquals(new Map{ 761 | 'elements' => new List{ 762 | new Map{ 763 | 'element' => new List{ 764 | '123' 765 | } 766 | } 767 | } 768 | }, obj); 769 | } 770 | 771 | @IsTest 772 | private static void deserializeEmptyListClose() 773 | { 774 | Object obj = XML.deserialize('').toObject(); 775 | System.assertEquals(new Map{ 776 | 'elements' => null 777 | }, obj); 778 | } 779 | 780 | @IsTest 781 | private static void deserializeNull() 782 | { 783 | try { 784 | XML.deserialize(null).toObject(); 785 | throw new XmlException('Error not thrown.'); 786 | } catch (Exception e) { 787 | System.assertEquals('The XML string is invalid, value: null', e.getMessage()); 788 | } 789 | } 790 | 791 | @IsTest 792 | private static void deserializeDatetime() 793 | { 794 | Contact contact = new Contact( 795 | FirstName = 'First', 796 | LastName = 'Last' 797 | ); 798 | insert contact; 799 | 800 | Contact contactQuery = [SELECT Id, CreatedDate FROM Contact LIMIT 1]; 801 | Contact contactResult = (Contact) XML.deserialize(XML.serialize(contactQuery).toString()).setType(Contact.class).toObject(); 802 | 803 | System.assertEquals(contactQuery.Id, contactResult.Id); 804 | System.assertEquals(contactQuery.CreatedDate, contactResult.CreatedDate); 805 | } 806 | 807 | @IsTest 808 | private static void deserializeEmptyString() 809 | { 810 | try { 811 | XML.deserialize('').toObject(); 812 | throw new XmlException('Error not thrown.'); 813 | } catch (Exception e) { 814 | System.assertEquals('The XML string is invalid, value: ', e.getMessage()); 815 | } 816 | } 817 | 818 | @IsTest 819 | private static void deserializeRootNodeSingleLevel() 820 | { 821 | Object obj = XML.deserialize('value').setRootNode('/a').toObject(); 822 | System.assertEquals(new Map{ 823 | 'a' => 'value' 824 | }, obj); 825 | } 826 | 827 | @IsTest 828 | private static void deserializeRootNodeSingleLevelNoLeadingSlash() 829 | { 830 | Object obj = XML.deserialize('value').setRootNode('a').toObject(); 831 | System.assertEquals(new Map{ 832 | 'a' => 'value' 833 | }, obj); 834 | } 835 | 836 | @IsTest 837 | private static void deserializeRootNodeSingleLevelMultipleLeadingSlashes() 838 | { 839 | Object obj = XML.deserialize('value').setRootNode('//a').toObject(); 840 | System.assertEquals(new Map{ 841 | 'a' => 'value' 842 | }, obj); 843 | } 844 | 845 | @IsTest 846 | private static void deserializeRootNodeSingleLevelTrailingSlash() 847 | { 848 | Object obj = XML.deserialize('value').setRootNode('a/').toObject(); 849 | System.assertEquals(new Map{ 850 | 'a' => 'value' 851 | }, obj); 852 | } 853 | 854 | @IsTest 855 | private static void deserializeRootNodeSingleLevelMultipleTrailingSlashes() 856 | { 857 | Object obj = XML.deserialize('value').setRootNode('a//').toObject(); 858 | System.assertEquals(new Map{ 859 | 'a' => 'value' 860 | }, obj); 861 | } 862 | 863 | @IsTest 864 | private static void deserializeRootNodeSingleLevelOnlySlashes() 865 | { 866 | Object obj = XML.deserialize('value').setRootNode('////').toObject(); 867 | System.assertEquals(new Map{ 868 | 'a' => 'value' 869 | }, obj); 870 | } 871 | 872 | @IsTest 873 | private static void deserializeRootNodeMultiLevelDrillSingle() 874 | { 875 | Object obj = XML.deserialize('value').setRootNode('/a/b').toObject(); 876 | System.assertEquals(new Map{ 877 | 'c' => 'value' 878 | }, obj); 879 | } 880 | 881 | @IsTest 882 | private static void deserializeRootNodeMultiLevelDrillMulti() 883 | { 884 | Object obj = XML.deserialize('value').setRootNode('/a/b').toObject(); 885 | System.assertEquals(new Map{ 886 | 'c' => 'value' 887 | }, obj); 888 | } 889 | 890 | @IsTest 891 | private static void deserializeRootNodeMultiLevelDrillFull() 892 | { 893 | Object obj = XML.deserialize('value').setRootNode('/a/b/c').toObject(); 894 | System.assertEquals(new Map{ 895 | 'c' => 'value' 896 | }, obj); 897 | } 898 | 899 | @IsTest 900 | private static void deserializeRootNodeDrillInvalid() 901 | { 902 | try { 903 | XML.deserialize('value').setRootNode('/a/b/c').toObject(); 904 | throw new XmlException('Error not thrown.'); 905 | } catch (Exception e) { 906 | System.assertEquals('Cannot drill into the node "b" set by the root node path /a/b/c', e.getMessage()); 907 | } 908 | } 909 | 910 | @IsTest 911 | private static void deserializeRootNodeMultiLevelMultiMap() 912 | { 913 | Object obj = XML.deserialize('value1value2').setRootNode('/a/b').toObject(); 914 | System.assertEquals(new Map{ 915 | 'c' => 'value1', 916 | 'd' => 'value2' 917 | }, obj); 918 | } 919 | 920 | @IsTest 921 | private static void deserializeRootNodeMultiLevelMultiList() 922 | { 923 | Object obj = XML.deserialize('value1value2').setRootNode('/a/b').toObject(); 924 | System.assertEquals(new Map{ 925 | 'c' => new List{ 926 | 'value1', 'value2' 927 | } 928 | }, obj); 929 | } 930 | 931 | @IsTest 932 | private static void deserializeRootNodeMultiLevelMultiListInvalidPath() 933 | { 934 | try { 935 | XML.deserialize('value1value2').setRootNode('/a/b/c').toObject(); 936 | throw new XmlException('Error not thrown.'); 937 | } catch (Exception e) { 938 | System.assertEquals('Cannot drill into the node "c" set by the root node path /a/b/c', e.getMessage()); 939 | } 940 | } 941 | 942 | @IsTest 943 | private static void deserializeRootNodeSingleLevelNull() 944 | { 945 | Object obj = XML.deserialize('value').setRootNode(null).toObject(); 946 | System.assertEquals(new Map{ 947 | 'a' => 'value' 948 | }, obj); 949 | } 950 | 951 | /* 952 | --------------------------------------------- 953 | -- Debugging 954 | --------------------------------------------- 955 | */ 956 | @IsTest 957 | private static void serializeToFormattedStringList() 958 | { 959 | String xmlString = XML.serialize(new List{ 960 | 'a', 961 | 'b' 962 | }).beautify().toString(); 963 | System.assertEquals('\r\n a\r\n b\r\n', xmlString); 964 | } 965 | 966 | @IsTest 967 | private static void serializeToStringMinifiedList() 968 | { 969 | String xmlString = XML.serialize(new List{ 970 | 'a', 971 | 'b' 972 | }).minify().toString(); 973 | System.assertEquals('ab', xmlString); 974 | } 975 | 976 | @IsTest 977 | private static void serializeToFormattedStringListMap() 978 | { 979 | String xmlString = XML.serialize(new List{ 980 | new Map{ 981 | 'a' => 'b', 982 | 'c' => 'd' 983 | } 984 | }).beautify().toString(); 985 | System.assertEquals('\r\n d\r\n b\r\n', xmlString); 986 | } 987 | 988 | @IsTest 989 | private static void serializeNamespace() 990 | { 991 | String xmlString = XML.serialize(new List{ 992 | new Map{ 993 | '{https://example.org}a' => 'b', 994 | '{https://example.org}c' => 'd' 995 | } 996 | }).addNamespace('https://example.org', 'b').toString(); 997 | System.assertEquals('db', xmlString); 998 | } 999 | 1000 | @IsTest 1001 | private static void serializeNamespaces() 1002 | { 1003 | String xmlString = XML.serialize(new List{ 1004 | new Map{ 1005 | '{https://example1.org}a' => 'b', 1006 | '{https://example2.org}c' => 'd' 1007 | } 1008 | }).setNamespaces(new Map{ 1009 | 'https://example1.org' => 'p1', 'https://example2.org' => 'p2' 1010 | }).toString(); 1011 | System.assertEquals('db', xmlString); 1012 | } 1013 | 1014 | @IsTest 1015 | private static void serializeNamespacesNotFound() 1016 | { 1017 | String xmlString = XML.serialize(new List{ 1018 | new Map{ 1019 | '{https://example1.org}a' => 'b' 1020 | } 1021 | }).setNamespaces(new Map{ 1022 | 'https://example2.org' => 'p2' 1023 | }).toString(); 1024 | System.assertEquals('b', xmlString); 1025 | } 1026 | 1027 | @IsTest 1028 | private static void serializeToFormattedStringListMapEncoding() 1029 | { 1030 | String xmlString = XML.serialize(new List{ 1031 | new Map{ 1032 | 'a' => 'b', 1033 | 'c' => 'd' 1034 | } 1035 | }).showEncoding().beautify().toString(); 1036 | System.assertEquals('\r\n\r\n d\r\n b\r\n', xmlString); 1037 | } 1038 | 1039 | @IsTest 1040 | private static void serializeToFormattedStringString() 1041 | { 1042 | String xmlString = XML.serialize('abc').beautify().toString(); 1043 | System.assertEquals('abc', xmlString); 1044 | } 1045 | 1046 | @IsTest 1047 | private static void serializeDebug() 1048 | { 1049 | XML.serialize('abc').debug().beautify().debug(); 1050 | } 1051 | 1052 | @IsTest 1053 | private static void serializeDeserializeHideNamespaces() 1054 | { 1055 | String xmlString = XML.serialize(new List{ 1056 | new Map{ 1057 | '{https://example1.org}a' => 'b', 1058 | '{https://example2.org}c' => 'd' 1059 | } 1060 | }).setNamespaces(new Map{ 1061 | 'https://example1.org' => 'p1', 'https://example2.org' => 'p2' 1062 | }).toString(); 1063 | 1064 | System.assertEquals(new Map{ 1065 | 'element' => new Map{ 1066 | 'a' => 'b', 1067 | 'c' => 'd' 1068 | } 1069 | }, XML.deserialize(xmlString).hideNamespaces().toObject()); 1070 | } 1071 | 1072 | @IsTest 1073 | private static void serializeDeserializeShowNamespaces() 1074 | { 1075 | String xmlString = XML.serialize(new List{ 1076 | new Map{ 1077 | '{https://example1.org}a' => 'b', 1078 | '{https://example2.org}c' => 'd' 1079 | } 1080 | }).setNamespaces(new Map{ 1081 | 'https://example1.org' => 'p1', 'https://example2.org' => 'p2' 1082 | }).toString(); 1083 | 1084 | System.assertEquals(new Map{ 1085 | 'element' => new Map{ 1086 | '{https://example1.org}a' => 'b', 1087 | '{https://example2.org}c' => 'd' 1088 | } 1089 | }, XML.deserialize(xmlString).showNamespaces().toObject()); 1090 | } 1091 | 1092 | @IsTest 1093 | private static void serializeDeserializeFilterNamespaces() 1094 | { 1095 | String xmlString = XML.serialize(new List{ 1096 | new Map{ 1097 | '{https://example1.org}a' => 'b', 1098 | '{https://example2.org}c' => 'd', 1099 | 'e' => 'f' 1100 | } 1101 | }).setNamespaces(new Map{ 1102 | 'https://example1.org' => 'p1', 'https://example2.org' => 'p2' 1103 | }).toString(); 1104 | 1105 | System.assertEquals(new Map{ 1106 | 'element' => new Map{ 1107 | '{https://example1.org}a' => 'b', 1108 | 'e' => 'f' 1109 | } 1110 | }, XML.deserialize(xmlString).filterNamespace('https://example1.org').toObject()); 1111 | } 1112 | 1113 | @IsTest 1114 | private static void deserializeDebug() 1115 | { 1116 | XML.deserialize('abc').debug(); 1117 | } 1118 | 1119 | /* 1120 | --------------------------------------------- 1121 | -- Object Tests 1122 | --------------------------------------------- 1123 | */ 1124 | @IsTest 1125 | public static void serializeObject() 1126 | { 1127 | Library library = new Library( 1128 | new Catalog( 1129 | new Books( 1130 | new List{ 1131 | new Book('title1', new Authors(new List{ 1132 | 'Name1', 'Name2' 1133 | }), '23.00'), 1134 | new Book('title2', new Authors(new List{ 1135 | 'Name3' 1136 | }), '23.00'), 1137 | new Book('title5', new Authors(new List{ 1138 | }), null) 1139 | } 1140 | ) 1141 | ) 1142 | ); 1143 | 1144 | String xmlString = XML.serialize(library).setRootNodeName('library').toString(); 1145 | System.assertEquals('title123.00Name1Name2title223.00Name3title5', xmlString); 1146 | } 1147 | 1148 | @IsTest 1149 | public static void serializeObjectSuppressNulls() 1150 | { 1151 | Library library = new Library( 1152 | new Catalog( 1153 | new Books( 1154 | new List{ 1155 | new Book('title1', new Authors(new List{ 1156 | 'Name1', 'Name2' 1157 | }), '23.00'), 1158 | new Book('title2', new Authors(new List{ 1159 | 'Name3' 1160 | }), '23.00'), 1161 | new Book('title5', new Authors(new List{ 1162 | }), null) 1163 | } 1164 | ) 1165 | ) 1166 | ); 1167 | 1168 | String xmlString = XML.serialize(library).suppressNulls().setRootNodeName('library').toString(); 1169 | System.assertEquals('title123.00Name1Name2title223.00Name3title5', xmlString); 1170 | } 1171 | 1172 | @IsTest 1173 | public static void serializeObjectChangeRoot() 1174 | { 1175 | Library library = new Library( 1176 | new Catalog( 1177 | new Books( 1178 | new List{ 1179 | new Book('title1', new Authors(new List{ 1180 | 'Name1', 'Name2' 1181 | }), '23.00'), 1182 | new Book('title2', new Authors(new List{ 1183 | 'Name3' 1184 | }), '23.00'), 1185 | new Book('title5', new Authors(new List{ 1186 | }), null) 1187 | } 1188 | ) 1189 | ) 1190 | ); 1191 | 1192 | String xmlString = XML.serialize(library).setRootNodeName('library').toString(); 1193 | System.assertEquals('title123.00Name1Name2title223.00Name3title5', xmlString); 1194 | } 1195 | 1196 | @IsTest 1197 | public static void serializeDeserializeObject() 1198 | { 1199 | Library libraryObject = new Library( 1200 | new Catalog( 1201 | new Books( 1202 | new List{ 1203 | new Book('title1', new Authors(new List{ 1204 | 'Name1', 'Name2' 1205 | }), '23.00'), 1206 | new Book('title1', new Authors(new List{ 1207 | 'Name3', 'Name4' 1208 | }), '23.00') 1209 | } 1210 | ) 1211 | ) 1212 | ); 1213 | 1214 | String xmlString = XML.serialize(libraryObject).setRootNodeName('Library').toString(); 1215 | System.assertEquals(String.valueOf(libraryObject), String.valueOf(XML.deserialize(xmlString, Library.class).toObject())); 1216 | } 1217 | 1218 | @IsTest 1219 | public static void serializeDeserializeObjectArrayNode() 1220 | { 1221 | Library libraryObject = new Library( 1222 | new Catalog( 1223 | new Books( 1224 | new List{ 1225 | new Book('title1', new Authors(new List{ 1226 | 'Name1', 'Name2' 1227 | }), '23.00'), 1228 | new Book('title2', new Authors(new List{ 1229 | 'Name3' 1230 | }), '23.00'), 1231 | new Book('title5', null, null) 1232 | } 1233 | ) 1234 | ) 1235 | ); 1236 | 1237 | String xmlString = XML.serialize(libraryObject).setRootNodeName('Library').toString(); 1238 | System.assertEquals(String.valueOf(libraryObject), 1239 | String.valueOf(XML.deserialize(xmlString, Library.class) 1240 | .addArrayNode('author').toObject()) 1241 | ); 1242 | } 1243 | 1244 | @IsTest 1245 | public static void serializeDeserializeObjectArrayNodes() 1246 | { 1247 | Library libraryObject = new Library( 1248 | new Catalog( 1249 | new Books( 1250 | new List{ 1251 | new Book('title5', null, null) 1252 | } 1253 | ) 1254 | ) 1255 | ); 1256 | 1257 | String xmlString = XML.serialize(libraryObject).setRootNodeName('Library').toString(); 1258 | System.assertEquals(String.valueOf(libraryObject), 1259 | String.valueOf(XML.deserialize(xmlString, Library.class) 1260 | .setArrayNodes(new Set{ 1261 | 'author', 'book' 1262 | }).toObject()) 1263 | ); 1264 | } 1265 | 1266 | @IsTest 1267 | public static void serializeDeserializerBook() 1268 | { 1269 | Book bk = new Book('title5', null, '23.00'); 1270 | 1271 | String xmlString = XML.serialize(bk).setRootNodeName('Book').toString(); 1272 | System.assertEquals(String.valueOf(bk), 1273 | String.valueOf(XML.deserialize(xmlString, Book.class)) 1274 | ); 1275 | } 1276 | 1277 | @IsTest 1278 | public static void serializeDeserializerBookUnknownRoot() 1279 | { 1280 | Book bk = new Book('title5', null, '23.00'); 1281 | 1282 | String xmlString = XML.serialize(bk).setRootNodeName('Unknown').toString(); 1283 | System.assertEquals(String.valueOf(new Book()), 1284 | String.valueOf(XML.deserialize(xmlString, Book.class)) 1285 | ); 1286 | } 1287 | 1288 | @IsTest 1289 | public static void serializeDeserializerCatalogInvalid() 1290 | { 1291 | System.assertEquals(String.valueOf(new Catalog()), 1292 | String.valueOf(XML.deserialize('', Catalog.class)) 1293 | ); 1294 | } 1295 | 1296 | @IsTest 1297 | public static void deserializeObject() 1298 | { 1299 | Library library = (Library) XML.deserialize( 1300 | 'title123.00Name1Name2title223.00Name3Name4title5' 1301 | ).setType(Library.class).toObject(); 1302 | 1303 | System.assertEquals('title1', library.catalog.books.book[0].title); 1304 | System.assertEquals(JSON.serialize(new Authors(new List{ 1305 | 'Name1', 'Name2' 1306 | })), JSON.serialize(library.catalog.books.book[0].authors)); 1307 | System.assertEquals('23.00', library.catalog.books.book[0].price); 1308 | 1309 | System.assertEquals('title2', library.catalog.books.book[1].title); 1310 | System.assertEquals(JSON.serialize(new Authors(new List{ 1311 | 'Name3', 'Name4' 1312 | })), JSON.serialize(library.catalog.books.book[1].authors)); 1313 | System.assertEquals('23.00', library.catalog.books.book[1].price); 1314 | } 1315 | 1316 | @IsTest 1317 | public static void deserializeObjectArrayNodeError() 1318 | { 1319 | try { 1320 | XML.deserialize( 1321 | 'title123.00Name1Name2title223.00Name4title5' 1322 | ).setType(Library.class).toObject(); 1323 | System.assert(false); 1324 | } catch (XmlException e) { 1325 | System.assertEquals('An array node has not been correctly set by value "Name4"', e.getMessage()); 1326 | } 1327 | } 1328 | 1329 | 1330 | @IsTest 1331 | public static void serializeObjectReservedWordDisabled() 1332 | { 1333 | Object obj = XML.deserialize( 1334 | '' + 1335 | ' 2019-01-28' + 1336 | ' ' + 1337 | '' 1338 | ) 1339 | .unsanitize() 1340 | .toObject(); 1341 | 1342 | System.assertEquals(new Map{ 1343 | 'element' => null 1344 | }, obj); 1345 | } 1346 | 1347 | @IsTest 1348 | public static void serializeObjectReservedWordEnabled() 1349 | { 1350 | Map obj = (Map) XML.deserialize( 1351 | '' + 1352 | ' 2019-01-28' + 1353 | ' ' + 1354 | '' 1355 | ) 1356 | .sanitize() 1357 | .toObject(); 1358 | 1359 | Map objElement = (Map) obj.get('element'); 1360 | 1361 | System.assertEquals('2019-01-28', objElement.get('Date_x')); 1362 | System.assertEquals('11:00:09Z', objElement.get('Time_x')); 1363 | } 1364 | 1365 | @IsTest 1366 | public static void serializeObjectInferTypes() 1367 | { 1368 | ClassWithTypes classWithTypes = (ClassWithTypes) XML.deserialize( 1369 | '' + 1370 | ' 2019-01-28' + 1371 | ' ' + 1372 | ' true' + 1373 | ' false' + 1374 | ' 123' + 1375 | ' test' + 1376 | ' true' + 1377 | ' false' + 1378 | '' 1379 | ).setType(ClassWithTypes.class) 1380 | .setReservedWordSuffix('_xyz') 1381 | .toObject(); 1382 | 1383 | System.assertEquals(Date.newInstance(2019, 01, 28), classWithTypes.date_xyz); 1384 | System.assertEquals(Time.newInstance(11, 00, 09, 0), classWithTypes.time_xyz); 1385 | System.assert(classWithTypes.boolean_true); 1386 | System.assert(!classWithTypes.boolean_false); 1387 | System.assertEquals(123, classWithTypes.integer_xyz); 1388 | System.assertEquals('true', classWithTypes.string_true); 1389 | System.assertEquals('false', classWithTypes.string_false); 1390 | System.assertEquals('test', classWithTypes.string_xyz); 1391 | } 1392 | 1393 | @IsTest 1394 | public static void serializeObjectInferTypesRootNode() 1395 | { 1396 | ClassWithTypes classWithTypes = (ClassWithTypes) XML.deserialize( 1397 | '' + 1398 | ' ' + 1399 | ' 2019-01-28' + 1400 | ' ' + 1401 | ' true' + 1402 | ' false' + 1403 | ' 123' + 1404 | ' test' + 1405 | ' true' + 1406 | ' false' + 1407 | ' ' + 1408 | '' 1409 | ).setType(ClassWithTypes.class) 1410 | .setReservedWordSuffix('_xyz') 1411 | .setRootNode('/OuterNode/ClassWithTypes') 1412 | .toObject(); 1413 | 1414 | System.assertEquals(Date.newInstance(2019, 01, 28), classWithTypes.date_xyz); 1415 | System.assertEquals(Time.newInstance(11, 00, 09, 0), classWithTypes.time_xyz); 1416 | System.assert(classWithTypes.boolean_true); 1417 | System.assert(!classWithTypes.boolean_false); 1418 | System.assertEquals(123, classWithTypes.integer_xyz); 1419 | System.assertEquals('true', classWithTypes.string_true); 1420 | System.assertEquals('false', classWithTypes.string_false); 1421 | System.assertEquals('test', classWithTypes.string_xyz); 1422 | } 1423 | 1424 | @IsTest 1425 | public static void serializeObjectNoAttributes() 1426 | { 1427 | Library library = new Library( 1428 | new Catalog( 1429 | new Books( 1430 | new List{ 1431 | new Book('title1', new Authors(new List{ 1432 | 'Name1', 'Name2' 1433 | }), '23.00'), 1434 | new Book('title2', new Authors(new List{ 1435 | 'Name3' 1436 | }), '23.00'), 1437 | new Book('title5', new Authors(new List{ 1438 | }), null) 1439 | } 1440 | ) 1441 | ) 1442 | ); 1443 | 1444 | String xmlString = XML.serialize(library).setRootNodeName('library').embedAttributes().toString(); 1445 | System.assertEquals('title123.00Name1Name2title223.00Name3title5', xmlString); 1446 | } 1447 | 1448 | @IsTest 1449 | public static void serializeObjectAttributes() 1450 | { 1451 | BookWithStringAttributes book = new BookWithStringAttributes(); 1452 | book.attributes.put('key1', 'value1'); 1453 | book.attributes.put('key2', 'value2'); 1454 | book.title = 'title'; 1455 | 1456 | String xmlString = XML.serialize(book).setRootNodeName('book').embedAttributes().toString(); 1457 | System.assertEquals('title', xmlString); 1458 | } 1459 | 1460 | @IsTest 1461 | public static void serializeObjectAttributesNone() 1462 | { 1463 | BookWithStringAttributes book = new BookWithStringAttributes(); 1464 | book.attributes.put('key1', 'value1'); 1465 | book.attributes.put('key2', 'value2'); 1466 | book.title = 'title'; 1467 | 1468 | String xmlString = XML.serialize(book).setRootNodeName('book').splitAttributes().toString(); 1469 | System.assertEquals('titlevalue2value1', xmlString); 1470 | } 1471 | 1472 | @IsTest 1473 | public static void serializeObjectAttributeNumbers() 1474 | { 1475 | BookWithIntegerAttributes book = new BookWithIntegerAttributes(); 1476 | book.attributes.put('key1', 12); 1477 | book.attributes.put('key2', 23); 1478 | book.title = 'title'; 1479 | 1480 | String xmlString = XML.serialize(book).setRootNodeName('book').embedAttributes().toString(); 1481 | System.assertEquals('title', xmlString); 1482 | } 1483 | 1484 | @IsTest 1485 | public static void serializeObjectAttributeEmpty() 1486 | { 1487 | BookWithStringAttributes book = new BookWithStringAttributes(); 1488 | book.attributes.put('key1', null); 1489 | book.title = 'title'; 1490 | 1491 | String xmlString = XML.serialize(book).setRootNodeName('book').embedAttributes().toString(); 1492 | System.assertEquals('title', xmlString); 1493 | } 1494 | 1495 | @IsTest 1496 | public static void serializeObjectAttributesWithRoot() 1497 | { 1498 | BookWithStringAttributes book = new BookWithStringAttributes(); 1499 | book.attributes.put('key1', 'value1'); 1500 | book.title = 'title'; 1501 | 1502 | String xmlString = XML.serialize(book).setRootNodeName('book').addRootAttribute('key2', 'value2').embedAttributes().toString(); 1503 | System.assertEquals('title', xmlString); 1504 | } 1505 | 1506 | @IsTest 1507 | public static void serializeObjectAttributesWithRootOverride() 1508 | { 1509 | BookWithStringAttributes book = new BookWithStringAttributes(); 1510 | book.attributes.put('key1', 'value1'); 1511 | book.title = 'title'; 1512 | 1513 | String xmlString = XML.serialize(book).setRootNodeName('book').addRootAttribute('key1', 'value2').embedAttributes().toString(); 1514 | System.assertEquals('title', xmlString); 1515 | } 1516 | 1517 | @IsTest 1518 | public static void serializeObjectAttributesWithRootOverrideSelf() 1519 | { 1520 | BookWithStringAttributesSelf book = new BookWithStringAttributesSelf(); 1521 | book.attributes.put('key1', 'value1'); 1522 | book.self = 'title'; 1523 | 1524 | String xmlString = XML.serialize(book).setRootNodeName('book').addRootAttribute('key2', 'value2').embedAttributes().toString(); 1525 | System.assertEquals('title', xmlString); 1526 | } 1527 | 1528 | @IsTest 1529 | public static void serializeObjectdAttributesSelf() 1530 | { 1531 | BookWithStringAttributesSelf book1 = new BookWithStringAttributesSelf(); 1532 | book1.attributes.put('key1', 'value1'); 1533 | book1.self = 'title1'; 1534 | 1535 | BookWithStringAttributesSelf book2 = new BookWithStringAttributesSelf(); 1536 | book2.attributes.put('key2', 'value2'); 1537 | book2.self = 'title2'; 1538 | 1539 | String xmlString = XML.serialize(new List{ 1540 | book1, book2 1541 | }).setRootNodeName('books').embedAttributes().toString(); 1542 | System.assertEquals('title1title2', xmlString); 1543 | } 1544 | 1545 | @IsTest 1546 | public static void serializeObjectAttributesSelfInvalid() 1547 | { 1548 | try { 1549 | BookWithStringAttributesSelfInvalid book = new BookWithStringAttributesSelfInvalid(); 1550 | book.attributes.put('key1', 'value1'); 1551 | book.self = 'title'; 1552 | book.abc = 'abc123'; 1553 | String xmlString = XML.serialize(book).setRootNodeName('book').toString(); 1554 | System.assert(false, 'Exception expected to be thrown: ' + xmlString); 1555 | } catch (XmlException e) { 1556 | System.assert(e.getMessage().contains('self keyword')); 1557 | } 1558 | } 1559 | 1560 | @IsTest 1561 | public static void deserializeObjectAttributesNotEmbedded() 1562 | { 1563 | BookWithStringAttributes book = (BookWithStringAttributes) XML.deserialize('title').setType(BookWithStringAttributes.class).toObject(); 1564 | 1565 | System.assertNotEquals(null, book.attributes); 1566 | System.assertEquals('value1', book.attributes.get('key1')); 1567 | System.assertEquals('title', book.title); 1568 | } 1569 | 1570 | @IsTest 1571 | public static void deserializeObjectAttributesEmbedded() 1572 | { 1573 | BookWithStringAttributes book = (BookWithStringAttributes) XML.deserialize('value1title').setType(BookWithStringAttributes.class).toObject(); 1574 | 1575 | System.assertNotEquals(null, book.attributes); 1576 | System.assertEquals('value1', book.attributes.get('key1')); 1577 | System.assertEquals('title', book.title); 1578 | } 1579 | 1580 | @IsTest 1581 | public static void deserializeObjectAttributesCombination() 1582 | { 1583 | BookWithStringAttributes book = (BookWithStringAttributes) XML.deserialize('value2title').setType(BookWithStringAttributes.class).toObject(); 1584 | 1585 | System.assertNotEquals(null, book.attributes); 1586 | System.assertEquals('value1', book.attributes.get('key1')); 1587 | System.assertEquals('value2', book.attributes.get('key2')); 1588 | System.assertEquals('title', book.title); 1589 | } 1590 | 1591 | private class Library { 1592 | public Catalog catalog; 1593 | 1594 | public Library() 1595 | { 1596 | } 1597 | 1598 | public Library(Catalog catalog) 1599 | { 1600 | this.catalog = catalog; 1601 | } 1602 | } 1603 | 1604 | private class Catalog { 1605 | public Books books; 1606 | 1607 | public Catalog() 1608 | { 1609 | } 1610 | 1611 | public Catalog(Books books) 1612 | { 1613 | this.books = books; 1614 | } 1615 | } 1616 | 1617 | private class Books { 1618 | public List book; 1619 | 1620 | public Books() 1621 | { 1622 | } 1623 | 1624 | public Books(List book) 1625 | { 1626 | this.book = book; 1627 | } 1628 | } 1629 | 1630 | private class Book implements XML.Deserializable { 1631 | public String title; 1632 | public Authors authors; 1633 | public String price; 1634 | 1635 | public Book() 1636 | { 1637 | } 1638 | 1639 | public Book(String title, Authors authors, String price) 1640 | { 1641 | this.title = title; 1642 | this.authors = authors; 1643 | this.price = price; 1644 | } 1645 | 1646 | public Book xmlDeserialize(Object objMap) 1647 | { 1648 | title = (String) ((Map) objMap).get('title'); 1649 | price = (String) ((Map) objMap).get('price'); 1650 | return this; 1651 | } 1652 | } 1653 | 1654 | private class Authors { 1655 | public List author; 1656 | 1657 | public Authors() 1658 | { 1659 | } 1660 | 1661 | public Authors(List author) 1662 | { 1663 | this.author = author; 1664 | } 1665 | } 1666 | 1667 | class SpecialBookDateTime_x { 1668 | public Date date_x; 1669 | public Time time_x; 1670 | } 1671 | 1672 | class SpecialBookDateTime_AnotherSuffix { 1673 | public Date date_AnotherSuffix; 1674 | public Time time_AnotherSuffix; 1675 | } 1676 | 1677 | class BookWithStringAttributes { 1678 | public Map attributes = new Map(); 1679 | public String title; 1680 | } 1681 | 1682 | class BookWithIntegerAttributes { 1683 | public Map attributes = new Map(); 1684 | public String title; 1685 | } 1686 | 1687 | class BookWithStringAttributesSelf { 1688 | public Map attributes = new Map(); 1689 | public String self; 1690 | } 1691 | 1692 | class BookWithStringAttributesSelfInvalid { 1693 | public Map attributes = new Map(); 1694 | public String self; 1695 | public String abc; 1696 | } 1697 | 1698 | public class ClassWithTypes { 1699 | public Date date_xyz; 1700 | public Time time_xyz; 1701 | public Boolean boolean_true; 1702 | public Boolean boolean_false; 1703 | public Integer integer_xyz; 1704 | public String string_xyz; 1705 | public String string_true; 1706 | public String string_false; 1707 | } 1708 | } 1709 | -------------------------------------------------------------------------------- /force-app/main/default/classes/XMLTest.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 57.0 4 | Active 5 | 6 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | const { jestConfig } = require('@salesforce/sfdx-lwc-jest/config'); 2 | 3 | module.exports = { 4 | ...jestConfig, 5 | modulePathIgnorePatterns: ['/.localdevserver'] 6 | }; 7 | -------------------------------------------------------------------------------- /manifest/package.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | XML 5 | XMLTest 6 | ApexClass 7 | 8 | 53.0 9 | 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "salesforce-app", 3 | "private": true, 4 | "version": "5.2.1", 5 | "description": "Salesforce App", 6 | "scripts": { 7 | "lint": "eslint **/{aura,lwc}/**", 8 | "test": "npm run test:unit", 9 | "test:unit": "sfdx-lwc-jest", 10 | "test:unit:watch": "sfdx-lwc-jest --watch", 11 | "test:unit:debug": "sfdx-lwc-jest --debug", 12 | "test:unit:coverage": "sfdx-lwc-jest --coverage", 13 | "prettier": "prettier --write \"**/*.{cls,cmp,component,css,html,js,json,md,page,trigger,xml,yaml,yml}\"", 14 | "prettier:verify": "prettier --list-different \"**/*.{cls,cmp,component,css,html,js,json,md,page,trigger,xml,yaml,yml}\"", 15 | "postinstall": "husky install", 16 | "precommit": "lint-staged" 17 | }, 18 | "devDependencies": { 19 | "@lwc/eslint-plugin-lwc": "^1.0.1", 20 | "@prettier/plugin-xml": "^0.13.1", 21 | "@salesforce/eslint-config-lwc": "^2.0.0", 22 | "@salesforce/eslint-plugin-aura": "^2.0.0", 23 | "@salesforce/eslint-plugin-lightning": "^0.1.1", 24 | "@salesforce/sfdx-lwc-jest": "^0.13.0", 25 | "eslint": "^7.29.0", 26 | "eslint-plugin-import": "^2.23.4", 27 | "eslint-plugin-jest": "^24.3.6", 28 | "husky": "^7.0.0", 29 | "lint-staged": "^11.0.0", 30 | "prettier": "^2.3.2", 31 | "prettier-plugin-apex": "^1.10.1" 32 | }, 33 | "lint-staged": { 34 | "**/*.{cls,cmp,component,css,html,js,json,md,page,trigger,xml,yaml,yml}": [ 35 | "prettier --write" 36 | ], 37 | "**/{aura,lwc}/**": [ 38 | "eslint" 39 | ] 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /sfdc-xml-parser.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /sfdx-project.json: -------------------------------------------------------------------------------- 1 | { 2 | "packageDirectories": [ 3 | { 4 | "path": "force-app", 5 | "default": true 6 | } 7 | ], 8 | "name": "XML", 9 | "namespace": "", 10 | "sfdcLoginUrl": "https://login.salesforce.com", 11 | "sourceApiVersion": "54.0" 12 | } 13 | --------------------------------------------------------------------------------