blob: 0389c970e40db2d704fcf233d979c4731b443abe [file] [log] [blame]
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You 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 org.apache.sis.test.xml;
import java.io.IOException;
import java.nio.file.Path;
import java.nio.file.Files;
import java.nio.file.DirectoryStream;
import java.nio.file.DirectoryIteratorException;
import java.util.Map;
import java.util.HashMap;
import javax.xml.bind.annotation.XmlNs;
import javax.xml.bind.annotation.XmlElement;
import javax.xml.parsers.ParserConfigurationException;
import org.xml.sax.SAXException;
import org.opengis.geoapi.Departures;
import org.opengis.geoapi.DocumentationStyle;
import org.opengis.geoapi.SchemaInformation;
import org.opengis.geoapi.SchemaException;
import org.apache.sis.util.StringBuilders;
/**
* Compares JAXB annotations against the ISO 19115 schemas. This test requires a connection to
* <a href="https://standards.iso.org/iso/19115/-3/">https://standards.iso.org/iso/19115/-3/</a>.
* All classes in a given directory are scanned.
*
* <h2>Limitations</h2>
* Current implementation ignores the XML prefix (e.g. {@code "cit:"} in {@code "cit:CI_Citation"}).
* We assume that there is no name collision, especially given that {@code "CI_"} prefix in front of
* most OGC/ISO class names have the effect of a namespace. If a collision nevertheless happen, then
* an exception will be thrown.
*
* <p>Current implementation assumes that XML element name, type name, property name and property type
* name follow some naming convention. For example type names are suffixed with {@code "_Type"} in OGC
* schemas, while property type names are suffixed with {@code "_PropertyType"}. This class throws an
* exception if a type does not follow the expected naming convention. This requirement makes
* implementation easier, by reducing the amount of {@link Map}s that we need to manage.</p>
*
* @author Martin Desruisseaux (Geomatys)
* @version 1.0
* @since 1.0
* @module
*/
public final strictfp class SchemaCompliance extends SchemaInformation {
/**
* The prefix of XML type names for properties. In ISO/OGC schemas, this prefix does not appear
* in the definition of class types but may appear in the definition of property types.
*/
private static final String ABSTRACT_PREFIX = "Abstract_";
/**
* The suffix of XML type names for classes.
* This is used by convention in OGC/ISO standards (but not necessarily in other XSD).
*/
static final String TYPE_SUFFIX = "_Type";
/**
* Separator between XML prefix and the actual name.
*/
private static final char PREFIX_SEPARATOR = ':';
/**
* Root directory from which to search for classes.
*/
private final Path classRootDirectory;
/**
* The namespaces associated to prefixes, as declared by JAXB {@link XmlNs} annotations.
* Used for verifying that no prefix is defined twice for different namespaces.
*
* <p>This field is not really related to schema loading process. But we keep it in this class for
* {@link PackageVerifier} convenience, as a way to share a single map for all verifier instances.</p>
*/
final Map<String,String> allXmlNS;
/**
* Creates a new verifier for classes under the given directory. The given directory shall be the
* root of {@code "*.class"} files. For example if the {@code mypackage.MyClass} class is compiled
* in the {@code "MyProject/target/classes/mypackage/MyClass.class"} file, then the root directory
* shall be {@code "MyProject/target/classes/"}.
*
* @param classRootDirectory the root of compiled class files.
* @param schemaRootDirectory if the computer contains a local copy of ISO schemas, path to that directory.
* Otherwise {@code null}. This is only for making tests faster.
*/
public SchemaCompliance(final Path classRootDirectory, final Path schemaRootDirectory) {
super(schemaRootDirectory, new Departures(), DocumentationStyle.NONE);
this.classRootDirectory = classRootDirectory;
allXmlNS = new HashMap<>();
}
/**
* Verifies {@link XmlElement} annotations on all {@code *.class} files in the given directory and sub-directories.
* The given directory must be a sub-directory of the root directory given at construction time.
* This method will invoke itself for scanning sub-directories.
*
* @param directory the directory to scan for classes, relative to class root directory.
* @throws IOException if an error occurred while reading files or schemas.
* @throws ClassNotFoundException if an error occurred while loading a {@code "*.class"} file.
* @throws ParserConfigurationException if {@link javax.xml.parsers.DocumentBuilder} can not be created.
* @throws SAXException if an error occurred while parsing the XSD file.
* @throws SchemaException if a XSD file does not comply with our assumptions,
* or a JAXB annotation failed a compliance check.
*/
public void verify(final Path directory)
throws IOException, ClassNotFoundException, ParserConfigurationException, SAXException, SchemaException
{
PackageVerifier verifier = null;
final StringBuilder buffer = new StringBuilder();
try (DirectoryStream<Path> stream = Files.newDirectoryStream(classRootDirectory.resolve(directory))) {
for (Path path : stream) {
final String filename = path.getFileName().toString();
if (!filename.startsWith(".")) {
if (Files.isDirectory(path)) {
verify(path);
} else if (filename.endsWith(".class")) {
path = classRootDirectory.relativize(path);
buffer.setLength(0);
buffer.append(path.toString()).setLength(buffer.length() - 6); // Remove ".class" suffix.
StringBuilders.replace(buffer, '/', '.');
final Class<?> c = Class.forName(buffer.toString());
if (verifier == null) {
verifier = new PackageVerifier(this, c.getPackage());
}
verifier.verify(c);
}
}
}
} catch (DirectoryIteratorException e) {
throw e.getCause();
}
if (verifier != null) {
verifier.reportUnused();
}
}
/**
* Verifies that the relationship between the name of the given entity and its type are consistent with
* OGC/ISO conventions. This method ignores the prefix (e.g. {@code "mdb:"} in {@code "mdb:MD_Metadata"}).
*
* @param enclosing schema or other container where the error happened.
* @param name the class or property name. Example: {@code "MD_Metadata"}, {@code "citation"}.
* @param type the type of the above named object. Example: {@code "MD_Metadata_Type"}, {@code "CI_Citation_PropertyType"}.
* @param suffix the expected suffix at the end of {@code type}.
* @throws SchemaException if the given {@code name} and {@code type} are not compliant with expected convention.
*/
static void verifyNamingConvention(final String enclosing,
final String name, final String type, final String suffix) throws SchemaException
{
if (type.endsWith(suffix)) {
int nameStart = name.indexOf(PREFIX_SEPARATOR) + 1; // Skip "mdb:" or similar prefix.
int typeStart = type.indexOf(PREFIX_SEPARATOR) + 1;
if (name.startsWith(ABSTRACT_PREFIX, nameStart)) nameStart += ABSTRACT_PREFIX.length();
if (type.startsWith(ABSTRACT_PREFIX, typeStart)) typeStart += ABSTRACT_PREFIX.length();
final int length = name.length() - nameStart;
if (type.length() - typeStart - suffix.length() == length &&
type.regionMatches(typeStart, name, nameStart, length))
{
return;
}
}
throw new SchemaException(String.format("Error in %s:%n" +
"The type name should be the name with \"%s\" suffix, but found name=\"%s\" and type=\"%s\">.",
enclosing, suffix, name, type));
}
/**
* Removes leading and trailing spaces if any, then the prefix and the suffix in the given name.
* The prefix is anything before the first {@value #PREFIX_SEPARATOR} character.
* The suffix must be the given string, otherwise an exception is thrown.
*
* @param name the name from which to remove prefix and suffix.
* @param suffix the suffix to remove.
* @return the given name without prefix and suffix.
* @throws SchemaException if the given name does not end with the given suffix.
*/
static String trim(String name, final String suffix) throws SchemaException {
name = name.trim();
if (name.endsWith(suffix)) {
return name.substring(name.indexOf(PREFIX_SEPARATOR) + 1, name.length() - suffix.length());
}
throw new SchemaException(String.format("Expected a name ending with \"%s\" but got \"%s\".", suffix, name));
}
}