blob: 74f6667238fc6a648ae15852f2bbc64debb63994 [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.util.Map;
import java.util.Set;
import java.util.HashMap;
import java.util.Collection;
import java.util.Collections;
import java.io.IOException;
import java.lang.reflect.Type;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.lang.reflect.AnnotatedElement;
import java.lang.reflect.ParameterizedType;
import javax.xml.bind.annotation.XmlNs;
import javax.xml.bind.annotation.XmlType;
import javax.xml.bind.annotation.XmlSchema;
import javax.xml.bind.annotation.XmlElement;
import javax.xml.bind.annotation.XmlRootElement;
import javax.xml.bind.annotation.adapters.XmlJavaTypeAdapter;
import javax.xml.bind.annotation.adapters.XmlJavaTypeAdapters;
import javax.xml.parsers.ParserConfigurationException;
import org.xml.sax.SAXException;
import org.opengis.util.CodeList;
import org.opengis.annotation.UML;
import org.opengis.geoapi.SchemaException;
import org.opengis.geoapi.SchemaInformation;
import org.apache.sis.util.Classes;
import org.apache.sis.internal.system.Modules;
import org.apache.sis.internal.xml.LegacyNamespaces;
import org.apache.sis.xml.Namespaces;
import static org.apache.sis.test.TestCase.PENDING_FUTURE_SIS_VERSION;
/**
* Verify JAXB annotations in a single package. A new instance of this class is created by
* {@link SchemaCompliance#verify(java.nio.file.Path)} for each Java package to be verified.
*
* @author Martin Desruisseaux (Geomatys)
* @version 1.0
* @since 1.0
* @module
*/
final strictfp class PackageVerifier {
/**
* Sentinel value used in {@link #LEGACY_NAMESPACES} for meaning "all properties in that namespace".
*/
@SuppressWarnings("unchecked")
private static final Set<String> ALL = InfiniteSet.INSTANCE;
/**
* Classes or properties having a JAXB annotation in this namespace should be deprecated.
* Deprecated namespaces are enumerated as keys. If the associated value is {@link #ALL},
* the whole namespace is deprecated. If the value is not ALL, then only the enumerated
* properties are deprecated.
*
* <p>Non-ALL values are rare. They happen in a few cases where a property is legacy despite its namespace.
* Those "properties" are errors in the legacy ISO 19139:2007 schema; they were types without their property
* wrappers. For example in {@code SV_CoupledResource}, {@code <gco:ScopedName>} was marshalled without its
* {@code <srv:scopedName>} wrapper — note the upper and lower-case "s". Because {@code ScopedName} is a type,
* we had to keep the namespace declared in {@link org.apache.sis.util.iso.DefaultScopedName}
* (the replacement is performed by {@code org.apache.sis.xml.TransformingWriter}).</p>
*/
private static final Map<String, Set<String>> LEGACY_NAMESPACES;
static {
final Map<String, Set<String>> m = new HashMap<>(8);
m.put(LegacyNamespaces.GMD, ALL);
m.put(LegacyNamespaces.GMI, ALL);
m.put(LegacyNamespaces.GMX, ALL);
m.put(LegacyNamespaces.SRV, ALL);
m.put(Namespaces.GCO, Collections.singleton("ScopedName")); // Not to be confused with standard <srv:scopedName>
LEGACY_NAMESPACES = Collections.unmodifiableMap(m);
}
/**
* Types declared in JAXB annotations to be considered as equivalent to types in XML schemas.
*/
private static final Map<String,String> TYPE_EQUIVALENCES;
static {
final Map<String,String> m = new HashMap<>();
m.put("PT_FreeText", "CharacterString");
m.put("Abstract_Citation", "CI_Citation");
m.put("AbstractCI_Party", "CI_Party");
m.put("Abstract_Responsibility", "CI_Responsibility");
m.put("Abstract_Extent", "EX_Extent");
TYPE_EQUIVALENCES = Collections.unmodifiableMap(m);
}
/**
* The schemas to compare with the JAXB annotations.
* Additional schemas will be loaded as needed.
*/
private final SchemaCompliance schemas;
/**
* The package name, for reporting error.
*/
private final String packageName;
/**
* The default namespace to use if a class does not define explicitly a namespace.
*/
private final String packageNS;
/**
* The namespace of the class under examination.
* This field must be updated for every class found in a package.
*/
private String classNS;
/**
* The class under examination, used in error messages.
* This field must be updated for every class found in a package.
*/
private Class<?> currentClass;
/**
* Whether the class under examination is defined in a legacy namespace.
* In such case, some checks may be skipped because we didn't loaded schemas for legacy properties.
*/
private boolean isDeprecatedClass;
/**
* The schema definition for the class under examination.
*
* @see SchemaCompliance#getTypeDefinition(String)
*/
private Map<String, SchemaCompliance.Element> properties;
/**
* Whether a namespace is actually used of not.
* We use this map for identifying unnecessary prefix declarations.
*/
private final Map<String,Boolean> namespaceIsUsed;
/**
* Whether adapters declared in {@code package-info.java} are used or not.
*/
private final Map<Class<?>,Boolean> adapterIsUsed;
/**
* Creates a new verifier for the given package.
*/
PackageVerifier(final SchemaCompliance schemas, final Package pkg)
throws IOException, ParserConfigurationException, SAXException, SchemaException
{
this.schemas = schemas;
namespaceIsUsed = new HashMap<>();
adapterIsUsed = new HashMap<>();
String name = "?", namespace = "";
if (pkg != null) {
name = pkg.getName();
final XmlSchema schema = pkg.getAnnotation(XmlSchema.class);
if (schema != null) {
namespace = schema.namespace();
String location = schema.location();
if (!XmlSchema.NO_LOCATION.equals(location)) {
if (!location.startsWith(schema.namespace())) {
throw new SchemaException("XML schema location inconsistent with namespace in package " + name);
}
schemas.loadSchema(location);
}
for (final XmlNs xmlns : schema.xmlns()) {
final String pr = xmlns.prefix();
final String ns = xmlns.namespaceURI();
final String cr = schemas.allXmlNS.put(pr, ns);
if (cr != null && !cr.equals(ns)) {
throw new SchemaException(String.format("Prefix \"%s\" associated to two different namespaces:%n%s%n%s", pr, cr, ns));
}
if (namespaceIsUsed.put(ns, Boolean.FALSE) != null) {
throw new SchemaException(String.format("Duplicated namespace in package %s:%n%s", name, ns));
}
}
}
/*
* Lists the type of all values for which an adapter is declared in package-info.
* If the type is not explicitly declared, then it is inferred from class signature.
*/
final XmlJavaTypeAdapters adapters = pkg.getAnnotation(XmlJavaTypeAdapters.class);
if (adapters != null) {
for (final XmlJavaTypeAdapter adapter : adapters.value()) {
Class<?> propertyType = adapter.type();
if (propertyType == XmlJavaTypeAdapter.DEFAULT.class) {
for (Class<?> c = adapter.value(); ; c = c.getSuperclass()) {
final Type type = c.getGenericSuperclass();
if (type == null) {
throw new SchemaException(String.format(
"Can not infer type for %s adapter.", adapter.value().getName()));
}
if (type instanceof ParameterizedType) {
final Type[] p = ((ParameterizedType) type).getActualTypeArguments();
if (p.length == 2) {
Type pt = p[1];
if (pt instanceof ParameterizedType) {
pt = ((ParameterizedType) pt).getRawType();
}
if (pt instanceof Class<?>) {
propertyType = (Class<?>) pt;
break;
}
}
}
}
}
if (adapterIsUsed.put((Class<?>) propertyType, Boolean.FALSE) != null) {
throw new SchemaException(String.format(
"More than one adapter for %s in package %s", propertyType, name));
}
}
}
}
packageName = name;
packageNS = namespace;
}
/**
* Verifies {@code @XmlType} and {@code @XmlRootElement} on the class. This method verifies naming convention
* (type name should be same as root element name with {@value SchemaCompliance#TYPE_SUFFIX} suffix appended),
* ensures that the name exists in the schema, and checks the namespace.
*
* @param type the class on which to verify annotations.
*/
final void verify(final Class<?> type)
throws IOException, ParserConfigurationException, SAXException, SchemaException
{
/*
* Reinitialize fields to be updated for each class.
*/
classNS = null;
currentClass = type;
isDeprecatedClass = false;
properties = Collections.emptyMap();
final XmlType xmlType = type.getDeclaredAnnotation(XmlType.class);
final XmlRootElement xmlRoot = type.getDeclaredAnnotation(XmlRootElement.class);
XmlElement codeList = null;
/*
* Get the type name and namespace from the @XmlType or @XmlRootElement annotations.
* If both of them are present, verify that they are consistent (same namespace and
* same name with "_Type" suffix in @XmlType). If the type name is not declared, we
* assume that it is the same than the class name (this is what Apache SIS 1.0 does
* in its org.apache.sis.internal.jaxb.code package for CodeList adapters).
*/
final String isoName; // ISO class name (not the same than Java class name).
if (xmlRoot != null) {
classNS = xmlRoot.namespace();
isoName = xmlRoot.name();
if (xmlType != null) {
if (!classNS.equals(xmlType.namespace())) {
throw new SchemaException(errorInClassMember(null)
.append("Mismatched namespace in @XmlType and @XmlRootElement.").toString());
}
SchemaCompliance.verifyNamingConvention(type.getName(), isoName, xmlType.name(), SchemaCompliance.TYPE_SUFFIX);
}
} else if (xmlType != null) {
classNS = xmlType.namespace();
final String name = xmlType.name();
isoName = SchemaCompliance.trim(name, SchemaCompliance.TYPE_SUFFIX);
} else {
/*
* If there is neither @XmlRootElement or @XmlType annotation, it may be a code list as implemented
* in the org.apache.sis.internal.jaxb.code package. Those adapters have a single @XmlElement which
* is to be interpreted as if it was the actual type.
*/
for (final Method method : type.getDeclaredMethods()) {
final XmlElement e = method.getDeclaredAnnotation(XmlElement.class);
if (e != null) {
if (codeList != null) return;
codeList = e;
}
}
if (codeList == null) return;
classNS = codeList.namespace();
isoName = codeList.name();
}
/*
* Verify that the namespace declared on the class is not redundant with the namespace
* declared in the package. Actually redundant namespaces are not wrong, but we try to
* reduce code size.
*/
if (classNS.equals(AnnotationConsistencyCheck.DEFAULT)) {
classNS = packageNS;
} else if (classNS.equals(packageNS)) {
throw new SchemaException(errorInClassMember(null)
.append("Redundant namespace declaration: ").append(classNS).toString());
}
/*
* Verify that the namespace has a prefix associated to it in the package-info file.
*/
if (namespaceIsUsed.put(classNS, Boolean.TRUE) == null) {
throw new SchemaException(errorInClassMember(null)
.append("No prefix in package-info for ").append(classNS).toString());
}
/*
* Properties in the legacy GMD or GMI namespaces may be deprecated, depending if a replacement
* is already available or not. However properties in other namespaces should not be deprecated.
* Some validations of deprecated properties are skipped because we didn't loaded their schema.
*/
isDeprecatedClass = (LEGACY_NAMESPACES.get(classNS) == ALL);
if (!isDeprecatedClass) {
if (type.isAnnotationPresent(Deprecated.class)) {
throw new SchemaException(errorInClassMember(null)
.append("Unexpected @Deprecated annotation.").toString());
}
/*
* Verify that class name exists, then verify its namespace (associated to the null key by convention).
*/
properties = schemas.getTypeDefinition(isoName);
if (properties == null) {
throw new SchemaException(errorInClassMember(null)
.append("Unknown name declared in @XmlRootElement: ").append(isoName).toString());
}
final String expectedNS = properties.get(null).namespace;
if (!classNS.equals(expectedNS)) {
throw new SchemaException(errorInClassMember(null)
.append(isoName).append(" shall be associated to namespace ").append(expectedNS).toString());
}
if (codeList != null) return; // If the class was a code list, we are done.
}
/*
* At this point the classNS, className, isDeprecatedClass and properties field have been set.
* We can now loop over the XML elements, which may be on fields or on methods (public or private).
*/
for (final Field field : type.getDeclaredFields()) {
Class<?> valueType = field.getType();
final boolean isCollection = Collection.class.isAssignableFrom(valueType);
if (isCollection) {
valueType = Classes.boundOfParameterizedProperty(field);
}
verify(field, field.getName(), valueType, isCollection);
}
for (final Method method : type.getDeclaredMethods()) {
Class<?> valueType = method.getReturnType();
final boolean isCollection = Collection.class.isAssignableFrom(valueType);
if (isCollection) {
valueType = Classes.boundOfParameterizedProperty(method);
}
verify(method, method.getName(), valueType, isCollection);
}
}
/**
* Validate a field or a method against the expected schema.
*
* @param property the field or method to validate.
* @param javaName the field name or method name in Java code.
* @param valueType the field type or the method return type, or element type in case of collection.
* @param isCollection whether the given value type is the element type of a collection.
*/
private void verify(final AnnotatedElement property, final String javaName,
final Class<?> valueType, final boolean isCollection) throws SchemaException
{
final XmlElement element = property.getDeclaredAnnotation(XmlElement.class);
if (element == null) {
return; // No @XmlElement annotation - skip this property.
}
String name = element.name();
if (name.equals(AnnotationConsistencyCheck.DEFAULT)) {
name = javaName;
}
String ns = element.namespace();
if (ns.equals(AnnotationConsistencyCheck.DEFAULT)) {
ns = classNS;
}
if (namespaceIsUsed.put(ns, Boolean.TRUE) == null) {
throw new SchemaException(errorInClassMember(javaName)
.append("Missing @XmlNs for namespace ").append(ns).toString());
}
/*
* Remember that we need an adapter for this property, unless the method or field defines its own adapter.
* In theory we do not need to report missing adapter since JAXB performs its own check, but we do anyway
* because JAXB has default adapters for String, Double, Boolean, Date, etc. which do not match the way
* OGC/ISO marshal those elements.
*/
if (!property.isAnnotationPresent(XmlJavaTypeAdapter.class) && valueType != null) {
/*
* Internal classes in Apache SIS "jaxb" subpackages can be marshalled directly.
* Apache SIS classes defined in other packages may be code lists, which still need adapters.
*/
if (!valueType.getName().startsWith(Modules.CLASSNAME_PREFIX) || CodeList.class.isAssignableFrom(valueType)) {
Class<?> c = valueType;
while (adapterIsUsed.replace(c, Boolean.TRUE) == null) {
final Class<?> parent = c.getSuperclass();
if (parent != null) {
c = parent;
} else {
final Class<?>[] p = c.getInterfaces();
if (p.length == 0) {
throw new SchemaException(errorInClassMember(javaName)
.append("Missing @XmlJavaTypeAdapter for ").append(valueType).toString());
}
c = p[0]; // Take only the first interface, which should be the "main" parent.
}
}
}
}
/*
* We do not verify fully the properties in legacy namespaces because we didn't loaded their schemas.
* However we verify at least that those properties are not declared as required.
*/
if (LEGACY_NAMESPACES.getOrDefault(ns, Collections.emptySet()).contains(name)) {
if (!isDeprecatedClass && element.required()) {
throw new SchemaException(errorInClassMember(javaName)
.append("Legacy property should not be required.").toString());
}
} else {
/*
* Property in non-legacy namespaces should not be deprecated. Verify also their namespace
* and whether the property is required or optional, and whether it should be a collection.
*/
if (property.isAnnotationPresent(Deprecated.class)) {
throw new SchemaException(errorInClassMember(javaName)
.append("Unexpected deprecation status.").toString());
}
final SchemaCompliance.Element info = properties.get(name);
if (info == null) {
throw new SchemaException(errorInClassMember(javaName)
.append("Unexpected XML element: ").append(name).toString());
}
if (info.namespace != null && !ns.equals(info.namespace)) {
throw new SchemaException(errorInClassMember(javaName)
.append("Declared namespace: ").append(ns).append(System.lineSeparator())
.append("Expected namespace: ").append(info.namespace).toString());
}
if (element.required() != info.isRequired) {
throw new SchemaException(errorInClassMember(javaName)
.append("Expected @XmlElement(required = ").append(info.isRequired).append(')').toString());
}
/*
* Following is a continuation of our check for multiplicity, but also the beginning of the check
* for return value type. The return type should be an interface with a UML annotation; we check
* that this annotation contains the name of the expected type.
*/
if (isCollection) {
if (!info.isCollection) {
if (PENDING_FUTURE_SIS_VERSION) // Temporarily disabled because require GeoAPI modifications.
throw new SchemaException(errorInClassMember(javaName).append("Value should be a singleton.").toString());
}
} else if (info.isCollection) {
if (PENDING_FUTURE_SIS_VERSION) // Temporarily disabled because require GeoAPI modifications.
throw new SchemaException(errorInClassMember(javaName).append("Value should be a collection.").toString());
}
if (valueType != null) {
final UML valueUML = valueType.getAnnotation(UML.class);
if (valueUML != null) {
String expected = info.typeName;
String actual = valueUML.identifier();
expected = TYPE_EQUIVALENCES.getOrDefault(expected, expected);
actual = TYPE_EQUIVALENCES.getOrDefault(actual, actual);
if (!expected.equals(actual)) {
if (PENDING_FUTURE_SIS_VERSION) // Temporarily disabled because require GeoAPI modifications.
throw new SchemaException(errorInClassMember(javaName)
.append("Declared value type: ").append(actual).append(System.lineSeparator())
.append("Expected value type: ").append(expected).toString());
}
}
}
/*
* Verify if we have a @XmlNs for the type of the value. This is probably not required, but we
* do that as a safety. A common namespace added by this check is Metadata Common Classes (MCC).
*/
final Map<String, SchemaCompliance.Element> valueInfo = schemas.getTypeDefinition(info.typeName);
if (valueInfo != null) {
final SchemaInformation.Element typeAndNS = valueInfo.get(null);
if (typeAndNS != null) {
final String valueNS = typeAndNS.namespace;
if (namespaceIsUsed.put(valueNS, Boolean.TRUE) == null) {
throw new SchemaException(errorInClassMember(javaName)
.append("Missing @XmlNs for property value namespace: ").append(valueNS).toString());
}
}
}
}
}
/**
* Returns a message beginning with "Error in …", to be completed by the caller.
* This is an helper method for exception messages.
*
* @param name the property name, or {@code null} if none.
*/
private StringBuilder errorInClassMember(final String name) {
final StringBuilder builder = new StringBuilder(80).append("Error in ");
if (isDeprecatedClass) {
builder.append("legacy ");
}
builder.append(currentClass.getCanonicalName());
if (name != null) {
builder.append('.').append(name);
}
return builder.append(':').append(System.lineSeparator());
}
/**
* Verifies if there is any unused namespace or adapter in package-info file.
*/
final void reportUnused() throws SchemaException {
for (final Map.Entry<String,Boolean> entry : namespaceIsUsed.entrySet()) {
if (!entry.getValue()) {
throw new SchemaException(String.format("Unused namespace in package %s:%n%s", packageName, entry.getKey()));
}
}
for (final Map.Entry<Class<?>,Boolean> entry : adapterIsUsed.entrySet()) {
if (!entry.getValue()) {
throw new SchemaException(String.format("Unused adapter in package %s for %s.", packageName, entry.getKey()));
}
}
}
}