/** * Copyright (c) Codice Foundation * <p/> * This is free software: you can redistribute it and/or modify it under the terms of the GNU Lesser * General Public License as published by the Free Software Foundation, either version 3 of the * License, or any later version. * <p/> * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without * even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * Lesser General Public License for more details. A copy of the GNU Lesser General Public License * is distributed along with this program and can be found at * <http://www.gnu.org/licenses/lgpl.html>. */ package ddf.services.schematron; import java.io.StringReader; import java.net.URL; import java.util.ArrayList; import java.util.List; import java.util.Vector; import javax.xml.parsers.ParserConfigurationException; import javax.xml.transform.ErrorListener; import javax.xml.transform.Source; import javax.xml.transform.Templates; import javax.xml.transform.Transformer; import javax.xml.transform.TransformerConfigurationException; import javax.xml.transform.TransformerException; import javax.xml.transform.TransformerFactory; import javax.xml.transform.URIResolver; import javax.xml.transform.dom.DOMResult; import javax.xml.transform.dom.DOMSource; import javax.xml.transform.stream.StreamSource; import org.apache.commons.lang.StringUtils; import org.osgi.framework.Bundle; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import ddf.catalog.data.Metacard; import ddf.catalog.plugin.StopProcessingException; import ddf.catalog.validation.MetacardValidator; import ddf.catalog.validation.ValidationException; import net.sf.saxon.Configuration; import net.sf.saxon.TransformerFactoryImpl; import net.sf.saxon.trans.DynamicLoader; /** * This pre-ingest service provides validation of an ingested XML document against a Schematron * schema file. * * When this service is instantiated at deployment time to the OSGi container it goes through 3 * different preprocessing stages on the Schematron schema file. (These steps are required by the * ISO Schematron implementation) * <ol> * <li>1. Preprocess the Schematron schema with iso_dsdl_include.xsl. This is a macro processor to * assemble the schema from various parts.</li> * <li>2. Preprocess the output from stage 1 with iso_abstract_expand.xsl. This is a macro processor * to convert abstract patterns to real patterns.</li> * <li>3. Compile the Schematron schema into an XSLT script. This will use iso_svrl_for_xslt2.xsl * (which in turn invokes iso_schematron_skeleton_for_saxon.xsl)</li> * </ol> * * When XML documents are ingested, this service will run the XSLT generated by stage 3 against the * XML document, validating it against the "compiled" Schematron schema file. * * This service is using the SVRL script, hence the output of the validation will be an * SVRL-formatted XML document. * * @see <a href="http://www.schematron.com">Schematron</a> * * @author rodgersh * */ public class SchematronValidationService implements MetacardValidator { private static final int DEFAULT_PRIORITY = 100; private static final String CLASS_NAME = SchematronValidationService.class.getName(); /** ISO Schematron XSLT to expand inclusions in provided Schematron schema file */ private static final String ISO_SCHEMATRON_INCLUSION_EXPAND_XSL_FILENAME = "iso-schematron/iso_dsdl_include.xsl"; /** ISO Schematron XSLT to expand abstractions in provided Schematron schema file */ private static final String ISO_SCHEMATRON_ABSTRACT_EXPAND_XSL_FILENAME = "iso-schematron/iso_abstract_expand.xsl"; /** * SVRL extension to ISO Schematron skeleton, allowing an XML-formatted report output from * Schematron validation */ private static final String ISO_SVRL_XSL_FILENAME = "iso-schematron/iso_svrl_for_xslt2.xsl"; /** * Base directory in the bundle that all paths to resources will be relative to, * e.g., "xyz" could be the bundle base directory and path to CVE files would be appended * to this bundle base directory. A value of null indicates that "/" is the default * base directory for the bundle. */ private final String bundleBaseDir; /** This class' logger */ private static final Logger LOGGER = LoggerFactory.getLogger(SchematronValidationService.class); /** The original Schematron .sch file */ private String schematronSchemaFilename; /** * Priority of this service using this Schematron .sch ruleset file. This is retrieved by * LocalSite implementation to determine the order of execution of the PreIngest services. */ private int priority; /** * Flag indicating if Schematron warnings should be suppressed, meaning that if only warnings * are detected during validation, then Catalog Entry is considered valid. */ private boolean suppressWarnings; /** * Saxon transformer factory. The Saxon TransformerFactory is specified to JAXP via the * META-INF/services/javax.xml.transform.TransformerFactory file. */ private TransformerFactory transformerFactory; /** The compiled Schematron schema file used to validate the input XML document */ private Templates validator; /** Generated xsl:messages from the preprocessor */ private Vector<String> warnings = new Vector<String>(); /** Report generated during transformation/validation of input XML against precompiled .sch file */ private SchematronReport report; /** * @param bundle * OSGi bundle containing sch file that will be using this service * @param schematronSchemaFilename * client-provided Schematron rules in XML-format, usually with a .sch file extension * * @throws SchematronInitializationException */ public SchematronValidationService(final Bundle bundle, String schematronSchemaFilename) throws SchematronInitializationException { this(bundle, schematronSchemaFilename, false); } /** * @param bundle * OSGi bundle containing sch file that will be using this service * @param schematronSchemaFilename * client-provided Schematron rules in XML-format, usually with a .sch file extension * @param suppressWarnings * indicates whether to suppress Schematron validation warnings and indicate that a * Catalog Entry with only warnings is valid * * @throws SchematronInitializationException */ public SchematronValidationService(final Bundle bundle, String schematronSchemaFilename, boolean suppressWarnings) throws SchematronInitializationException { String methodName = "constructor"; LOGGER.debug("ENTERING: {}.{}", CLASS_NAME, methodName); LOGGER.debug("schematronSchemaFilename = {}", schematronSchemaFilename); LOGGER.debug("suppressWarnings = {}", suppressWarnings); this.schematronSchemaFilename = schematronSchemaFilename; this.bundleBaseDir = null; this.suppressWarnings = suppressWarnings; this.priority = DEFAULT_PRIORITY; init(bundle, schematronSchemaFilename); LOGGER.debug("EXITING: {}.{}", CLASS_NAME, methodName); } /** * @param bundle * OSGi bundle containing sch file that will be using this service * @param schematronSchemaFilename * client-provided Schematron rules in XML-format, usually with a .sch file extension * @param bundleBaseDir * base directory in the bundle for getting resources out of the bundle * @param suppressWarnings * indicates whether to suppress Schematron validation warnings and indicate that a * Catalog Entry with only warnings is valid * * @throws SchematronInitializationException */ public SchematronValidationService(final Bundle bundle, String schematronSchemaFilename, String bundleBaseDir, boolean suppressWarnings) throws SchematronInitializationException { String methodName = "constructor"; LOGGER.debug("ENTERING: {}.{}", CLASS_NAME, methodName); LOGGER.debug("schematronSchemaFilename = {}", schematronSchemaFilename); LOGGER.debug("suppressWarnings = {}", suppressWarnings); this.schematronSchemaFilename = schematronSchemaFilename; this.bundleBaseDir = bundleBaseDir; this.suppressWarnings = suppressWarnings; this.priority = DEFAULT_PRIORITY; init(bundle, schematronSchemaFilename); LOGGER.debug("EXITING: {}.{}", CLASS_NAME, methodName); } /** * @param bundle * OSGi bundle containing sch file that will be using this service * @param schematronSchemaFilename * * @throws SchematronInitializationException */ private void init(final Bundle bundle, String schematronSchemaFilename) throws SchematronInitializationException { String methodName = "init"; LOGGER.debug("ENTERING: {}.{}", CLASS_NAME, methodName); // Initialize TransformerFactory if not already done if (transformerFactory == null) { transformerFactory = TransformerFactory .newInstance(net.sf.saxon.TransformerFactoryImpl.class.getName(), SchematronValidationService.class.getClassLoader()); } // Build the URI resolver to resolve any address for those who call base XSLTs from // extension bundles // (i.e., bundles other than the one we are currently running in) try { URIResolver resolver = new URIResolver() { @Override public Source resolve(String href, String base) throws TransformerException { LOGGER.debug("URIResolver: href = {}, base = {}", href, base); LOGGER.debug("bundleBaseDir = {}", bundleBaseDir); // Since paths to resources within a bundle are not actually file system paths, // paths with relative pathing in them should be stripped out. Relative pathing // notation is not understood by the bundle.getResource(...) method. // Example: the path xyz/Schematron/subdir1/../../CVE/subdir2 is *not* the equivalient // to xyz/CVE/subdir2. xyz would have been passed in as the bundleBaseDir, and // the href passed in would be ../../CVE/subdir2, hence the relative pathing // needs to be removed. href = StringUtils.remove(href, "../"); href = StringUtils.remove(href, "./"); if (StringUtils.isNotBlank(bundleBaseDir)) { href = bundleBaseDir + "/" + href; } LOGGER.debug("URIResolver: (Modified) href = {}", href); try { URL resourceAddressURL = bundle.getResource(href); String resourceAddress = resourceAddressURL.toString(); LOGGER.debug("Resolved resource address: {}", resourceAddress); return new StreamSource(resourceAddress); } catch (Exception e) { LOGGER.error("URIResolver error: {}", e.getMessage(), e); return null; } } }; transformerFactory.setURIResolver(resolver); // Use Saxon-specific Configuration class to setup a dynamic class loader so we can // search across bundles for imported XSLTs Configuration config = ((TransformerFactoryImpl) transformerFactory).getConfiguration(); DynamicLoader dynamicLoader = new DynamicLoader(); dynamicLoader.setClassLoader(new BundleProxyClassLoader(bundle)); config.setDynamicLoader(dynamicLoader); // DDF-855: set ErrorListener to catch any warnings/errors during loading of the // ruleset file and log (vs. Saxon default of writing to console) the warnings/errors config.setErrorListener(new SaxonErrorListener(this.schematronSchemaFilename)); // Retrieve the Schematron schema XML file (usually a .sch file) URL schUrl = bundle.getResource(schematronSchemaFilename); Source schSource = new StreamSource(schUrl.toString()); // Stage 1: Perform inclusion expansion on Schematron schema file DOMResult stage1Result = performStage(bundle, schSource, ISO_SCHEMATRON_INCLUSION_EXPAND_XSL_FILENAME); DOMSource stage1Output = new DOMSource(stage1Result.getNode()); // Stage 2: Perform abstract expansion on output file from Stage 1 DOMResult stage2Result = performStage(bundle, stage1Output, ISO_SCHEMATRON_ABSTRACT_EXPAND_XSL_FILENAME); DOMSource stage2Output = new DOMSource(stage2Result.getNode()); // Stage 3: Compile the .sch rules that have been prepocessed by Stages 1 and 2 (i.e., // the output of Stage 2) DOMResult stage3Result = performStage(bundle, stage2Output, ISO_SVRL_XSL_FILENAME); DOMSource stage3Output = new DOMSource(stage3Result.getNode()); // Precompile the Schematron preprocessor XSL file this.validator = transformerFactory.newTemplates(stage3Output); } catch (TransformerConfigurationException e) { LOGGER.error("Couldn't create transfomer", e); throw new SchematronInitializationException( "Error trying to create SchematronValidationService using sch file " + this.schematronSchemaFilename, e); } catch (TransformerException e) { LOGGER.error("Couldn't create transfomer", e); throw new SchematronInitializationException( "Error trying to create SchematronValidationService using sch file " + this.schematronSchemaFilename, e); } catch (ParserConfigurationException e) { LOGGER.error("Couldn't create transfomer", e); throw new SchematronInitializationException( "Error trying to create SchematronValidationService using sch file " + this.schematronSchemaFilename, e); // Would go here if an invalid .sch file was passed in } catch (Exception e) { LOGGER.error("Couldn't create transfomer", e); throw new SchematronInitializationException( "Error trying to create SchematronValidationService using sch file " + this.schematronSchemaFilename, e); } LOGGER.debug("EXITING: {}.{}", CLASS_NAME, methodName); } /** * Execute a Schematron preprocessing/compile stage on the provided input source using the * provided preprocessor file. * * @param bundle * OSGi bundle * @param input * name of file to be transformed * @param preprocessorFilename * name of preprocessor file to use for the transformation * * @return result of transforming the preprocessor file into a DOMResult * @throws TransformerException * @throws TransformerConfigurationException * @throws ParserConfigurationException */ private DOMResult performStage(final Bundle bundle, Source input, String preprocessorFilename) throws TransformerException, TransformerConfigurationException, ParserConfigurationException, SchematronInitializationException { String methodName = "performStage"; LOGGER.debug("ENTERING: {}.{}", CLASS_NAME, methodName); LOGGER.debug("preprocessorFilename = {}", preprocessorFilename); // Retrieve the preprocessor XSL file URL preprocessorUrl = bundle.getResource(preprocessorFilename); if (preprocessorUrl == null) { LOGGER.debug( "preprocessorUrl is NULL - cannot perform staging of Schematron preprocessor file"); throw new SchematronInitializationException( "preprocessorUrl is NULL for file " + preprocessorFilename + " - cannot perform staging of Schematron preprocessor file"); } else { LOGGER.debug("URL = {}", preprocessorUrl.toString()); } Source preprocessorSource = new StreamSource(preprocessorUrl.toString()); // Initialize container for warnings we may receive during transformation of input warnings = new Vector<String>(); TransformerFactory transformerFactory = TransformerFactory .newInstance(net.sf.saxon.TransformerFactoryImpl.class.getName(), SchematronValidationService.class.getClassLoader()); Transformer transformer = transformerFactory.newTransformer(preprocessorSource); // Setup an error listener to catch warnings and errors generated during transformation Listener listener = new Listener(); transformer.setErrorListener(listener); // Transform the input using the preprocessor's transformer, capturing the output in a DOM DOMResult domResult = new DOMResult(); transformer.transform(input, domResult); LOGGER.debug("EXITING: {}.{}", CLASS_NAME, methodName); return domResult; } /** * Perform Schematron validation on specified catalog entry. * * @param catalogEntryNum * of the catalog entry being validated (ranges from 1 to n) * @param catalogEntry * catalog entry to be validated * * @throws StopProcessingException */ public void performSchematronValidation(int catalogEntryNum, Metacard catalogEntry) throws StopProcessingException { String methodName = "performSchematronValidation"; LOGGER.debug("ENTERING: {}.{}", CLASS_NAME, methodName); LOGGER.debug("Using .sch ruleset: {}", this.schematronSchemaFilename); // Convert the catalog entry's Document to a String String entryDocument = catalogEntry.getMetadata(); LOGGER.debug("entryDocument: {}", entryDocument); // Create a Reader for the catalog entry's contents StringReader entryDocumentReader = new StringReader(entryDocument); try { // Using the precompiled/stored Schematron validator, validate the catalog entry's // contents Transformer transformer = validator.newTransformer(); DOMResult schematronResult = new DOMResult(); transformer.transform(new StreamSource(entryDocumentReader), schematronResult); this.report = new SvrlReport(schematronResult); LOGGER.trace("SVRL Report:\n\n{}", report.getReportAsText()); // If the Schematron validation failed, then throw an exception with details of the // errors // and warnings from the Schematron report included in the exception that is thrown to // the client. if (!this.report.isValid(this.suppressWarnings)) { StringBuffer errorMessage = new StringBuffer( "Schematron validation failed for catalog entry #" + catalogEntryNum + ".\n\n"); List<String> errors = this.report.getErrors(); LOGGER.debug("errors.size() = {}", errors.size()); for (String error : errors) { errorMessage.append(error); errorMessage.append("\n"); } // If warnings are to be included from the Schematron report as part of the errors // message if (!this.suppressWarnings) { List<String> warnings = this.report.getWarnings(); LOGGER.debug("warnings.size() = {}", warnings.size()); for (String warning : warnings) { LOGGER.debug("warning = {}", warning); errorMessage.append(warning); errorMessage.append("\n"); } } throw new StopProcessingException(errorMessage.toString()); } } catch (TransformerException te) { LOGGER.debug("Unable to setup validator", te); LOGGER.debug("EXITING: {}.{}", CLASS_NAME, methodName); throw new StopProcessingException("Could not setup validator to perform validation."); } LOGGER.debug("EXITING: {}.{}", CLASS_NAME, methodName); } /** * Retrieve the Schematron validation results. * * @return Schematron validation output report */ public SchematronReport getSchematronReport() { return this.report; } /** * Retrieve the name of the original Schematron .sch file provided as input to this validation * service. * * @return name of original Schematron .sch file */ public String getSchematronSchemaFilename() { return this.schematronSchemaFilename; } /** * Retrieve suppress warnings flag. * * @return true indicates Schematron warnings are being suppressed */ public boolean getSuppressWarnings() { LOGGER.debug("ENTERING: getSuppressWarnings"); return this.suppressWarnings; } /** * Suppress Schematron warnings, such that only errors mark a request as invalid. This method is * called whenever the Save button is selected for a Schematron ruleset bundle on the OSGi * container's web console Configuration admin page. * * @param suppressWarnings * true indicates Schematron warnings are to be suppressed */ public void setSuppressWarnings(boolean suppressWarnings) { LOGGER.debug("ENTERING: setSuppressWarnings"); LOGGER.debug("suppressWarnings = {} (sch filename = {})", suppressWarnings, this.schematronSchemaFilename); this.suppressWarnings = suppressWarnings; LOGGER.debug("EXITING: setSuppressWarnings"); } /** * Retrieve the priority of this validation service. * * @return priority of this service */ public int getPriority() { return this.priority; } /** * Sets the priority of this Schematron Validation Service, which indicates its order of * execution amongst all preingest services. * * @param priority * priority of service, ranging from 1 to 100 (1 being highest priority) */ public void setPriority(int priority) { String methodName = "setPriority"; LOGGER.debug("ENTERING: {}.{}", CLASS_NAME, methodName); LOGGER.debug("Setting priority = {}", priority); this.priority = priority; // Bound priority to be between 1 and 100 (inclusive) if (this.priority > 100) { this.priority = 100; } else if (this.priority < 1) { this.priority = 1; } LOGGER.debug("EXITING: {}.{}", CLASS_NAME, methodName); } @Override public void validate(Metacard metacard) throws ValidationException { LOGGER.debug("Using .sch ruleset: {}", this.schematronSchemaFilename); // Convert the metacard's metadata to a String String metadata = metacard.getMetadata(); LOGGER.debug("metadata: {}", metadata); if (metadata != null) { // Create a Reader for the catalog entry's contents StringReader metadataReader = new StringReader(metadata); try { // Using the precompiled/stored Schematron validator, validate // the catalog entry's // contents Transformer transformer = validator.newTransformer(); DOMResult schematronResult = new DOMResult(); transformer.transform(new StreamSource(metadataReader), schematronResult); this.report = new SvrlReport(schematronResult); LOGGER.trace("SVRL Report:\n\n{}", report.getReportAsText()); // If the Schematron validation failed, then throw an exception // with details of the // errors // and warnings from the Schematron report included in the // exception that is thrown to // the client. if (!this.report.isValid(this.suppressWarnings)) { List<String> warnings = new ArrayList<String>(); StringBuffer errorMessage = new StringBuffer( "Schematron validation failed.\n\n"); List<String> errors = this.report.getErrors(); List<String> trimmedErrors = new ArrayList<>(); LOGGER.debug("errors.size() = {}", errors.size()); for (String error : errors) { errorMessage.append(error); errorMessage.append("\n"); trimmedErrors.add(error.trim()); } // If warnings are to be included from the Schematron report // as part of the errors // message List<String> trimmedWarnings = new ArrayList<>(); if (!this.suppressWarnings) { warnings = this.report.getWarnings(); LOGGER.debug("warnings.size() = {}", warnings.size()); for (String warning : warnings) { LOGGER.debug("warning = {}", warning); errorMessage.append(warning); errorMessage.append("\n"); trimmedWarnings.add(warning.trim()); } } LOGGER.debug(errorMessage.toString()); throw new SchematronValidationException("Schematron validation failed.", trimmedErrors, trimmedWarnings); } } catch (TransformerException te) { LOGGER.warn("Could not setup validator to perform validation", te); throw new SchematronValidationException( "Could not setup validator to perform validation."); } } else { throw new SchematronValidationException( "The Metacard.METADATA attribute must not be null to run shematron validation against the Metacard"); } } /** * The Listener class which catches Saxon configuration errors. * * DDF-855: These warnings and errors are logged so that they are * not displayed on the console. */ private class SaxonErrorListener implements ErrorListener { private String schematronSchemaFilename; public SaxonErrorListener(String schematronSchemaFilename) { this.schematronSchemaFilename = schematronSchemaFilename; } @Override public void warning(TransformerException e) throws TransformerException { LOGGER.warn("Transformer warning: '{}' on file: {}", e.getMessage(), this.schematronSchemaFilename); LOGGER.debug("Saxon exception", e); } @Override public void error(TransformerException e) throws TransformerException { LOGGER.warn("Transformer warning: '{}' on file: {}", e.getMessage(), this.schematronSchemaFilename); LOGGER.debug("Saxon exception", e); } @Override public void fatalError(TransformerException e) throws TransformerException { LOGGER.error("Transformer error: (Schematron file = {}):", this.schematronSchemaFilename, e); } } /** * The Listener class which catches xsl:messages during the transformation/stages of the * Schematron schema. */ private class Listener implements ErrorListener { public void warning(TransformerException e) throws TransformerException { warnings.add(e.getMessage()); } public void error(TransformerException e) throws TransformerException { throw e; } public void fatalError(TransformerException e) throws TransformerException { throw e; } } }