001/*
002 * Copyright 2012-2016 UnboundID Corp.
003 *
004 * This program is free software; you can redistribute it and/or modify
005 * it under the terms of the GNU General Public License (GPLv2 only)
006 * or the terms of the GNU Lesser General Public License (LGPLv2.1 only)
007 * as published by the Free Software Foundation.
008 *
009 * This program is distributed in the hope that it will be useful,
010 * but WITHOUT ANY WARRANTY; without even the implied warranty of
011 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
012 * GNU General Public License for more details.
013 *
014 * You should have received a copy of the GNU General Public License
015 * along with this program; if not, see <http://www.gnu.org/licenses>.
016 */
017
018package com.unboundid.scim.marshal.xml;
019
020import com.unboundid.scim.data.BaseResource;
021import com.unboundid.scim.marshal.StreamMarshaller;
022import com.unboundid.scim.sdk.SCIMException;
023import com.unboundid.scim.sdk.ServerErrorException;
024import com.unboundid.scim.sdk.Debug;
025import com.unboundid.scim.sdk.SCIMConstants;
026import com.unboundid.scim.sdk.Resources;
027import com.unboundid.scim.sdk.BulkOperation;
028import com.unboundid.scim.sdk.SCIMAttribute;
029import com.unboundid.scim.sdk.SCIMAttributeValue;
030import com.unboundid.scim.sdk.StaticUtils;
031
032import javax.xml.XMLConstants;
033import javax.xml.bind.DatatypeConverter;
034import javax.xml.stream.XMLOutputFactory;
035import javax.xml.stream.XMLStreamException;
036import javax.xml.stream.XMLStreamWriter;
037import java.io.IOException;
038import java.io.OutputStream;
039import java.util.Collections;
040import java.util.List;
041import java.util.Set;
042import java.util.concurrent.atomic.AtomicBoolean;
043
044
045/**
046 * This class provides a stream marshaller implementation to write a stream of
047 * SCIM objects to their XML representation.
048 */
049public class XmlStreamMarshaller implements StreamMarshaller
050{
051  private static final String xsiURI =
052      XMLConstants.W3C_XML_SCHEMA_INSTANCE_NS_URI;
053
054  private final OutputStream outputStream;
055  private final XMLStreamWriter xmlStreamWriter;
056
057
058
059  /**
060   * Create a new XML marshaller that writes to the provided output stream.
061   * The resulting marshaller must be closed after use.
062   *
063   * @param outputStream  The output stream to be written by this marshaller.
064   *
065   * @throws SCIMException  If the marshaller could not be created.
066   */
067  public XmlStreamMarshaller(final OutputStream outputStream)
068      throws SCIMException
069  {
070    this.outputStream = outputStream;
071
072    try
073    {
074      final XMLOutputFactory outputFactory = XMLOutputFactory.newInstance();
075      xmlStreamWriter =
076          outputFactory.createXMLStreamWriter(outputStream, "UTF-8");
077    }
078    catch (Exception e)
079    {
080      Debug.debugException(e);
081      throw new ServerErrorException(
082          "Cannot create XML marshaller: " + e.getMessage());
083    }
084  }
085
086
087
088  /**
089   * {@inheritDoc}
090   */
091  @Override
092  public void close() throws SCIMException
093  {
094    try
095    {
096      xmlStreamWriter.close();
097    }
098    catch (XMLStreamException e)
099    {
100      Debug.debugException(e);
101    }
102
103    try
104    {
105      outputStream.close();
106    }
107    catch (IOException e)
108    {
109      Debug.debugException(e);
110    }
111  }
112
113
114
115  /**
116   * {@inheritDoc}
117   */
118  public void marshal(final BaseResource resource)
119      throws SCIMException
120  {
121    try
122    {
123      xmlStreamWriter.writeStartDocument("UTF-8", "1.0");
124      xmlStreamWriter.setDefaultNamespace(SCIMConstants.SCHEMA_URI_CORE);
125
126      final String resourceSchemaURI =
127          resource.getResourceDescriptor().getSchema();
128
129      xmlStreamWriter.writeStartElement(
130          SCIMConstants.DEFAULT_SCHEMA_PREFIX,
131          resource.getResourceDescriptor().getName(), resourceSchemaURI);
132      marshal(resource, xmlStreamWriter, null);
133      xmlStreamWriter.writeEndElement();
134
135      xmlStreamWriter.writeEndDocument();
136    }
137    catch (XMLStreamException e)
138    {
139      Debug.debugException(e);
140      throw new ServerErrorException(
141          "Cannot write resource: " + e.getMessage());
142    }
143  }
144
145
146
147  /**
148   * {@inheritDoc}
149   */
150  public void marshal(final SCIMException response)
151      throws SCIMException
152  {
153    try
154    {
155      xmlStreamWriter.writeStartDocument("UTF-8", "1.0");
156
157      xmlStreamWriter.setPrefix(SCIMConstants.DEFAULT_SCHEMA_PREFIX,
158          SCIMConstants.SCHEMA_URI_CORE);
159      xmlStreamWriter.setPrefix("xsi", xsiURI);
160      xmlStreamWriter.writeStartElement(SCIMConstants.SCHEMA_URI_CORE,
161          "Response");
162      xmlStreamWriter.writeNamespace(SCIMConstants.DEFAULT_SCHEMA_PREFIX,
163          SCIMConstants.SCHEMA_URI_CORE);
164      xmlStreamWriter.writeNamespace("xsi", xsiURI);
165
166      xmlStreamWriter.writeStartElement(
167          SCIMConstants.SCHEMA_URI_CORE, "Errors");
168
169      xmlStreamWriter.writeStartElement(
170          SCIMConstants.SCHEMA_URI_CORE, "Error");
171
172      xmlStreamWriter.writeStartElement(
173          SCIMConstants.SCHEMA_URI_CORE, "code");
174      xmlStreamWriter.writeCharacters(String.valueOf(response.getStatusCode()));
175      xmlStreamWriter.writeEndElement();
176
177      final String description = response.getMessage();
178      if (description != null)
179      {
180        xmlStreamWriter.writeStartElement(
181            SCIMConstants.SCHEMA_URI_CORE, "description");
182        AtomicBoolean base64Encoded = new AtomicBoolean(false);
183        String cleanXML = cleanStringForXML(description, base64Encoded);
184        if (base64Encoded.get())
185        {
186          xmlStreamWriter.writeAttribute("base64Encoded", "true");
187        }
188        xmlStreamWriter.writeCharacters(cleanXML);
189        xmlStreamWriter.writeEndElement();
190      }
191
192      xmlStreamWriter.writeEndElement();
193      xmlStreamWriter.writeEndElement();
194      xmlStreamWriter.writeEndElement();
195      xmlStreamWriter.writeEndDocument();
196    }
197    catch (XMLStreamException e)
198    {
199      Debug.debugException(e);
200      throw new ServerErrorException(
201          "Cannot write error response: " + e.getMessage());
202    }
203  }
204
205
206
207  /**
208   * {@inheritDoc}
209   */
210  public void marshal(final Resources<? extends BaseResource> response)
211      throws SCIMException
212  {
213    try
214    {
215      xmlStreamWriter.writeStartDocument("UTF-8", "1.0");
216
217      xmlStreamWriter.setPrefix(SCIMConstants.DEFAULT_SCHEMA_PREFIX,
218          SCIMConstants.SCHEMA_URI_CORE);
219      xmlStreamWriter.setPrefix("xsi", xsiURI);
220      xmlStreamWriter.writeStartElement(SCIMConstants.SCHEMA_URI_CORE,
221          "Response");
222      xmlStreamWriter.writeNamespace(SCIMConstants.DEFAULT_SCHEMA_PREFIX,
223          SCIMConstants.SCHEMA_URI_CORE);
224      xmlStreamWriter.writeNamespace("xsi", xsiURI);
225
226      xmlStreamWriter.writeStartElement("totalResults");
227      xmlStreamWriter.writeCharacters(
228          Long.toString(response.getTotalResults()));
229      xmlStreamWriter.writeEndElement();
230
231      xmlStreamWriter.writeStartElement("itemsPerPage");
232      xmlStreamWriter.writeCharacters(
233          Integer.toString(response.getItemsPerPage()));
234      xmlStreamWriter.writeEndElement();
235
236      xmlStreamWriter.writeStartElement("startIndex");
237      xmlStreamWriter.writeCharacters(
238          Long.toString(response.getStartIndex()));
239      xmlStreamWriter.writeEndElement();
240
241      xmlStreamWriter.writeStartElement("Resources");
242
243      for (final BaseResource resource : response)
244      {
245        xmlStreamWriter.writeStartElement("Resource");
246        marshal(resource, xmlStreamWriter, xsiURI);
247        xmlStreamWriter.writeEndElement();
248      }
249
250      xmlStreamWriter.writeEndElement();
251
252      xmlStreamWriter.writeEndElement();
253      xmlStreamWriter.writeEndDocument();
254    }
255    catch (XMLStreamException e)
256    {
257      Debug.debugException(e);
258      throw new ServerErrorException(
259          "Cannot write resources: " + e.getMessage());
260    }
261  }
262
263
264  /**
265   * {@inheritDoc}
266   */
267  public void writeBulkStart(final int failOnErrors,
268                             final Set<String> schemaURIs)
269      throws SCIMException
270  {
271    try
272    {
273      xmlStreamWriter.writeStartDocument("UTF-8", "1.0");
274
275      xmlStreamWriter.setPrefix(SCIMConstants.DEFAULT_SCHEMA_PREFIX,
276          SCIMConstants.SCHEMA_URI_CORE);
277      xmlStreamWriter.setPrefix("xsi", xsiURI);
278      xmlStreamWriter.writeStartElement(SCIMConstants.SCHEMA_URI_CORE,
279          "Bulk");
280      xmlStreamWriter.writeNamespace(SCIMConstants.DEFAULT_SCHEMA_PREFIX,
281          SCIMConstants.SCHEMA_URI_CORE);
282      xmlStreamWriter.writeNamespace("xsi", xsiURI);
283
284      if (failOnErrors >= 0)
285      {
286        xmlStreamWriter.writeStartElement("failOnErrors");
287        xmlStreamWriter.writeCharacters(
288            Integer.toString(failOnErrors));
289        xmlStreamWriter.writeEndElement();
290      }
291
292      xmlStreamWriter.writeStartElement("Operations");
293    }
294    catch (XMLStreamException e)
295    {
296      Debug.debugException(e);
297      throw new ServerErrorException(
298          "Cannot write start of bulk operations: " + e.getMessage());
299    }
300  }
301
302
303
304  /**
305   * {@inheritDoc}
306   */
307  public void writeBulkOperation(final BulkOperation o)
308      throws SCIMException
309  {
310    try
311    {
312      xmlStreamWriter.writeStartElement("Operation");
313      if (o.getMethod() != null)
314      {
315        xmlStreamWriter.writeStartElement("method");
316        xmlStreamWriter.writeCharacters(o.getMethod().name());
317        xmlStreamWriter.writeEndElement();
318      }
319      if (o.getBulkId() != null)
320      {
321        xmlStreamWriter.writeStartElement("bulkId");
322        xmlStreamWriter.writeCharacters(o.getBulkId());
323        xmlStreamWriter.writeEndElement();
324      }
325      if (o.getVersion() != null)
326      {
327        xmlStreamWriter.writeStartElement("version");
328        xmlStreamWriter.writeCharacters(o.getVersion());
329        xmlStreamWriter.writeEndElement();
330      }
331      if (o.getPath() != null)
332      {
333        xmlStreamWriter.writeStartElement("path");
334        AtomicBoolean base64Encoded = new AtomicBoolean(false);
335        String cleanXML = cleanStringForXML(o.getPath(), base64Encoded);
336        if(base64Encoded.get())
337        {
338          xmlStreamWriter.writeAttribute("base64Encoded", "true");
339        }
340        xmlStreamWriter.writeCharacters(cleanXML);
341        xmlStreamWriter.writeEndElement();
342      }
343      if (o.getLocation() != null)
344      {
345        xmlStreamWriter.writeStartElement("location");
346        AtomicBoolean base64Encoded = new AtomicBoolean(false);
347        String cleanXML = cleanStringForXML(o.getLocation(), base64Encoded);
348        if(base64Encoded.get())
349        {
350          xmlStreamWriter.writeAttribute("base64Encoded", "true");
351        }
352        xmlStreamWriter.writeCharacters(cleanXML);
353        xmlStreamWriter.writeEndElement();
354      }
355      if (o.getData() != null)
356      {
357        xmlStreamWriter.writeStartElement("data");
358        marshal(o.getData(), xmlStreamWriter, xsiURI);
359        xmlStreamWriter.writeEndElement();
360      }
361      if (o.getStatus() != null)
362      {
363        xmlStreamWriter.writeStartElement("status");
364        xmlStreamWriter.writeStartElement("code");
365        xmlStreamWriter.writeCharacters(o.getStatus().getCode());
366        xmlStreamWriter.writeEndElement();
367        if (o.getStatus().getDescription() != null)
368        {
369          xmlStreamWriter.writeStartElement("description");
370
371          AtomicBoolean base64Encoded = new AtomicBoolean(false);
372          String cleanXML =
373               cleanStringForXML(o.getStatus().getDescription(), base64Encoded);
374          if(base64Encoded.get())
375          {
376            xmlStreamWriter.writeAttribute("base64Encoded", "true");
377          }
378          xmlStreamWriter.writeCharacters(cleanXML);
379          xmlStreamWriter.writeEndElement();
380        }
381        xmlStreamWriter.writeEndElement();
382      }
383      xmlStreamWriter.writeEndElement();
384    }
385    catch (XMLStreamException e)
386    {
387      Debug.debugException(e);
388      throw new ServerErrorException(
389          "Cannot write bulk operation: " + e.getMessage());
390    }
391  }
392
393
394  /**
395   * {@inheritDoc}
396   */
397  public void writeBulkFinish()
398      throws SCIMException
399  {
400    try
401    {
402      xmlStreamWriter.writeEndElement();
403      xmlStreamWriter.writeEndElement();
404      xmlStreamWriter.writeEndDocument();
405    }
406    catch (XMLStreamException e)
407    {
408      Debug.debugException(e);
409      throw new ServerErrorException(
410          "Cannot write end of bulk operations: " + e.getMessage());
411    }
412  }
413
414
415
416  /**
417   * {@inheritDoc}
418   */
419  public void bulkMarshal(final int failOnErrors,
420                          final List<BulkOperation> operations)
421      throws SCIMException
422  {
423    writeBulkStart(failOnErrors, Collections.<String>emptySet());
424    for (final BulkOperation o : operations)
425    {
426      writeBulkOperation(o);
427    }
428    writeBulkFinish();
429  }
430
431
432
433  /**
434   * Write a SCIM object to an XML stream.
435   *
436   * @param resource        The SCIM resource to be written.
437   * @param xmlStreamWriter The stream to which the SCIM object should be
438   *                        written.
439   * @param xsiURI          The xsi URI to use for the type attribute.
440   * @throws XMLStreamException If the object could not be written.
441   */
442  private void marshal(final BaseResource resource,
443                       final XMLStreamWriter xmlStreamWriter,
444                       final String xsiURI)
445    throws XMLStreamException
446  {
447    final String resourceSchemaURI =
448        resource.getResourceDescriptor().getSchema();
449
450    int i = 1;
451    for (final String schemaURI :
452        resource.getResourceDescriptor().getAttributeSchemas())
453    {
454      if (schemaURI.equalsIgnoreCase(resourceSchemaURI))
455      {
456        final String prefix = SCIMConstants.DEFAULT_SCHEMA_PREFIX;
457        xmlStreamWriter.setPrefix(prefix, schemaURI);
458        xmlStreamWriter.writeNamespace(prefix, schemaURI);
459      }
460      else if (resource.getScimObject().hasSchema(schemaURI))
461      {
462        final String prefix = "ns" + String.valueOf(i++);
463        xmlStreamWriter.setPrefix(prefix, schemaURI);
464        xmlStreamWriter.writeNamespace(prefix, schemaURI);
465      }
466    }
467
468    if (xsiURI != null)
469    {
470      xmlStreamWriter.writeAttribute(xsiURI, "type",
471          SCIMConstants.DEFAULT_SCHEMA_PREFIX + ':' +
472              resource.getResourceDescriptor().getName());
473    }
474
475    for (String schema : resource.getScimObject().getSchemas())
476    {
477      for (SCIMAttribute a : resource.getScimObject().getAttributes(schema))
478      {
479        if (a.getAttributeDescriptor().isMultiValued())
480        {
481          writeMultiValuedAttribute(a, xmlStreamWriter);
482        }
483        else
484        {
485          writeSingularAttribute(a, xmlStreamWriter);
486        }
487      }
488    }
489  }
490
491
492
493  /**
494   * Write a multi-valued attribute to an XML stream.
495   *
496   * @param scimAttribute   The attribute to be written.
497   * @param xmlStreamWriter The stream to which the attribute should be
498   *                        written.
499   * @throws XMLStreamException If the attribute could not be written.
500   */
501  private void writeMultiValuedAttribute(final SCIMAttribute scimAttribute,
502                                         final XMLStreamWriter xmlStreamWriter)
503    throws XMLStreamException
504  {
505    final SCIMAttributeValue[] values = scimAttribute.getValues();
506
507    writeStartElement(scimAttribute, xmlStreamWriter);
508
509    for (final SCIMAttributeValue value : values)
510    {
511      if (value == null)
512      {
513        continue;
514      }
515
516      writeChildStartElement(scimAttribute, xmlStreamWriter);
517
518      if (value.isComplex())
519      {
520        for (final SCIMAttribute a : value.getAttributes().values())
521        {
522          if (a.getAttributeDescriptor().isMultiValued())
523          {
524            writeMultiValuedAttribute(a, xmlStreamWriter);
525          }
526          else
527          {
528            writeSingularAttribute(a, xmlStreamWriter);
529          }
530        }
531      }
532      else
533      {
534        String stringValue = value.getStringValue();
535        AtomicBoolean base64Encoded = new AtomicBoolean(false);
536        String cleanXML = cleanStringForXML(stringValue, base64Encoded);
537        if(base64Encoded.get())
538        {
539          xmlStreamWriter.writeAttribute("base64Encoded", "true");
540        }
541        xmlStreamWriter.writeCharacters(cleanXML);
542      }
543      xmlStreamWriter.writeEndElement();
544    }
545
546    xmlStreamWriter.writeEndElement();
547  }
548
549
550
551  /**
552   * Write a singular attribute to an XML stream.
553   *
554   * @param scimAttribute   The attribute to be written.
555   * @param xmlStreamWriter The stream to which the attribute should be
556   *                        written.
557   * @throws XMLStreamException If the attribute could not be written.
558   */
559  private void writeSingularAttribute(final SCIMAttribute scimAttribute,
560                                      final XMLStreamWriter xmlStreamWriter)
561    throws XMLStreamException
562  {
563    writeStartElement(scimAttribute, xmlStreamWriter);
564
565    final SCIMAttributeValue val = scimAttribute.getValue();
566
567    if (val.isComplex())
568    {
569      for (final SCIMAttribute a : val.getAttributes().values())
570      {
571        if (a.getAttributeDescriptor().isMultiValued())
572        {
573          writeMultiValuedAttribute(a, xmlStreamWriter);
574        }
575        else
576        {
577          writeSingularAttribute(a, xmlStreamWriter);
578        }
579      }
580    }
581    else
582    {
583      String stringValue = scimAttribute.getValue().getStringValue();
584      AtomicBoolean base64Encoded = new AtomicBoolean(false);
585      String cleanXML = cleanStringForXML(stringValue, base64Encoded);
586      if(base64Encoded.get())
587      {
588        xmlStreamWriter.writeAttribute("base64Encoded", "true");
589      }
590      xmlStreamWriter.writeCharacters(cleanXML);
591    }
592
593    xmlStreamWriter.writeEndElement();
594  }
595
596
597
598  /**
599   * Helper that writes namespace when needed.
600   * @param scimAttribute Attribute tag to write.
601   * @param xmlStreamWriter Writer to write with.
602   * @throws XMLStreamException thrown if error writing the tag element.
603   */
604  private void writeStartElement(final SCIMAttribute scimAttribute,
605                                 final XMLStreamWriter xmlStreamWriter)
606    throws XMLStreamException
607  {
608    if (scimAttribute.getSchema().equalsIgnoreCase(
609        SCIMConstants.SCHEMA_URI_CORE))
610    {
611      xmlStreamWriter.writeStartElement(scimAttribute.getName());
612    }
613    else
614    {
615      xmlStreamWriter.writeStartElement(scimAttribute.getSchema(),
616        scimAttribute.getName());
617    }
618  }
619
620
621
622  /**
623   * Helper that writes namespace when needed.
624   * @param scimAttribute Attribute tag to write.
625   * @param xmlStreamWriter Writer to write with.
626   * @throws XMLStreamException thrown if error writing the tag element.
627   */
628  private void writeChildStartElement(final SCIMAttribute scimAttribute,
629                                      final XMLStreamWriter xmlStreamWriter)
630    throws XMLStreamException
631  {
632    if (scimAttribute.getSchema().equalsIgnoreCase(
633        SCIMConstants.SCHEMA_URI_CORE))
634    {
635      xmlStreamWriter.writeStartElement(scimAttribute.getAttributeDescriptor().
636          getMultiValuedChildName());
637    }
638    else
639    {
640      xmlStreamWriter.writeStartElement(scimAttribute.getSchema(),
641        scimAttribute.getAttributeDescriptor().getMultiValuedChildName());
642    }
643  }
644
645
646
647  /**
648   * This method determines whether the input string contains invalid XML
649   * unicode characters as specified by the XML 1.0 standard, and thus needs
650   * to be base-64 encoded. It also replaces any unicode characters greater
651   * than 0x7F with their decimal equivalent of their unicode code point
652   * (for example the Copyright sign (0xC2A9) becomes &#49833;)
653   *
654   * The returned string is either:
655   *
656   * 1) The base-64 encoding of the UTF-8 bytes of the original input string,
657   *    iff the original input string contained any invalid XML characters,
658   *
659   *  or
660   *
661   * 2) The original input string with any characters greater than 0x7f
662   *    replaced with the corresponding unicode code point.
663   *
664   * @param input The input string value to clean
665   * @param base64Encoded An output parameter indicating whether the returned
666   *                      string is base64-encoded.
667   * @return an XML-safe version of the input string, possibly base64-encoded
668   *         if the input contained invalid XML characters.
669   */
670  private static String cleanStringForXML(final String input,
671                                          final AtomicBoolean base64Encoded)
672  {
673    if (input == null || input.isEmpty())
674    {
675      return "";
676    }
677
678    //Buffer to hold the escaped output.
679    StringBuilder output = new StringBuilder(input.length());
680
681    char c;
682    for(int i = 0; i < input.length(); i++)
683    {
684      c = input.charAt(i);
685      if((c == 0x9) ||
686         (c == 0xA) ||
687         (c == 0xD) ||
688         ((c >= 0x20) && (c <= 0xD7FF)) ||
689         ((c >= 0xE000) && (c <= 0xFFFD)) ||
690         ((c >= 0x10000) && (c <= 0x10FFFF)))
691      {
692        //It's a valid XML character, now check if it needs escaping.
693        if (c > 0x7F)
694        {
695          output.append("&#").append(Integer.toString(c, 10)).append(";");
696        }
697        else
698        {
699          output.append(c);
700        }
701        continue;
702      }
703      else
704      {
705        //It's an invalid XML character, so base64-encode the whole thing.
706        if (base64Encoded != null)
707        {
708          base64Encoded.set(true);
709        }
710        return DatatypeConverter.printBase64Binary(
711                 StaticUtils.getUTF8Bytes(input));
712      }
713    }
714
715    if(base64Encoded != null)
716    {
717      base64Encoded.set(false);
718    }
719    return output.toString();
720  }
721}