From ef7001210f216dcc22099e20da253d0548ae5960 Mon Sep 17 00:00:00 2001 From: Matthias Eichner Date: Thu, 22 Oct 2026 14:34:24 +0200 Subject: [PATCH] MCR-3885 handle xsl:message when using Saxon - MCRErrorListener.setTo(Transformer): sets the error listener and, for Saxon, a message handler that logs xsl:message via Log4j instead of System.err - MCRErrorListener: use the text of a terminating xsl:message (Saxon TerminationException) in getMyMessageAndLocation() and fatalError() - MCRXSLTransformer: include the text of a terminating xsl:message in the thrown exception - use MCRErrorListener.setTo() in MCRXSLTransformer, MCRObjectCommands (xslt command) and MCRCommandUtils (CLI export transformers) - mycore-base: change scope of Saxon-HE from runtime to compile - add tests --- mycore-base/pom.xml | 1 + .../transformer/MCRXSLTransformer.java | 22 ++- .../mycore/common/xsl/MCRErrorListener.java | 104 +++++++++-- .../mycore/frontend/cli/MCRCommandUtils.java | 4 + .../frontend/cli/MCRObjectCommands.java | 1 + .../common/xsl/MCRErrorListenerTest.java | 174 ++++++++++++++++++ .../resources/xslt-message/xsl-message.xsl | 14 ++ 7 files changed, 299 insertions(+), 21 deletions(-) create mode 100644 mycore-base/src/test/java/org/mycore/common/xsl/MCRErrorListenerTest.java create mode 100644 mycore-base/src/test/resources/xslt-message/xsl-message.xsl diff --git a/mycore-base/pom.xml b/mycore-base/pom.xml index 3c7aa6f401..4652dedf07 100644 --- a/mycore-base/pom.xml +++ b/mycore-base/pom.xml @@ -279,6 +279,7 @@ net.sf.saxon Saxon-HE + compile org.apache.commons diff --git a/mycore-base/src/main/java/org/mycore/common/content/transformer/MCRXSLTransformer.java b/mycore-base/src/main/java/org/mycore/common/content/transformer/MCRXSLTransformer.java index 1b184763be..95716d8ec0 100644 --- a/mycore-base/src/main/java/org/mycore/common/content/transformer/MCRXSLTransformer.java +++ b/mycore-base/src/main/java/org/mycore/common/content/transformer/MCRXSLTransformer.java @@ -65,6 +65,8 @@ import org.xml.sax.SAXException; import org.xml.sax.XMLReader; +import net.sf.saxon.expr.instruct.TerminationException; + /** * Transforms XML content using a static XSL stylesheet. The stylesheet is configured via * MCR.ContentTransformer.{ID}.Stylesheet. You may choose your own instance of @@ -273,7 +275,7 @@ public void transform(MCRContent source, OutputStream out, MCRParameterCollector reader.parse(source.getInputSource()); } catch (TransformerConfigurationException | SAXException | IllegalArgumentException | ParserConfigurationException e) { - throw new IOException(e); + throw new IOException(getErrorMessage(e), e); } catch (RuntimeException e) { if (el != null && e.getCause() == null && el.getExceptionThrown() != null) { //typically if a RuntimeException has no cause, @@ -286,6 +288,18 @@ public void transform(MCRContent source, OutputStream out, MCRParameterCollector } } + /** + * Returns the message for an exception wrapping the given exception. Saxon does not include the text of a + * terminating xsl:message in its exception message, so it is added here. + */ + private static String getErrorMessage(Exception e) { + if (e.getCause() instanceof TerminationException terminationException) { + return "Error while XSL transformation: " + MCRErrorListener.getMyMessageAndLocation(terminationException); + } + // same message as for new IOException(e) or new MCRException(e) + return e.toString(); + } + protected MCRContent transform(MCRContent source, XMLReader reader, TransformerHandler transformerHandler, MCRParameterCollector parameter) throws IOException, SAXException, TransformerException, ParserConfigurationException { @@ -332,11 +346,11 @@ protected Deque getTransformHandlers(MCRParameterCollector p throws TransformerConfigurationException, SAXException, ParserConfigurationException { checkTemplateUptodate(); Deque xslSteps = new ArrayDeque<>(); - //every transformhandler shares the same ErrorListener instance + //every transformhandler gets its own ErrorListener instance for (Templates template : templates) { TransformerHandler handler = tFactory.newTransformerHandler(template); parameterCollector.setParametersTo(handler.getTransformer()); - handler.getTransformer().setErrorListener(new MCRErrorListener()); + MCRErrorListener.setTo(handler.getTransformer()); if (!xslSteps.isEmpty()) { Result result = new SAXResult(handler); xslSteps.getLast().setResult(result); @@ -436,7 +450,7 @@ public MCRContent getBaseContent() { transformed.setMimeType(mimeType); transformed.setEncoding(encoding); } catch (IOException | SAXException e) { - throw new MCRException(e); + throw new MCRException(getErrorMessage(e), e); } catch (RuntimeException e) { MCRErrorListener el = (MCRErrorListener) transformerHandler.getTransformer().getErrorListener(); if (el != null && e.getCause() == null && el.getExceptionThrown() != null) { diff --git a/mycore-base/src/main/java/org/mycore/common/xsl/MCRErrorListener.java b/mycore-base/src/main/java/org/mycore/common/xsl/MCRErrorListener.java index 2cfe3520e3..60c5fae2e7 100644 --- a/mycore-base/src/main/java/org/mycore/common/xsl/MCRErrorListener.java +++ b/mycore-base/src/main/java/org/mycore/common/xsl/MCRErrorListener.java @@ -20,12 +20,22 @@ import javax.xml.transform.ErrorListener; import javax.xml.transform.SourceLocator; +import javax.xml.transform.Transformer; import javax.xml.transform.TransformerException; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.apache.xml.utils.WrappedRuntimeException; +import net.sf.saxon.expr.instruct.TerminationException; +import net.sf.saxon.jaxp.StreamingTransformerImpl; +import net.sf.saxon.jaxp.TransformerImpl; +import net.sf.saxon.om.Item; +import net.sf.saxon.s9api.AbstractXsltTransformer; +import net.sf.saxon.s9api.Location; +import net.sf.saxon.s9api.Message; +import net.sf.saxon.trans.XPathException; + /** * @author Thomas Scheffler (yagee) * @@ -37,10 +47,42 @@ public final class MCRErrorListener implements ErrorListener { private String lastMessage; + /** + * Creates a new {@link MCRErrorListener} and sets it to the given transformer. + *

+ * For Saxon transformers, xsl:message output is logged, too. + * + * @param transformer the transformer to configure + * @return the error listener set to the transformer + */ + public static MCRErrorListener setTo(Transformer transformer) { + MCRErrorListener errorListener = new MCRErrorListener(); + transformer.setErrorListener(errorListener); + AbstractXsltTransformer xsltTransformer = null; + if (transformer instanceof TransformerImpl saxonTransformer) { + xsltTransformer = saxonTransformer.getUnderlyingXsltTransformer(); + } else if (transformer instanceof StreamingTransformerImpl saxonTransformer) { + xsltTransformer = saxonTransformer.getUnderlyingXsltTransformer(); + } + if (xsltTransformer != null) { + // Saxon does not report xsl:message to the ErrorListener but writes it to System.err + xsltTransformer.setMessageHandler(MCRErrorListener::logMessage); + } + return errorListener; + } + public TransformerException getExceptionThrown() { return exceptionThrown; } + private static void logMessage(Message message) { + Location location = message.getLocation(); + LOGGER.info(() -> location == null + ? getMessageAndLocation(null, 0, 0, message.getStringValue()) + : getMessageAndLocation(location.getSystemId(), location.getLineNumber(), location.getColumnNumber(), + message.getStringValue())); + } + private boolean triggerException(TransformerException e) { if (exceptionThrown != null) { return false; @@ -90,6 +132,10 @@ public void fatalError(TransformerException exception) throws TransformerExcepti && stackTrace[0].getClassName().endsWith("ElemMessage")) { LOGGER.debug("Original exception: ", unwrappedException); unwrappedException = new TransformerException(lastMessage); + } else if (unwrappedException instanceof TerminationException) { + //Saxon: xsl:message with terminate="yes", the exception message does not contain the message text + LOGGER.debug("Original exception: ", unwrappedException); + unwrappedException = new TransformerException(getMyMessageAndLocation(unwrappedException)); } if (triggerException(unwrappedException)) { LOGGER.fatal("Exception while XSL transformation.", unwrappedException); @@ -114,27 +160,51 @@ public static TransformerException unwrapException(TransformerException exceptio public static String getMyMessageAndLocation(TransformerException exception) { SourceLocator locator = exception.getLocator(); - StringBuilder msg = new StringBuilder(); - if (locator != null) { - String systemID = locator.getSystemId(); - int line = locator.getLineNumber(); - int col = locator.getColumnNumber(); - if (systemID != null) { - msg.append("SystemID: "); - msg.append(systemID); - } - if (line != 0) { - msg.append(" ["); - msg.append(line); - if (col != 0) { - msg.append(','); - msg.append(col); + String message = getMessage(exception); + if (locator == null) { + return getMessageAndLocation(null, 0, 0, message); + } + return getMessageAndLocation(locator.getSystemId(), locator.getLineNumber(), locator.getColumnNumber(), + message); + } + + /** + * Returns the message of the given exception. For a Saxon exception caused by xsl:message with + * terminate="yes" this is the text of the message, if it is not empty. + */ + private static String getMessage(TransformerException exception) { + if (exception instanceof TerminationException terminationException + && terminationException.getErrorObject() != null) { + try { + Item content = terminationException.getErrorObject().head(); + String text = content == null ? null : content.getStringValue(); + if (text != null && !text.isBlank()) { + return text; } - msg.append(']'); + } catch (XPathException e) { + LOGGER.debug("Could not get text of terminating xsl:message.", e); + } + } + return exception.getMessage(); + } + + private static String getMessageAndLocation(String systemID, int line, int col, String message) { + StringBuilder msg = new StringBuilder(); + if (systemID != null) { + msg.append("SystemID: "); + msg.append(systemID); + } + if (line > 0) { + msg.append(" ["); + msg.append(line); + if (col > 0) { + msg.append(','); + msg.append(col); } + msg.append(']'); } msg.append(": "); - msg.append(exception.getMessage()); + msg.append(message); return msg.toString(); } } diff --git a/mycore-base/src/main/java/org/mycore/frontend/cli/MCRCommandUtils.java b/mycore-base/src/main/java/org/mycore/frontend/cli/MCRCommandUtils.java index 12028ae361..7f7fa60ee0 100644 --- a/mycore-base/src/main/java/org/mycore/frontend/cli/MCRCommandUtils.java +++ b/mycore-base/src/main/java/org/mycore/frontend/cli/MCRCommandUtils.java @@ -40,6 +40,7 @@ import org.mycore.common.MCRConstants; import org.mycore.common.MCRUsageException; import org.mycore.common.content.transformer.MCRXSLTransformer; +import org.mycore.common.xsl.MCRErrorListener; import org.mycore.common.xsl.MCRXSLResourceHelper; import org.mycore.common.xsl.uriresolver.MCRURIResolver; import org.mycore.datamodel.common.MCRXMLMetadataManager; @@ -188,6 +189,8 @@ public static Transformer getTransformer(String style, String defaultStyle, Map< } Transformer transformer = cache.get(style); if (transformer != null) { + // fresh error listener, the old one keeps the exception of a previous transformation + MCRErrorListener.setTo(transformer); return transformer; } @@ -202,6 +205,7 @@ public static Transformer getTransformer(String style, String defaultStyle, Map< TransformerFactory transformerFactory = MCRXSLTransformer.createDefaultTransformerFactory(); transformerFactory.setURIResolver(MCRURIResolver.obtainInstance()); transformer = transformerFactory.newTransformer(new JDOMSource(element)); + MCRErrorListener.setTo(transformer); cache.put(style, transformer); LOGGER.info("Loaded transformer from resource {} for style {}.", xslFilePath, style); return transformer; diff --git a/mycore-base/src/main/java/org/mycore/frontend/cli/MCRObjectCommands.java b/mycore-base/src/main/java/org/mycore/frontend/cli/MCRObjectCommands.java index b89fdec8b3..ab7aa06bdb 100644 --- a/mycore-base/src/main/java/org/mycore/frontend/cli/MCRObjectCommands.java +++ b/mycore-base/src/main/java/org/mycore/frontend/cli/MCRObjectCommands.java @@ -977,6 +977,7 @@ private static void xslt(String objectId, String xslFilePath, boolean force) thr xmlReader.setEntityResolver(MCREntityResolver.getInstance()); SAXSource styleSource = new SAXSource(xmlReader, style.getInputSource()); Transformer transformer = transformerFactory.newTransformer(styleSource); + MCRErrorListener.setTo(transformer); for (Entry property : MCRConfigurationBase.getAllPropertiesMap().entrySet()) { transformer.setParameter(property.getKey(), property.getValue()); } diff --git a/mycore-base/src/test/java/org/mycore/common/xsl/MCRErrorListenerTest.java b/mycore-base/src/test/java/org/mycore/common/xsl/MCRErrorListenerTest.java new file mode 100644 index 0000000000..625d88dfc7 --- /dev/null +++ b/mycore-base/src/test/java/org/mycore/common/xsl/MCRErrorListenerTest.java @@ -0,0 +1,174 @@ +/* + * This file is part of *** M y C o R e *** + * See https://www.mycore.de/ for details. + * + * MyCoRe is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * MyCoRe 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with MyCoRe. If not, see . + */ + +package org.mycore.common.xsl; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; + +import javax.xml.transform.Transformer; +import javax.xml.transform.TransformerException; +import javax.xml.transform.TransformerFactory; +import javax.xml.transform.stream.StreamSource; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.core.LogEvent; +import org.apache.logging.log4j.core.Logger; +import org.apache.logging.log4j.core.appender.AbstractAppender; +import org.apache.logging.log4j.core.config.Property; +import org.jdom2.Document; +import org.jdom2.Element; +import org.jdom2.transform.JDOMResult; +import org.jdom2.transform.JDOMSource; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mycore.common.MCRException; +import org.mycore.common.content.MCRJDOMContent; +import org.mycore.common.content.transformer.MCRXSLTransformer; +import org.mycore.test.MyCoReTest; + +@MyCoReTest +public class MCRErrorListenerTest { + + private static final String STYLESHEET = "xslt-message/xsl-message.xsl"; + + private static final String MESSAGE = "Hello from xsl:message"; + + private static final String TERMINATE_MESSAGE = "No host present!"; + + private MessageAppender appender; + + @BeforeEach + public void addAppender() { + appender = new MessageAppender(); + appender.start(); + getListenerLogger().addAppender(appender); + } + + @AfterEach + public void removeAppender() { + getListenerLogger().removeAppender(appender); + appender.stop(); + } + + @Test + public void saxonMessageIsLogged() throws IOException { + MCRXSLTransformer transformer = MCRXSLTransformer.obtainInstance(net.sf.saxon.TransformerFactoryImpl.class, + STYLESHEET); + transformer.transform(new MCRJDOMContent(new Element("in"))).asString(); + assertLogged(MESSAGE); + } + + @Test + public void saxonTerminateMessage() { + MCRXSLTransformer transformer = MCRXSLTransformer.obtainInstance(net.sf.saxon.TransformerFactoryImpl.class, + STYLESHEET); + + MCRException mcrException = assertThrows(MCRException.class, + () -> transformer.transform(new MCRJDOMContent(new Element("in")), terminate()).asString()); + assertContains(mcrException.getMessage(), TERMINATE_MESSAGE); + + IOException ioException = assertThrows(IOException.class, + () -> transformer.transform(new MCRJDOMContent(new Element("in")), new ByteArrayOutputStream(), + terminate())); + assertContains(ioException.getMessage(), TERMINATE_MESSAGE); + + assertLogged(TERMINATE_MESSAGE); + } + + @Test + public void saxonPlainTransformerWithSetTo() throws TransformerException { + TransformerFactory factory = new net.sf.saxon.TransformerFactoryImpl(); + Transformer transformer = factory.newTransformer( + new StreamSource(getClass().getClassLoader().getResource(STYLESHEET).toString())); + MCRErrorListener.setTo(transformer); + transformer.setParameter("terminate", "yes"); + + TransformerException exception = assertThrows(TransformerException.class, + () -> transformer.transform(new JDOMSource(new Document(new Element("in"))), new JDOMResult())); + assertContains(MCRErrorListener.getMyMessageAndLocation(exception), TERMINATE_MESSAGE); + assertLogged(MESSAGE); + assertLogged(TERMINATE_MESSAGE); + } + + @Test + public void saxonEmptyTerminateMessage() throws TransformerException { + TransformerFactory factory = new net.sf.saxon.TransformerFactoryImpl(); + Transformer transformer = factory.newTransformer( + new StreamSource(getClass().getClassLoader().getResource(STYLESHEET).toString())); + MCRErrorListener.setTo(transformer); + transformer.setParameter("terminate", "empty"); + + TransformerException exception = assertThrows(TransformerException.class, + () -> transformer.transform(new JDOMSource(new Document(new Element("in"))), new JDOMResult())); + // falls back to the Saxon message + assertContains(MCRErrorListener.getMyMessageAndLocation(exception), "xsl:message"); + } + + @Test + public void xalanTerminateMessage() { + MCRXSLTransformer transformer = MCRXSLTransformer + .obtainInstance(org.apache.xalan.processor.TransformerFactoryImpl.class, STYLESHEET); + IOException ioException = assertThrows(IOException.class, + () -> transformer.transform(new MCRJDOMContent(new Element("in")), new ByteArrayOutputStream(), + terminate())); + assertContains(ioException.getMessage(), TERMINATE_MESSAGE); + assertLogged(MESSAGE); + } + + private static MCRParameterCollector terminate() { + MCRParameterCollector parameters = new MCRParameterCollector(false); + parameters.setParameter("terminate", "yes"); + return parameters; + } + + private void assertLogged(String message) { + assertTrue(appender.messages.stream().anyMatch(m -> m.contains(message)), + "Message '" + message + "' was not logged: " + appender.messages); + } + + private static void assertContains(String actual, String expected) { + assertTrue(actual != null && actual.contains(expected), + "Expected '" + expected + "' in: " + actual); + } + + private static Logger getListenerLogger() { + return (Logger) LogManager.getLogger(MCRErrorListener.class); + } + + private static final class MessageAppender extends AbstractAppender { + + private final List messages = new CopyOnWriteArrayList<>(); + + MessageAppender() { + super("xsl-message", null, null, true, Property.EMPTY_ARRAY); + } + + @Override + public void append(LogEvent event) { + messages.add(event.getMessage().getFormattedMessage()); + } + } + +} diff --git a/mycore-base/src/test/resources/xslt-message/xsl-message.xsl b/mycore-base/src/test/resources/xslt-message/xsl-message.xsl new file mode 100644 index 0000000000..ad18e93ea6 --- /dev/null +++ b/mycore-base/src/test/resources/xslt-message/xsl-message.xsl @@ -0,0 +1,14 @@ + + + + + Hello from xsl:message + + No host present! + + + + + + +