1   /*
2    * SPDX-FileCopyrightText: none
3    * SPDX-License-Identifier: CC0-1.0
4    */
5   
6   package dev.metaschema.core.model.validation;
7   
8   import org.eclipse.jdt.annotation.Owning;
9   import org.xml.sax.ErrorHandler;
10  import org.xml.sax.SAXException;
11  import org.xml.sax.SAXParseException;
12  
13  import java.io.IOException;
14  import java.io.InputStream;
15  import java.net.URI;
16  import java.util.Collections;
17  import java.util.LinkedList;
18  import java.util.List;
19  
20  import javax.xml.XMLConstants;
21  import javax.xml.transform.Source;
22  import javax.xml.transform.stream.StreamSource;
23  import javax.xml.validation.Schema;
24  import javax.xml.validation.SchemaFactory;
25  import javax.xml.validation.Validator;
26  
27  import dev.metaschema.core.model.IResourceLocation;
28  import dev.metaschema.core.model.constraint.IConstraint.Level;
29  import dev.metaschema.core.util.ObjectUtils;
30  import edu.umd.cs.findbugs.annotations.NonNull;
31  
32  /**
33   * Supports validating an XML resource using an XML schema.
34   */
35  public class XmlSchemaContentValidator
36      extends AbstractContentValidator {
37    private final Schema schema;
38  
39    @SuppressWarnings({ "resource", "PMD.UseTryWithResources" })
40    @NonNull
41    private static Schema toSchema(@NonNull List<? extends Source> schemaSources) throws IOException {
42      SchemaFactory schemafactory = SchemaFactory.newInstance(XMLConstants.W3C_XML_SCHEMA_NS_URI);
43      // schemafactory.setResourceResolver(new ClasspathResourceResolver());
44      // TODO verify source input streams are closed
45      try {
46        return ObjectUtils.notNull(schemaSources.isEmpty()
47            ? schemafactory.newSchema()
48            : schemafactory.newSchema(schemaSources.toArray(new Source[0])));
49      } catch (SAXException ex) {
50        throw new IOException(ex);
51      } finally {
52        // Close all source input streams
53        for (Source source : schemaSources) {
54          if (source instanceof StreamSource) {
55            StreamSource streamSource = (StreamSource) source;
56            if (streamSource.getInputStream() != null) {
57              streamSource.getInputStream().close();
58            }
59          }
60        }
61      }
62    }
63  
64    /**
65     * Construct a new XML schema validator using the provided XML schema sources.
66     * <p>
67     * This constructor takes ownership of the provided schema sources and is
68     * responsible for closing any associated input streams.
69     *
70     * @param schemaSources
71     *          the XML schemas to use for validation. Ownership of these sources is
72     *          transferred to this constructor, which will close any associated
73     *          input streams.
74     * @throws IOException
75     *           if an error occurred while parsing the provided XML schemas
76     */
77    public XmlSchemaContentValidator(@Owning @NonNull List<? extends Source> schemaSources) throws IOException {
78      this(toSchema(ObjectUtils.requireNonNull(schemaSources, "schemaSources")));
79    }
80  
81    /**
82     * Construct a new XML schema validator using the provided pre-parsed XML
83     * schema(s).
84     *
85     * @param schema
86     *          the pre-parsed XML schema(s) to use for validation
87     */
88    protected XmlSchemaContentValidator(@NonNull Schema schema) {
89      this.schema = ObjectUtils.requireNonNull(schema, "schema");
90    }
91  
92    private Schema getSchema() {
93      return schema;
94    }
95  
96    @Override
97    public IValidationResult validate(InputStream is, URI documentUri) throws IOException {
98      Source xmlSource = new StreamSource(is, documentUri.toASCIIString());
99  
100     Validator validator = getSchema().newValidator();
101     XmlValidationErrorHandler errorHandler = new XmlValidationErrorHandler(documentUri);
102     validator.setErrorHandler(errorHandler);
103     try {
104       validator.validate(xmlSource);
105     } catch (SAXParseException ex) {
106       String location = ex.getLineNumber() > -1 && ex.getColumnNumber() > -1
107           ? String.format("at %d:%d", ex.getLineNumber(), ex.getColumnNumber())
108           : "";
109       throw new IOException(
110           String.format("Unexpected failure during validation of '%s'%s. %s",
111               documentUri,
112               location,
113               ex.getLocalizedMessage()),
114           ex);
115     } catch (SAXException ex) {
116       throw new IOException(
117           String.format("Unexpected failure during validation of '%s'. %s",
118               documentUri,
119               ex.getLocalizedMessage()),
120           ex);
121     }
122     return errorHandler;
123   }
124 
125   /**
126    * Records an identified individual validation result found during XML schema
127    * validation.
128    */
129   public static class XmlValidationFinding implements IValidationFinding, IResourceLocation {
130     @NonNull
131     private final URI documentUri;
132     @NonNull
133     private final SAXParseException exception;
134     @NonNull
135     private final Level severity;
136 
137     /**
138      * Construct a new XML schema validation finding, which represents an issue
139      * identified during XML schema validation.
140      *
141      * @param severity
142      *          the finding significance
143      * @param exception
144      *          the XML schema validation exception generated during schema
145      *          validation representing the issue
146      * @param resourceUri
147      *          the resource the issue was found in
148      */
149     public XmlValidationFinding(
150         @NonNull Level severity,
151         @NonNull SAXParseException exception,
152         @NonNull URI resourceUri) {
153       this.severity = ObjectUtils.requireNonNull(severity, "severity");
154       this.exception = ObjectUtils.requireNonNull(exception, "exception");
155       this.documentUri = ObjectUtils.requireNonNull(resourceUri, "documentUri");
156     }
157 
158     @Override
159     public String getIdentifier() {
160       // always null
161       return null;
162     }
163 
164     @Override
165     public Kind getKind() {
166       return getSeverity() == Level.WARNING ? Kind.PASS : Kind.FAIL;
167     }
168 
169     @Override
170     public Level getSeverity() {
171       return severity;
172     }
173 
174     @Override
175     public URI getDocumentUri() {
176       String systemId = getCause().getSystemId();
177       return systemId == null ? documentUri : URI.create(systemId);
178     }
179 
180     @Override
181     public int getLine() {
182       return getCause().getLineNumber();
183     }
184 
185     @Override
186     public int getColumn() {
187       return getCause().getColumnNumber();
188     }
189 
190     @Override
191     public long getCharOffset() {
192       // not known
193       return -1;
194     }
195 
196     @Override
197     public long getByteOffset() {
198       // not known
199       return -1;
200     }
201 
202     @Override
203     public IResourceLocation getLocation() {
204       return this;
205     }
206 
207     @Override
208     public String getPathKind() {
209       // not known
210       return null;
211     }
212 
213     @Override
214     public String getPath() {
215       // not known
216       return null;
217     }
218 
219     @Override
220     public String getMessage() {
221       return getCause().getLocalizedMessage();
222     }
223 
224     @NonNull
225     @Override
226     public SAXParseException getCause() {
227       return exception;
228     }
229   }
230 
231   private static class XmlValidationErrorHandler implements ErrorHandler, IValidationResult {
232     @NonNull
233     private final URI documentUri;
234     @NonNull
235     private final List<XmlValidationFinding> findings = new LinkedList<>();
236     @NonNull
237     private Level highestSeverity = Level.INFORMATIONAL;
238 
239     public XmlValidationErrorHandler(@NonNull URI documentUri) {
240       this.documentUri = ObjectUtils.requireNonNull(documentUri, "documentUri");
241     }
242 
243     @NonNull
244     public URI getDocumentUri() {
245       return documentUri;
246     }
247 
248     private void adjustHighestSeverity(@NonNull Level severity) {
249       if (highestSeverity.ordinal() < severity.ordinal()) {
250         highestSeverity = severity;
251       }
252     }
253 
254     @SuppressWarnings("null")
255     @Override
256     public void warning(SAXParseException ex) throws SAXException {
257       findings.add(new XmlValidationFinding(Level.WARNING, ex, getDocumentUri()));
258       adjustHighestSeverity(Level.WARNING);
259     }
260 
261     @SuppressWarnings("null")
262     @Override
263     public void error(SAXParseException ex) throws SAXException {
264       findings.add(new XmlValidationFinding(Level.ERROR, ex, getDocumentUri()));
265       adjustHighestSeverity(Level.CRITICAL);
266     }
267 
268     @SuppressWarnings("null")
269     @Override
270     public void fatalError(SAXParseException ex) throws SAXException {
271       findings.add(new XmlValidationFinding(Level.CRITICAL, ex, getDocumentUri()));
272       adjustHighestSeverity(Level.CRITICAL);
273     }
274 
275     @SuppressWarnings("null")
276     @Override
277     @NonNull
278     public List<XmlValidationFinding> getFindings() {
279       return Collections.unmodifiableList(findings);
280     }
281 
282     @Override
283     public Level getHighestSeverity() {
284       return highestSeverity;
285     }
286   }
287 }