XmlSchemaReader.java

/*
 * Copyright © 2026 IKE Network (support@ike.network)
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package network.ike.plugin.schema;

import network.ike.plugin.schema.SchemaSignature.Enumeration;
import network.ike.plugin.schema.SchemaSignature.Position;
import network.ike.plugin.schema.SchemaSignature.PositionKind;
import network.ike.plugin.schema.SchemaSignature.TypeDefinition;
import network.ike.plugin.schema.SchemaSignature.TypeReference;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;

import javax.xml.XMLConstants;
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.ParserConfigurationException;
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.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;

/**
 * Reads XML Schema files into a {@link SchemaSignature} with the JDK's own XML support and
 * nothing else (IKE-Network/ike-issues#1104). It reads what a signature is: named complex
 * types, their {@code extension base}, their documentation, their element and attribute
 * positions, and named simple types that enumerate values. Schema features a signature
 * does not need, choices, groups, and attribute groups, are refused rather than silently
 * dropped, so that a schema outside the reader's reach fails loudly.
 *
 * <p>The reader is namespace-aware and matches schema elements by the XML Schema
 * namespace, so the prefix a file uses, {@code xs} or {@code xsd}, does not matter. A
 * type is identified by its namespace and its name: a bare reference resolves to the
 * file's default namespace, or its target namespace when it declares none, and a prefixed
 * reference through the file's own namespace declarations, so {@code xs:string} is a
 * schema primitive and {@code a:CqlToElmBase} a type of another schema.
 */
public final class XmlSchemaReader {

    private static final String XSD = XMLConstants.W3C_XML_SCHEMA_NS_URI;

    private final DocumentBuilder builder;

    /**
     * Creates a reader with a secure, namespace-aware document builder.
     *
     * @throws IllegalStateException when the JDK cannot provide a document builder
     */
    public XmlSchemaReader() {
        DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
        factory.setNamespaceAware(true);
        try {
            factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
            factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
            this.builder = factory.newDocumentBuilder();
        } catch (ParserConfigurationException e) {
            throw new IllegalStateException("The JDK cannot provide a secure XML parser", e);
        }
    }

    /**
     * Reads one or more schema files into a single signature, in the order given. Types
     * are collected across all files, so a base declared in one file and extended in
     * another resolves.
     *
     * @param schemas the schema files
     * @return the signature the files declare
     * @throws IOException      when a file cannot be read or is not well-formed XML
     * @throws IllegalArgumentException when a file uses a schema feature the reader refuses
     */
    public SchemaSignature read(List<Path> schemas) throws IOException {
        List<TypeDefinition> types = new ArrayList<>();
        List<Enumeration> enumerations = new ArrayList<>();
        Map<TypeReference, Path> seen = new LinkedHashMap<>();
        for (Path schema : schemas) {
            try (InputStream in = Files.newInputStream(schema)) {
                Document document = builder.parse(in);
                Element root = document.getDocumentElement();
                refuseUnsupported(root, schema);
                Namespaces namespaces = new Namespaces(root);
                for (Element complexType : children(root, "complexType")) {
                    List<TypeDefinition> declared = new ArrayList<>();
                    declared.add(readType(complexType, namespaces, declared));
                    for (TypeDefinition type : declared) {
                        Path earlier = seen.putIfAbsent(type.reference(), schema);
                        if (earlier != null) {
                            throw new IllegalArgumentException("Type " + type.name() + " of namespace "
                                    + type.namespace() + " is declared in " + earlier + " and again in " + schema);
                        }
                        types.add(type);
                    }
                }
                for (Element simpleType : children(root, "simpleType")) {
                    readEnumeration(simpleType, namespaces).ifPresent(enumerations::add);
                }
            } catch (org.xml.sax.SAXException e) {
                throw new IOException("Schema " + schema + " is not well-formed XML: " + e.getMessage(), e);
            }
        }
        return new SchemaSignature(types, enumerations);
    }

    private static void refuseUnsupported(Element root, Path schema) {
        for (String feature : List.of("choice", "group", "attributeGroup")) {
            if (root.getElementsByTagNameNS(XSD, feature).getLength() > 0) {
                throw new IllegalArgumentException("Schema " + schema + " uses xs:" + feature
                        + ", which the signature reader does not read");
            }
        }
    }

    /**
     * Reads a named complex type. A type declared inline by one of its elements, without a name
     * of its own, is read too and added to {@code inner}, named after the element that declares
     * it: the schema's {@code Library} declares its {@code usings} inline, so that type is
     * {@code Library usings}, and the element's position holds it (IKE-Network/ike-issues#1112).
     */
    private static TypeDefinition readType(Element complexType, Namespaces namespaces, List<TypeDefinition> inner) {
        String name = complexType.getAttribute("name");
        boolean isAbstract = "true".equals(complexType.getAttribute("abstract"));
        String documentation = documentation(complexType);
        Optional<TypeReference> base = Optional.empty();
        Element content = complexType;
        Element complexContent = firstChild(complexType, "complexContent");
        if (complexContent != null) {
            Element extension = firstChild(complexContent, "extension");
            if (extension != null) {
                base = Optional.of(namespaces.resolve(extension.getAttribute("base")));
                content = extension;
            } else {
                Element restriction = firstChild(complexContent, "restriction");
                if (restriction != null) {
                    content = restriction;
                }
            }
        }
        List<Position> positions = readPositions(content, name, namespaces, inner);
        return new TypeDefinition(namespaces.target(), name, base, isAbstract, documentation, positions);
    }

    private static List<Position> readPositions(Element content, String ownerName, Namespaces namespaces,
                                                List<TypeDefinition> inner) {
        List<Position> positions = new ArrayList<>();
        Element sequence = firstChild(content, "sequence");
        if (sequence != null) {
            for (Element element : children(sequence, "element")) {
                positions.add(readElement(element, ownerName, namespaces, inner));
            }
        }
        for (Element attribute : children(content, "attribute")) {
            positions.add(readAttribute(attribute, namespaces));
        }
        return positions;
    }

    private static Position readElement(Element element, String ownerName, Namespaces namespaces,
                                        List<TypeDefinition> inner) {
        String name = element.getAttribute("name");
        TypeReference type;
        Element inline = firstChild(element, "complexType");
        if (element.hasAttribute("type")) {
            type = namespaces.resolve(element.getAttribute("type"));
        } else if (inline != null) {
            String innerName = ownerName + " " + name;
            List<Position> innerPositions = readPositions(inline, innerName, namespaces, inner);
            inner.add(new TypeDefinition(namespaces.target(), innerName, Optional.empty(), false,
                    documentation(element), innerPositions));
            type = new TypeReference(namespaces.target(), innerName);
        } else {
            type = TypeReference.primitive("anyType");
        }
        int minimum = element.hasAttribute("minOccurs") ? Integer.parseInt(element.getAttribute("minOccurs")) : 1;
        int maximum = 1;
        if (element.hasAttribute("maxOccurs")) {
            String max = element.getAttribute("maxOccurs");
            maximum = "unbounded".equals(max) ? Position.UNBOUNDED : Integer.parseInt(max);
        }
        return new Position(name, PositionKind.ELEMENT, type, minimum, maximum, documentation(element));
    }

    private static Position readAttribute(Element attribute, Namespaces namespaces) {
        String name = attribute.getAttribute("name");
        TypeReference type = attribute.hasAttribute("type")
                ? namespaces.resolve(attribute.getAttribute("type")) : TypeReference.primitive("string");
        int minimum = "required".equals(attribute.getAttribute("use")) ? 1 : 0;
        return new Position(name, PositionKind.ATTRIBUTE, type, minimum, 1, documentation(attribute));
    }

    private static Optional<Enumeration> readEnumeration(Element simpleType, Namespaces namespaces) {
        Element restriction = firstChild(simpleType, "restriction");
        if (restriction == null) {
            return Optional.empty();
        }
        List<String> values = new ArrayList<>();
        for (Element enumeration : children(restriction, "enumeration")) {
            values.add(enumeration.getAttribute("value"));
        }
        if (values.isEmpty()) {
            return Optional.empty();
        }
        return Optional.of(new Enumeration(namespaces.target(), simpleType.getAttribute("name"),
                documentation(simpleType), values));
    }

    /** One schema file's namespace declarations, for resolving the names it uses. */
    private record Namespaces(String target, String defaultNamespace, Element root) {
        Namespaces(Element root) {
            this(root.hasAttribute("targetNamespace") ? root.getAttribute("targetNamespace") : "",
                    root.lookupNamespaceURI(null), root);
        }

        /**
         * Resolves a possibly prefixed name the way XML Schema does: a prefix through the
         * file's declarations, no prefix through the default namespace, or the target
         * namespace when the file declares no default.
         */
        TypeReference resolve(String qualified) {
            int colon = qualified.indexOf(':');
            if (colon < 0) {
                return new TypeReference(defaultNamespace != null ? defaultNamespace : target, qualified);
            }
            String prefix = qualified.substring(0, colon);
            String local = qualified.substring(colon + 1);
            String namespace = root.lookupNamespaceURI(prefix);
            if (namespace == null) {
                throw new IllegalArgumentException("Prefix " + prefix + " in " + qualified
                        + " is not declared by the schema");
            }
            return new TypeReference(namespace, local);
        }
    }

    private static String documentation(Element owner) {
        Element annotation = firstChild(owner, "annotation");
        if (annotation == null) {
            return "";
        }
        StringBuilder text = new StringBuilder();
        for (Element documentation : children(annotation, "documentation")) {
            if (text.length() > 0) {
                text.append(' ');
            }
            text.append(documentation.getTextContent());
        }
        return text.toString().replaceAll("\\s+", " ").trim();
    }

    private static Element firstChild(Element parent, String localName) {
        for (Element child : children(parent, localName)) {
            return child;
        }
        return null;
    }

    private static List<Element> children(Element parent, String localName) {
        List<Element> matches = new ArrayList<>();
        NodeList nodes = parent.getChildNodes();
        for (int i = 0; i < nodes.getLength(); i++) {
            Node node = nodes.item(i);
            if (node.getNodeType() == Node.ELEMENT_NODE && XSD.equals(node.getNamespaceURI())
                    && localName.equals(node.getLocalName())) {
                matches.add((Element) node);
            }
        }
        return matches;
    }
}