XMLUtil.java

/*
 * This file is part of Indicators.
 *
 * Indicators 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.
 *
 * Indicators 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 Indicators. If not, see <https://www.gnu.org/licenses/>.
 */
package fr.inrae.agroclim.indicators.xml;

import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.io.PrintWriter;
import java.io.Reader;
import java.io.StringWriter;
import java.io.Writer;
import java.nio.charset.StandardCharsets;
import java.util.Map;

import javax.xml.parsers.ParserConfigurationException;
import javax.xml.transform.Source;

import org.w3c.dom.ls.LSInput;
import org.w3c.dom.ls.LSResourceResolver;
import org.xml.sax.SAXException;

import fr.inrae.agroclim.indicators.exception.IndicatorsException;
import fr.inrae.agroclim.indicators.exception.type.XmlErrorType;
import fr.inrae.agroclim.indicators.model.EvaluationSettings;
import fr.inrae.agroclim.indicators.model.Knowledge;
import jakarta.xml.bind.JAXBException;
import jakarta.xml.bind.Marshaller;
import jakarta.xml.bind.Unmarshaller;
import lombok.Getter;
import lombok.Setter;
import lombok.extern.log4j.Log4j2;

/**
 * Helper to serialize/deserialize EvaluationSettings.
 *
 * @author jcufi
 */
@Log4j2
public abstract class XMLUtil {

    /**
     * Public ID of doctype for an evaluation.
     */
    private static final String DTD_PUBLIC_ID_EVALUATION = "-//INRAE AgroClim.//DTD Evaluation 1.1//EN";

    /**
     * Public ID of doctype for knowledge.
     */
    private static final String DTD_PUBLIC_ID_KNOWLEDGE = "-//INRAE AgroClim.//DTD Knowledge 1.1//EN";

    /**
     * DOCTYPE header for an evaluation.
     */
    private static final String DOCTYPE_EVALUATION = "<!DOCTYPE evaluationSettings PUBLIC\n"
            + "    \"" + DTD_PUBLIC_ID_EVALUATION + "\"\n"
            + "    \"https://agroclim.inrae.fr/getari/dtd/1.1/evaluation.dtd\">";

    /**
     * DOCTYPE header for Knowledge.
     */
    private static final String DOCTYPE_KNOWLEDGE = "<!DOCTYPE knowledge PUBLIC\n"
            + "    \"" + DTD_PUBLIC_ID_KNOWLEDGE + "\"\n"
            + "    \"https://agroclim.inrae.fr/getari/dtd/1.1/knowledge.dtd\">";

    /**
     * DTD resolver for jakarta.xml.validation.Validator.
     */
    public static final class CustomLSResourceResolver
    implements LSResourceResolver {

        @Override
        public LSInput resolveResource(final String type,
                final String namespaceURI, final String publicId,
                final String systemId, final String baseURI) {
            InputStream input = null;
            final Map<String, InputStream> dtds = getDtds();
            if (dtds.containsKey(publicId)) {
                input = dtds.get(publicId);
            }
            return new CustomLSInput(systemId, publicId, baseURI, input);
        }
    }

    /**
     * DTD resource for resolver for CustomLSResourceResolver.
     */
    private static class CustomLSInput implements LSInput {
        /**
         * The system identifier, a URI reference [IETF RFC 2396], for this
         * input source.
         */
        @Getter
        @Setter
        private String systemId;
        /**
         * The public identifier for this input source.
         */
        @Getter
        @Setter
        private String publicId;
        /**
         * The base URI to be used (see section 5.1.4 in [IETF RFC 2396]) for
         * resolving a relative systemId to an absolute URI.
         */
        @Getter
        @Setter
        private String baseURI;
        /**
         * DTD stream.
         */
        private final InputStream input;

        /**
         * Constructor.
         *
         * @param inputSystemId
         *            The system identifier, a URI reference [IETF RFC 2396],
         *            for this input source.
         * @param inputPublicId
         *            The public identifier for this input source.
         * @param inputBaseURI
         *            The base URI to be used (see section 5.1.4 in [IETF RFC
         *            2396]) for resolving a relative systemId to an absolute
         *            URI.
         * @param inputStream
         *            DTD stream.
         */
        CustomLSInput(final String inputSystemId, final String inputPublicId,
                final String inputBaseURI, final InputStream inputStream) {
            this.systemId = inputSystemId;
            this.publicId = inputPublicId;
            this.baseURI = inputBaseURI;
            this.input = inputStream;
        }

        @Override
        public Reader getCharacterStream() {
            return null;
        }

        @Override
        public void setCharacterStream(final Reader characterStream) {
            // do nothing
        }

        @Override
        public InputStream getByteStream() {
            return null;
        }

        @Override
        public void setByteStream(final InputStream byteStream) {
            // do nothing
        }

        @Override
        public String getStringData() {
            if (this.input == null) {
                return null;
            }
            try {
                final byte[] data = new byte[this.input.available()];
                if (this.input.read(data) > 0) {
                    return new String(data, StandardCharsets.UTF_8);
                }
                LOGGER.error("Failed to read data");
                return null;
            } catch (final IOException e) {
                LOGGER.catching(e);
                return null;
            }
        }

        @Override
        public void setStringData(final String stringData) {
            // Do nothing
        }

        @Override
        public String getEncoding() {
            return null;
        }

        @Override
        public void setEncoding(final String encoding) {
            // Do nothing
        }

        @Override
        public boolean getCertifiedText() {
            return false;
        }

        @Override
        public void setCertifiedText(final boolean certifiedText) {
            // Do nothing
        }
    }

    /**
     * @return  Public ID of doctype → Resource for the doctype file.
     */
    public static Map<String, InputStream> getDtds() {
        return Map.of(//
                DTD_PUBLIC_ID_EVALUATION, getResourceAsStream("evaluation.dtd"), //
                DTD_PUBLIC_ID_KNOWLEDGE, getResourceAsStream("knowledge.dtd"));
    }

    /**
     * Finds a resource with a given name.
     *
     * @param filename relative name
     * @return found resource
     */
    private static InputStream getResourceAsStream(final String filename) {
        return XMLUtil.class.getResourceAsStream("/fr/inrae/agroclim/indicators/" + filename);
    }

    /**
     * Load from file.
     *
     * @param xmlFile
     *            XML file
     * @param clazz
     *            used classes
     * @return object for the XML file
     * @throws IndicatorsException
     *             exception from JAXBException
     */
    public static Object load(final File xmlFile, final Class<?>... clazz) throws IndicatorsException {
        try (InputStream inputStream = new FileInputStream(xmlFile);) {
            return loadResource(inputStream, clazz);
        } catch (final FileNotFoundException ex) {
            throw new IndicatorsException(XmlErrorType.FILE_NOT_FOUND, ex, xmlFile.getAbsolutePath());
        } catch (final IOException ex) {
            throw new IndicatorsException(XmlErrorType.UNABLE_TO_LOAD, ex);
        }
    }

    /**
     * Load from InputStream.
     *
     * @param inputStream
     *            XML file
     * @param clazz
     *            used classes
     * @return object for the XML file
     * @throws IndicatorsException
     *             exception from JAXBException
     */
    public static Object loadResource(final InputStream inputStream, final Class<?>... clazz)
            throws IndicatorsException {
        if (inputStream == null) {
            throw new IndicatorsException(XmlErrorType.FILE_NOT_FOUND, "InputStream should not be null!");
        }
        try {
            UnmarshallerBuilder builder = new UnmarshallerBuilder();
            builder.setDtds(getDtds());
            builder.setClassesToBeBound(clazz);
            final Unmarshaller um = builder.build();
            final Source source = builder.buildSource(inputStream);
            return um.unmarshal(source);
        } catch (JAXBException | ParserConfigurationException | SAXException ex) {
            // details of exception are not shown using LOGGER.catching(ex)
            final Writer buffer = new StringWriter();
            final PrintWriter pw = new PrintWriter(buffer);
            ex.printStackTrace(pw);
            LOGGER.fatal("Unable to deserialize : {}", buffer);
            throw new IndicatorsException(XmlErrorType.UNABLE_TO_LOAD, ex);
        }
    }

    /**
     * Serialize to OutputStream.
     *
     *
     * @param o
     *            object to serialize
     * @param outputStream
     *            output stream
     * @param clazz
     *            used classes
     * @throws IndicatorsException
     *             exception from JAXBException
     */
    public static void serialize(final Object o, final OutputStream outputStream, final Class<?>... clazz)
                    throws IndicatorsException {
        try (OutputStreamWriter writer = new OutputStreamWriter(outputStream, StandardCharsets.UTF_8);) {
            serialize(o, writer, clazz);
        } catch (final IOException ex) {
            throw new IndicatorsException(XmlErrorType.UNABLE_TO_SERIALIZE, ex,
                    "Unable to create OutputStreamWriter : " + o);
        }
    }

    /**
     * Serialize to file.
     *
     * @param o
     *            object to serialize
     * @param fileName
     *            file path
     * @param clazz
     *            used classes
     * @throws IndicatorsException
     *             exception from JAXBException
     */
    public static void serialize(final Object o, final String fileName, final Class<?>... clazz)
            throws IndicatorsException {
        try (FileOutputStream stream = new FileOutputStream(fileName);) {
            serialize(o, new OutputStreamWriter(stream, StandardCharsets.UTF_8), clazz);
        } catch (final IOException ex) {
            LOGGER.catching(ex);
            throw new IndicatorsException(XmlErrorType.UNABLE_TO_SERIALIZE, ex,
                    "Unable to create FileOutputStream : " + o);
        }
    }

    /**
     * Serialize to writer.
     *
     * @param o
     *            object to serialize
     * @param writer
     *            writer
     * @param clazz
     *            used classes
     * @throws IndicatorsException
     *             exception from JAXBException
     */
    private static void serialize(final Object o, final Writer writer, final Class<?>... clazz)
            throws IndicatorsException {
        try {
            final MarshallerBuilder builder = new MarshallerBuilder();
            if (o instanceof EvaluationSettings) {
                builder.setDocType(DOCTYPE_EVALUATION);
            } else if (o instanceof Knowledge) {
                builder.setDocType(DOCTYPE_KNOWLEDGE);
            }
            builder.setClassesToBeBound(clazz);
            final Marshaller marshaller = builder.build();
            marshaller.marshal(o, writer);
        } catch (final JAXBException ex) {
            // details of exception are not shown using LOGGER.catching(ex)
            final Writer buffer = new StringWriter();
            final PrintWriter pw = new PrintWriter(buffer);
            ex.printStackTrace(pw);
            LOGGER.fatal("Unable to serialize : " + buffer.toString(), ex);
            throw new IndicatorsException(XmlErrorType.UNABLE_TO_SERIALIZE, ex);
        }
    }

    /**
     * No constructor for utility class.
     */
    private XMLUtil() {
    }
}