XMLValidationTaskDao.java

package cf.maybelambda.httpvalidator.springboot.persistence;

import cf.maybelambda.httpvalidator.springboot.model.ValidationTask;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.env.Environment;
import org.springframework.core.io.ClassPathResource;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;
import org.w3c.dom.Document;
import org.w3c.dom.NamedNodeMap;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
import org.xml.sax.SAXException;

import javax.management.modelmbean.XMLParseException;
import javax.swing.text.html.FormSubmitEvent.MethodType;
import javax.xml.XMLConstants;
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.ParserConfigurationException;
import javax.xml.validation.Schema;
import javax.xml.validation.SchemaFactory;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;

import static cf.maybelambda.httpvalidator.springboot.persistence.XMLErrorHandler.parseInputOrThrow;
import static java.util.Objects.requireNonNull;

/**
 * Provides methods to interact with the XML data file containing validation tasks.
 * <p>
 * This class is responsible for reading and updating the XML data file,
 * parsing its content, and validating its structure against a predefined schema.
 */
@Component
public class XMLValidationTaskDao {
    static final String URL_TAG = "url";
    static final String RES_TAG = "response";
    static final String REQ_BODY_TAG = "reqbody";
    static final String HEADER_TAG = "header";
    static final String VALIDATION_TAG = "validation";
    static final String REQ_METHOD_ATTR = "method";
    static final String RES_SC_ATTR = "statuscode";
    static final String DATAFILE_PROPERTY = "datafile";
    private static final String SCHEMA_FILENAME = "validations.xsd";
    private DocumentBuilder xmlParser;
    private static Logger logger = LoggerFactory.getLogger(XMLValidationTaskDao.class);
    private List<ValidationTask> tasks;
    private long lastModifiedTime;

    @Autowired
    private Environment env;
    @Autowired
    private ObjectMapper mapper;

    /**
     * Constructs an instance of XMLValidationTaskDao.
     * <p>
     * Initializes the XML parser with schema validation and security features.
     *
     * @throws ParserConfigurationException if a DocumentBuilder cannot be created.
     * @throws SAXException if an error occurs during schema parsing.
     * @throws IOException if an error occurs during schema file loading.
     */
    public XMLValidationTaskDao() throws ParserConfigurationException, SAXException, IOException {
        SchemaFactory schemaFactory = SchemaFactory.newInstance(XMLConstants.W3C_XML_SCHEMA_NS_URI);
        Schema schema = schemaFactory.newSchema((new ClassPathResource(SCHEMA_FILENAME)).getURL());
        DocumentBuilderFactory dbFactory = DocumentBuilderFactory.newInstance();
        dbFactory.setSchema(schema);
        dbFactory.setIgnoringElementContentWhitespace(true);
        dbFactory.setNamespaceAware(true);
        dbFactory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
        dbFactory.setFeature("http://xml.org/sax/features/external-general-entities", false);
        dbFactory.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
        this.xmlParser = dbFactory.newDocumentBuilder();

        XMLErrorHandler xsdErrorHandler = new XMLErrorHandler();
        this.xmlParser.setErrorHandler(xsdErrorHandler);
    }

    /**
     * Retrieves the file path of the XML data file from the environment properties.
     *
     * @return The path of the XML data file.
     */
    Path getDataFilePath() { return Path.of(requireNonNull(this.env.getProperty(DATAFILE_PROPERTY))); }

    /**
     * Parses the given XML input stream into a Document.
     *
     * @param inputStream The input stream of the XML content.
     * @return The parsed Document.
     * @throws XMLParseException if parsing fails.
     */
    Document parseXMLInput(InputStream inputStream) throws XMLParseException {
        return parseInputOrThrow(this.xmlParser::parse, inputStream, logger, "Failed to parse target XML content");
    }

    /**
     * Updates the XML data file with the content of the given multipart file.
     *
     * @param file The multipart file containing the new XML content.
     * @throws IOException if an I/O error occurs.
     * @throws NullPointerException if the file is null.
     * @throws XMLParseException if the XML content is invalid.
     */
    public synchronized void updateDataFile(MultipartFile file) throws IOException, NullPointerException, XMLParseException {
        try {
            this.parseXMLInput(file.getInputStream());
            Files.write(this.getDataFilePath(), file.getBytes());
        } catch (NullPointerException | XMLParseException e) {
            logger.warn("Invalid EXTERNAL XML received from API");
            throw e;
        } catch (IOException e) {
            logger.error("Failed writing new datafile to disk", e);
            throw e;
        }
    }

    /**
     * Retrieves the XML Document from the data file.
     *
     * @return The parsed Document.
     * @throws XMLParseException if parsing fails.
     * @throws FileNotFoundException if the data file is not found.
     */
    synchronized Document getDocData() throws XMLParseException, FileNotFoundException {
        return this.parseXMLInput(new FileInputStream(this.getDataFilePath().toFile()));
    }

    /**
     * Builds a Validation Task from the data in the received elements, the child nodes of a validation element.
     *
     * @return The new validation task.
     * @throws XMLParseException if JSON content in the reqbody element cannot be parsed.
     */
    ValidationTask createVTaskFromNodes(NodeList validation) throws XMLParseException {
        MethodType method = null;
        String url = null;
        List<String> headers = new ArrayList<>();
        JsonNode reqBody = this.mapper.nullNode();
        Integer resStatusCode = null;
        String resBody = null;

        for (int j = 0; j < validation.getLength(); j++) {
            Node childNode = validation.item(j);
            NamedNodeMap attrs = childNode.getAttributes();
            String name = childNode.getNodeName();
            String content = childNode.getTextContent().trim();

            if (URL_TAG.equals(name)) {
                int val = Integer.parseInt(attrs.getNamedItem(REQ_METHOD_ATTR).getTextContent());
                method = MethodType.values()[val];
                url = content;
            }
            if (HEADER_TAG.equals(name)) {
                headers.add(content);
            }
            if (REQ_BODY_TAG.equals(name)) {
                reqBody = parseInputOrThrow(this.mapper::readTree, content, logger, "Invalid JSON encountered in data file");
            }
            if (RES_TAG.equals(name)) {
                resStatusCode = Integer.parseInt(attrs.getNamedItem(RES_SC_ATTR).getTextContent());
                resBody = content;
            }
        }

        return new ValidationTask(method, url, headers, reqBody, resStatusCode, resBody);
    }

    /**
     * Retrieves all validation tasks; from the XML data file if it was modified since the last time it was read,
     * or from memory otherwise.
     *
     * @return A list of validation tasks.
     * @throws XMLParseException if parsing fails.
     * @throws FileNotFoundException if the data file is not found.
     */
    public List<ValidationTask> getAll() throws XMLParseException, FileNotFoundException {
        long lastModifiedTime = (new File(this.getDataFilePath().toUri())).lastModified();
        if (lastModifiedTime > this.lastModifiedTime) {
            List<ValidationTask> tasks = new ArrayList<>();
            NodeList validations = this.getDocData().getElementsByTagName(VALIDATION_TAG);
            for (int i = 0; i < validations.getLength(); i++) {
                tasks.add(this.createVTaskFromNodes(validations.item(i).getChildNodes()));
            }
            this.tasks = tasks;
            this.lastModifiedTime = lastModifiedTime;
        }

        return this.tasks;
    }

    /**
     * Checks if the XML data file exists and is readable.
     *
     * @return True if the data file exists and is readable, false otherwise.
     */
    public boolean isDataFileStatusOk() {
        Path path = this.getDataFilePath();
        return Files.isRegularFile(path) && Files.isReadable(path);
    }

    /**
     * Sets the XML parser for the XMLValidationTaskDao. Used for testing purposes.
     *
     * @param xmlParser The XML parser to set.
     */
    void setXmlParser(DocumentBuilder xmlParser) { this.xmlParser = xmlParser; }

    /**
     * Sets the logger; for testing purposes.
     *
     * @param logger The logger to set.
     */
    void setLogger(Logger logger) { XMLValidationTaskDao.logger = logger; }

    /**
     * Sets the environment for the XMLValidationTaskDao.
     * <p>
     * This method is used for testing purposes to inject a mock environment.
     *
     * @param env The environment to set.
     */
    void setEnv(Environment env) { this.env = env; }

    /**
     * Sets the object mapper; for testing purposes.
     *
     * @param mapper The ObjectMapper to set.
     */
    void setObjectMapper(ObjectMapper mapper) { this.mapper = mapper; }

    /**
     * Sets the lastModifiedTime of the data file; for testing purposes.
     *
     * @param time The new time to set.
     */
    void setLastModifiedTime(long time) { this.lastModifiedTime = time; }
}