package rocks.inspectit.shared.cs.jaxb; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.nio.file.DirectoryStream; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardOpenOption; import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; import java.util.List; import javax.xml.XMLConstants; import javax.xml.bind.JAXBContext; import javax.xml.bind.JAXBException; import javax.xml.bind.Marshaller; import javax.xml.bind.Unmarshaller; import javax.xml.parsers.DocumentBuilder; import javax.xml.parsers.DocumentBuilderFactory; import javax.xml.parsers.ParserConfigurationException; import javax.xml.transform.Result; import javax.xml.transform.Source; import javax.xml.transform.Transformer; import javax.xml.transform.TransformerException; import javax.xml.transform.TransformerFactory; import javax.xml.transform.stream.StreamResult; import javax.xml.transform.stream.StreamSource; import javax.xml.validation.Schema; import javax.xml.validation.SchemaFactory; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.util.CollectionUtils; import org.w3c.dom.Document; import org.w3c.dom.Element; import org.xml.sax.SAXException; import com.esotericsoftware.minlog.Log; /** * Component for marshall and unmarshall operation on JAXB. * <p> * Marshalling can be done to the file or to the byte array. Marshalling also supports definition of * the schema location and schema version. If this information is passed it will be added to the * output. * <p> * Unmarshalling supports migration to the updated schema version if needed (see * {@link #unmarshall(Path, Path, int, Path, Class)} and * {@link #unmarshall(byte[], Path, int, Path, Class)}). Unmarshalling can also be done with out any * migration. * * @author Ivan Senic * */ public class JAXBTransformator { /** * Logger of the class. */ private static final Logger LOG = LoggerFactory.getLogger(JAXBTransformator.class); /** * Marshals the object to the given path that must represent a path to the file. * * @param path * Path to file * @param object * Object to marshal * @param noNamespaceSchemaLocation * NoNamespaceSchemaLocation to set. If it's <code>null</code> no location will be * set. * @throws JAXBException * If {@link JAXBException} occurs. * @throws IOException * If {@link IOException} occurs. */ public void marshall(Path path, Object object, String noNamespaceSchemaLocation) throws JAXBException, IOException { marshall(path, object, noNamespaceSchemaLocation, 0); } /** * Marshals the object to the given path that must represent a path to the file. * <p> * This method is capable of setting the schema version to the object being marshalled. * * @param path * Path to file * @param object * Object to marshal * @param noNamespaceSchemaLocation * NoNamespaceSchemaLocation to set. If it's <code>null</code> no location will be * set. * @param schemaVersion * If schema version is set and object to marshall is instance of * {@link ISchemaVersionAware} then the given schema version will be set to the * object. Use <code>0</code> to ignore setting of schema version. * @throws JAXBException * If {@link JAXBException} occurs. * @throws IOException * If {@link IOException} occurs. */ public void marshall(Path path, Object object, String noNamespaceSchemaLocation, int schemaVersion) throws JAXBException, IOException { if (Files.isDirectory(path)) { throw new IOException("Can not marshal object to the path that represents the directory"); } Files.createDirectories(path.getParent()); JAXBContext context = JAXBContext.newInstance(object.getClass()); Marshaller marshaller = context.createMarshaller(); marshaller.setProperty(Marshaller.JAXB_FORMATTED_OUTPUT, true); if (null != noNamespaceSchemaLocation) { marshaller.setProperty(Marshaller.JAXB_NO_NAMESPACE_SCHEMA_LOCATION, noNamespaceSchemaLocation); } // set schema version if needed if ((object instanceof ISchemaVersionAware) && (0 != schemaVersion)) { ((ISchemaVersionAware) object).setSchemaVersion(schemaVersion); } try (OutputStream outputStream = Files.newOutputStream(path)) { marshaller.marshal(object, outputStream); } } /** * Marshals the object to the bytes. * * @param object * Object to marshal * @param noNamespaceSchemaLocation * NoNamespaceSchemaLocation to set. If it's <code>null</code> no location will be * set. * @return bytes representing the results of the marshall operation * @throws JAXBException * If {@link JAXBException} occurs. * @throws IOException * If {@link IOException} occurs. */ public byte[] marshall(Object object, String noNamespaceSchemaLocation) throws JAXBException, IOException { return marshall(object, noNamespaceSchemaLocation, 0); } /** * Marshals the object to the bytes. * * @param object * Object to marshal * @param noNamespaceSchemaLocation * NoNamespaceSchemaLocation to set. If it's <code>null</code> no location will be * set. * @param schemaVersion * If schema version is set and object to marshall is instance of * {@link ISchemaVersionAware} then the given schema version will be set to the * object. Use <code>0</code> to ignore setting of schema version. * @return bytes representing the results of the marshall operation * @throws JAXBException * If {@link JAXBException} occurs. * @throws IOException * If {@link IOException} occurs. */ public byte[] marshall(Object object, String noNamespaceSchemaLocation, int schemaVersion) throws JAXBException, IOException { JAXBContext context = JAXBContext.newInstance(object.getClass()); Marshaller marshaller = context.createMarshaller(); marshaller.setProperty(Marshaller.JAXB_FORMATTED_OUTPUT, true); if (null != noNamespaceSchemaLocation) { marshaller.setProperty(Marshaller.JAXB_NO_NAMESPACE_SCHEMA_LOCATION, noNamespaceSchemaLocation); } // set schema version if needed if ((object instanceof ISchemaVersionAware) && (0 != schemaVersion)) { ((ISchemaVersionAware) object).setSchemaVersion(schemaVersion); } try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) { marshaller.marshal(object, outputStream); return outputStream.toByteArray(); } } /** * Unmarshalls the given file. The root class of the XML must be given. * <p> * No migration will be tried if schema validation fails. * * @param <T> * Type of root object. * @param path * Path to file to unmarshall. * @param schemaPath * Path to the XSD schema that will be used to validate the XML file. If no schema is * provided no validation will be performed. * @param rootClass * Root class of the XML document. * @return Unmarshalled object. * @throws JAXBException * If {@link JAXBException} occurs during loading. * @throws IOException * If {@link IOException} occurs during loading. * @throws SAXException * If {@link SAXException} occurs during schema parsing. */ public <T> T unmarshall(Path path, Path schemaPath, Class<T> rootClass) throws JAXBException, IOException, SAXException { return this.unmarshall(path, schemaPath, 0, null, rootClass); } /** * Unmarshalls the given file. The root class of the XML must be given. * <p> * This method allows migration of the specified XML file to the wanted target schema version * using the files in the migration path (if possible). If the migration is successful the * original file be replaced with the migrated content. * * @param <T> * Type of root object. * @param path * Path to file to unmarshall. * @param schemaPath * Path to the XSD schema that will be used to validate the XML file. If no schema is * provided no validation will be performed. * @param targetSchemaVersion * The current schema version that is used as target. * @param migrationPath * Path that contains the XSLT migration files to use if schema validation fails. * @param rootClass * Root class of the XML document. * @return Unmarshalled object. * @throws JAXBException * If {@link JAXBException} occurs during loading. * @throws IOException * If {@link IOException} occurs during loading. * @throws SAXException * If {@link SAXException} occurs during schema parsing. */ @SuppressWarnings("unchecked") public <T> T unmarshall(Path path, Path schemaPath, int targetSchemaVersion, Path migrationPath, Class<T> rootClass) throws JAXBException, IOException, SAXException { if (Files.notExists(path) || Files.isDirectory(path)) { return null; } // check if we need to migrate InputStream inputStream = null; boolean migrated = false; if (null != migrationPath) { try (InputStream schemaCheckStream = Files.newInputStream(path, StandardOpenOption.READ)) { int schemaVersion = getSchemaVersion(schemaCheckStream, 0); // migrate if versions differ if (schemaVersion < targetSchemaVersion) { try { LOG.info("|- Migrating file " + path.toAbsolutePath().toString() + " from schema version " + schemaVersion + " to " + targetSchemaVersion); // enter migration, we expect result of migration as the result inputStream = migrate(Files.newInputStream(path, StandardOpenOption.READ), migrationPath, schemaVersion, targetSchemaVersion); migrated = (null != inputStream); } catch (TransformerException e) { String pathString = path.toAbsolutePath().toString(); throw new JAXBException("Failed to migrate data in file " + pathString, e); } } else if (schemaVersion > targetSchemaVersion) { LOG.warn("|- Migration of file " + path.toAbsolutePath().toString() + " is not possible. Current schema version " + schemaVersion + " is higher than migration target version " + targetSchemaVersion); } } } // no matter of migration, if here we have null load again if (null == inputStream) { inputStream = Files.newInputStream(path, StandardOpenOption.READ); } Unmarshaller unmarshaller = getUnmarshaller(schemaPath, rootClass); try { T object = (T) unmarshaller.unmarshal(inputStream); if (migrated) { if (object instanceof ISchemaVersionAware) { ((ISchemaVersionAware) object).setSchemaVersion(targetSchemaVersion); } // if need to rewrite just pass the object to the marshall String noNamespaceSchemaLocation = path.relativize(schemaPath).toString(); this.marshall(path, object, noNamespaceSchemaLocation); } return object; } finally { inputStream.close(); } } /** * Unmarshalls the bytes. The root class of the XML must be given. * <p> * No migration will be tried if schema validation fails. * * @param <T> * Type of root object. * @param data * bytes * @param schemaPath * Path to the XSD schema that will be used to validate the XML file. If no schema is * provided no validation will be performed. * @param rootClass * Root class of the XML document. * @return Unmarshalled object. * @throws JAXBException * If {@link JAXBException} occurs during loading. * @throws IOException * If {@link IOException} occurs during loading. * @throws SAXException * If {@link SAXException} occurs during schema parsing. */ public <T> T unmarshall(byte[] data, Path schemaPath, Class<T> rootClass) throws JAXBException, IOException, SAXException { return this.unmarshall(data, schemaPath, 0, null, rootClass); } /** * Unmarshalls the bytes. The root class of the XML must be given. * <p> * This method allows migration of the specified XML file to the wanted target schema version * using the files in the migration path (if possible). * * @param <T> * Type of root object. * @param data * bytes * @param schemaPath * Path to the XSD schema that will be used to validate the XML file. If no schema is * provided no validation will be performed. * @param targetSchemaVersion * The current schema version that is used as target. * @param migrationPath * Path that contains the XSLT migration files to use if schema validation fails. * @param rootClass * Root class of the XML document. * @return Unmarshalled object. * @throws JAXBException * If {@link JAXBException} occurs during loading. * @throws IOException * If {@link IOException} occurs during loading. * @throws SAXException * If {@link SAXException} occurs during schema parsing. */ @SuppressWarnings("unchecked") public <T> T unmarshall(byte[] data, Path schemaPath, int targetSchemaVersion, Path migrationPath, Class<T> rootClass) throws JAXBException, IOException, SAXException { // check if we need to migrate InputStream inputStream = null; boolean migrated = false; if (null != migrationPath) { try (InputStream xmlInputStream = new ByteArrayInputStream(data)) { int schemaVersion = getSchemaVersion(xmlInputStream, 0); // migrate if versions differ if (schemaVersion < targetSchemaVersion) { try { LOG.info("|- Migrating data bytes from schema version " + schemaVersion + " to " + targetSchemaVersion); // enter migration, we expect result of migration as the result inputStream = migrate(new ByteArrayInputStream(data), migrationPath, schemaVersion, targetSchemaVersion); migrated = (null != inputStream); } catch (TransformerException e) { throw new JAXBException("Failed to migrate data bytes", e); } } else if (schemaVersion > targetSchemaVersion) { LOG.warn("|- Migration of data bytes is not possible. Current schema version " + schemaVersion + " is higher than migration target version " + targetSchemaVersion); } } } // no matter of migration, if here we have null as stream use original if (null == inputStream) { inputStream = new ByteArrayInputStream(data); } Unmarshaller unmarshaller = getUnmarshaller(schemaPath, rootClass); try { T object = (T) unmarshaller.unmarshal(inputStream); if (migrated && (object instanceof ISchemaVersionAware)) { ((ISchemaVersionAware) object).setSchemaVersion(targetSchemaVersion); } return object; } finally { inputStream.close(); } } /** * Tries to find the first <code>schemaVersion=""</code> attribute in the XML root and returns * value inside as integer. * * @param xmlInputStream * Stream to read from. * @param defaultValue * default value to return to caller if version could not be found or attribute does * not have an integer number. * @return Version attribute value in root element found. */ private int getSchemaVersion(InputStream xmlInputStream, int defaultValue) { try { DocumentBuilderFactory dbFactory = DocumentBuilderFactory.newInstance(); DocumentBuilder dBuilder = dbFactory.newDocumentBuilder(); Document document = dBuilder.parse(xmlInputStream); // optional, but recommended // http://stackoverflow.com/questions/13786607/normalization-in-dom-parsing-with-java-how-does-it-work Element element = document.getDocumentElement(); element.normalize(); // we expect schemaVersion attribute in the root element if (element.hasAttribute("schemaVersion")) { String version = element.getAttribute("schemaVersion"); return (int) Double.parseDouble(version); } else { return defaultValue; } } catch (ParserConfigurationException | SAXException | IOException | NumberFormatException e) { LOG.warn("Error trying to get the schema version attribute.", e); return defaultValue; } } /** * Creates new {@link Unmarshaller} for the given root class. * <p> * If the schema path is given then the schema will be set to the {@link Unmarshaller} for the * validation. * * @param schemaPath * Path to the XSD schema that will be used in {@link Unmarshaller} for the * validation. * @param rootClass * Root class. * @return {@link Unmarshaller} instance. * @throws JAXBException * If {@link JAXBException} occurs due to the root class not being valid.. * @throws IOException * If {@link IOException} occurs during loading of the schema file. * @throws SAXException * If {@link SAXException} occurs during schema parsing. */ private Unmarshaller getUnmarshaller(Path schemaPath, Class<?> rootClass) throws JAXBException, IOException, SAXException { JAXBContext context = JAXBContext.newInstance(rootClass); Unmarshaller unmarshaller = context.createUnmarshaller(); if ((null != schemaPath) && Files.exists(schemaPath)) { SchemaFactory sf = SchemaFactory.newInstance(XMLConstants.W3C_XML_SCHEMA_NS_URI); try (InputStream inputStream = Files.newInputStream(schemaPath, StandardOpenOption.READ)) { Schema schema = sf.newSchema(new StreamSource(inputStream)); unmarshaller.setSchema(schema); } } return unmarshaller; } /** * Tries to migrate the XML contained in the given {@link InputStream} with the XSLT files * contained in the migration path. If migration is successful the migrated XML is returned as * content of the input stream. * * @param inputStream * Input stream of the original XML. * @param migrationPath * Path containing migration XSLT file(s). * @param fromVersion * Version to migrate from. * @param toVersion * Version to migrate to. * @return Unmarshalled object after XML migration, or <code>null</code> if migrating is not * possible due to the * @throws TransformerException * If {@link TransformerException} occurs during transforming of the XML. * @throws IOException * If migration file(s) can not be loaded. * @throws JAXBException * If unmarshall fails with migrated XML. */ private InputStream migrate(InputStream inputStream, Path migrationPath, int fromVersion, int toVersion) throws TransformerException, IOException, JAXBException { // get migration paths List<Path> migrationFiles = getMigrationFiles(migrationPath); if (CollectionUtils.isEmpty(migrationFiles)) { Log.warn("Migration not possible. No migration file exists in " + migrationPath.toAbsolutePath().toString()); return null; } // cut the list based on the wanted versions, we expect sorted already try { migrationFiles = migrationFiles.subList(fromVersion, toVersion); } catch (IndexOutOfBoundsException e) { Log.warn("Migration not possible, try to migrate from version " + fromVersion + " to version " + toVersion + ". Migration files avaliable is " + migrationFiles.size()); return null; } // create transformer factory TransformerFactory factory = TransformerFactory.newInstance(); // input stream for the migration InputStream xmlInputStream = inputStream; for (Path migrationFile : migrationFiles) { LOG.info("||-Appling migration file " + migrationFile.getFileName()); // take migration xslt file try (InputStream xsltInputStream = Files.newInputStream(migrationFile, StandardOpenOption.READ); InputStream is = xmlInputStream) { // create sources (both xslt and xml to migrate) Source xsltSource = new StreamSource(xsltInputStream); Source toMigrate = new StreamSource(is); // output stream for writing results ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); Result result = new StreamResult(outputStream); // transform Transformer transformer = factory.newTransformer(xsltSource); transformer.transform(toMigrate, result); // and save the result as the input for the next migration or unmarshaling xmlInputStream = new ByteArrayInputStream(outputStream.toByteArray()); } } // return migrated input stream return xmlInputStream; } /** * Collects the files from given directory if it exists. Files will be sorted by name. * * @param migrationPath * Path * @return Sorted files * @throws IOException * If directory stream fails */ private List<Path> getMigrationFiles(Path migrationPath) throws IOException { if ((null == migrationPath) || !Files.exists(migrationPath) || !Files.isDirectory(migrationPath)) { return Collections.emptyList(); } // get directory stream and add all files DirectoryStream<Path> directoryStream = Files.newDirectoryStream(migrationPath); List<Path> migrationsFiles = new ArrayList<>(); for (Path migrationFile : directoryStream) { if (!Files.isDirectory(migrationFile)) { migrationsFiles.add(migrationFile); } } // sort by name Collections.sort(migrationsFiles, new Comparator<Path>() { @Override public int compare(Path o1, Path o2) { return o1.getFileName().toString().compareTo(o2.getFileName().toString()); } }); return migrationsFiles; } }