/*
* 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;
import java.util.Set;
import java.util.HashSet;
import java.lang.reflect.Method;
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.XmlElementRef;
import javax.xml.bind.annotation.XmlRootElement;
import org.opengis.util.CodeList;
import org.opengis.annotation.UML;
import org.opengis.annotation.Obligation;
import org.opengis.annotation.Specification;
import org.apache.sis.util.ArraysExt;
import org.apache.sis.xml.Namespaces;
import org.junit.After;
import org.junit.Test;
import static org.junit.Assert.*;
import static org.apache.sis.test.TestUtilities.getSingleton;
/**
* Base class for validations of {@link UML}, {@link XmlElement} and other annotations.
* Some tests performed by this class are:
*
* <ul>
* <li>All implementation classes have {@link XmlRootElement} and {@link XmlType} annotations.</li>
* <li>The name declared in the {@code XmlType} annotations matches the
* {@linkplain #getExpectedTypeForElement expected value}.</li>
* <li>The name declared in the {@code XmlRootElement} (classes) or {@link XmlElement} (methods)
* annotations matches the identifier declared in the {@link UML} annotation of the GeoAPI interfaces.</li>
* <li>The {@code XmlElement.required()} boolean is consistent with the UML {@linkplain Obligation obligation}.</li>
* <li>The namespace declared in the {@code XmlRootElement} or {@code XmlElement} annotations
* is not redundant with the {@link XmlSchema} annotation in the package.</li>
* <li>The prefixes declared in the {@link XmlNs} annotations match the
* {@linkplain Namespaces#getPreferredPrefix expected prefixes}.</li>
* <li>The {@linkplain #getWrapperFor wrapper}, if any, is consistent.</li>
* </ul>
*
* @author Cédric Briançon (Geomatys)
* @author Martin Desruisseaux (Geomatys)
* @since 0.3 (derived from geotk-3.05)
* @version 0.3
* @module
*/
public abstract strictfp class AnnotationsTestCase extends TestCase {
/**
* The {@value} string used in JAXB annotations for default names or namespaces.
*/
private static final String DEFAULT = "##default";
/**
* The GeoAPI interfaces or {@link CodeList} types to test.
*/
protected final Class<?>[] types;
/**
* The type being tested, or {@code null} if none. In case of test failure, this information
* will be used by {@link #printFailureLocation()} for formatting a message giving the name
* of class and method where the failure occurred.
*/
protected String testingClass;
/**
* The method being tested, or {@code null} if none. In case of test failure, this information
* will be used by {@link #printFailureLocation()} for formatting a message giving the name of
* class and method where the failure occurred.
*/
protected String testingMethod;
/**
* Creates a new test suite for the given types.
*
* @param types The GeoAPI interfaces or {@link CodeList} types to test.
*/
protected AnnotationsTestCase(final Class<?>... types) {
this.types = types;
}
/**
* Returns the SIS implementation class for the given GeoAPI interface.
* For example the implementation of the {@link org.opengis.metadata.citation.Citation}
* interface is the {@link org.apache.sis.metadata.iso.citation.DefaultCitation} class.
*
* @param <T> The type represented by the {@code type} argument.
* @param type The GeoAPI interface (never a {@link CodeList} type).
* @return The SIS implementation for the given interface.
*/
protected abstract <T> Class<? extends T> getImplementation(Class<T> type);
/**
* If the given GeoAPI type, when marshalled to XML, is wrapped into an other XML element,
* returns the class of the wrapper for that XML element. Otherwise returns {@code null}.
* Such wrappers are unusual in XML (except for lists), but the ISO 19139 standard do that
* systematically for every elements.
*
* <p><b>Example:</b> when a {@link org.apache.sis.metadata.iso.citation.DefaultContact}
* is marshalled to XML inside a {@code ResponsibleParty}, the element is not marshalled
* directly inside its parent as we usually do in XML. Instead, we have a {@code <CI_Contact>}.
* inside the {@code <contactInfo>} element as below:</p>
*
* {@preformat xml
* <CI_ResponsibleParty>
* <contactInfo>
* <CI_Contact>
* ...
* </CI_Contact>
* </contactInfo>
* </CI_ResponsibleParty>
* }
*
* To reflect that fact, this method shall return the internal {@code CI_Contact}
* wrapper class for the {@link org.apache.sis.metadata.iso.citation.DefaultCitation} argument.
* If no wrapper is expected for the given class, then this method shall return {@code null}.
*
* <p>If a wrapper is expected for the given class but was not found, then this method shall throw
* {@link ClassNotFoundException}. Note that no wrapper may be defined explicitely for the given type,
* while a wrapper is defined for a parent of the given type. This method does not need to care about
* such situation, since the caller will automatically searches for a parent class if
* {@code ClassNotFoundException} has been thrown.</p>
*
* <p>In SIS implementation, most wrappers are also {@link javax.xml.bind.annotation.adapters.XmlAdapter}.
* But this is not a requirement.</p>
*
* @param type The GeoAPI interface or {@link CodeList} type.
* @return The wrapper for the given type, or {@code null} if none.
* @throws ClassNotFoundException If a wrapper was expected but not found.
*/
protected abstract Class<?> getWrapperFor(Class<?> type) throws ClassNotFoundException;
/**
* The value returned by {@link AnnotationsTestCase#getWrapperFor(Class)}, together with
* a boolean telling whether the wrapper has been found in the tested class or in one
* of its parent classes.
*/
private static final class WrapperClass {
final Class<?> type;
boolean isInherited;
WrapperClass(final Class<?> type) {
this.type = type;
}
}
/**
* Returns the value of {@link #getWrapperFor(Class)} for the given class, or for a parent
* of the given class if {@code getWrapperFor(Class)} threw {@code ClassNotFoundException}.
*
* @param type The GeoAPI interface or {@link CodeList} type.
* @return The wrapper for the given type. {@link WrapperClass#type} is {@code null} if
* no wrapper has been found.
* @throws ClassNotFoundException If a wrapper was expected but not found in the
* given type neither in any of the parent classes.
*/
private WrapperClass getWrapperInHierarchy(final Class<?> type) throws ClassNotFoundException {
try {
return new WrapperClass(getWrapperFor(type));
} catch (ClassNotFoundException e) {
for (final Class<?> parent : type.getInterfaces()) {
if (ArraysExt.containsIdentity(types, parent)) try {
final WrapperClass wrapper = getWrapperInHierarchy(parent);
wrapper.isInherited = true;
return wrapper;
} catch (ClassNotFoundException e2) {
// JDK7 branch does: e.addSuppressed(e2);
}
}
throw e;
}
}
/**
* Returns the XML type for an element of the given type. For example in ISO 19139,
* the XML type of {@code CI_Citation} is {@code CI_Citation_Type}.
*
* @param type The GeoAPI interface.
* @param impl The implementation class.
* @return The name of the XML type for the given element, or {@code null} if none.
*
* @see #testImplementationAnnotations()
*/
protected abstract String getExpectedTypeForElement(Class<?> type, Class<?> impl);
/**
* Returns the expected namespace for an element defined by the given specification.
* For example the namespace of any type defined by {@link Specification#ISO_19115}
* is {@code "http://www.isotc211.org/2005/gmd"}.
*
* <p>The default implementation recognizes the
* {@linkplain Specification#ISO_19115 ISO 19115},
* {@linkplain Specification#ISO_19115_2 ISO 19115-2},
* {@linkplain Specification#ISO_19139 ISO 19139} and
* {@linkplain Specification#ISO_19108 ISO 19108} specifications.
* Subclasses shall override this method if they need to support more namespaces.</p>
*
* <p>The prefix for the given namespace will be fetched by
* {@link Namespaces#getPreferredPrefix(String, String)}.</p>
*
* @param impl The implementation class or {@link CodeList} type.
* @param specification The specification that define the type, or {@code null} if unspecified.
* @return The expected namespace.
* @throws IllegalArgumentException If the given specification is unknown to this method.
*/
protected String getExpectedNamespace(final Class<?> impl, final Specification specification) {
switch (specification) {
case ISO_19115: return Namespaces.GMD;
case ISO_19115_2: return Namespaces.GMI;
case ISO_19139: return Namespaces.GMX;
case ISO_19108: return Namespaces.GMD;
default: throw new IllegalArgumentException(specification.toString());
}
}
/**
* Replaces {@value #DEFAULT} value by the {@link XmlSchema} namespace if needed,
* then performs validity check on the resulting namespace. This method checks that:
*
* <ul>
* <li>The namespace is not redundant with the package-level {@link XmlSchema} namespace.</li>
* <li>The namespace is declared in a package-level {@link XmlNs} annotation.</li>
* <li>The namespace is equals to the {@linkplain #getExpectedNamespace expected namespace}.</li>
* </ul>
*
* @param namespace The namespace given by the {@code @XmlRootElement} or {@code @XmlElement} annotation.
* @param impl The implementation or wrapper class for which to get the package namespace.
* @param uml The {@code @UML} annotation, or {@code null} if none.
* @return The actual namespace (same as {@code namespace} if it was not {@value #DEFAULT}).
*/
private String assertExpectedNamespace(String namespace, final Class<?> impl, final UML uml) {
assertNotNull("Missing namespace.", namespace);
assertFalse("Missing namespace.", namespace.trim().isEmpty());
/*
* Get the namespace declared at the package level, and ensure the the
* given namespace is not redundant with that package-level namespace.
*/
final XmlSchema schema = impl.getPackage().getAnnotation(XmlSchema.class);
assertNotNull("Missing @XmlSchema package annotation.", schema);
final String schemaNamespace = schema.namespace();
assertFalse("Missing namespace in @XmlSchema package annotation.", schemaNamespace.trim().isEmpty());
assertFalse("Namespace declaration is redundant with @XmlSchema.", namespace.equals(schemaNamespace));
/*
* Check that the namespace is declared in the package-level @XmlNs annotation.
* We do not verify the validity of those @XmlNs annotations, since this is the
* purpose of the 'testPackageAnnotations()' method.
*/
if (!DEFAULT.equals(namespace)) {
boolean found = false;
for (final XmlNs ns : schema.xmlns()) {
if (namespace.equals(ns.namespaceURI())) {
found = true;
break;
}
}
if (!found) {
fail("Namespace for " + impl + " is not declared in the package @XmlSchema.xmlns().");
}
} else {
namespace = schemaNamespace;
}
assertEquals("Wrong namespace for the ISO specification.",
getExpectedNamespace(impl, (uml != null) ? uml.specification() : null), namespace);
return namespace;
}
/**
* Returns the namespace declared in the {@link XmlSchema} annotation of the given package,
* or {@code null} if none.
*
* @param p The package, or {@code null}.
* @return The namespace, or {@code null} if none.
*/
private static String getNamespace(final Package p) {
if (p != null) {
final XmlSchema schema = p.getAnnotation(XmlSchema.class);
if (schema != null) {
final String namespace = schema.namespace().trim();
if (!namespace.isEmpty() && !DEFAULT.equals(namespace)) {
return namespace;
}
}
}
return null;
}
/**
* Returns the namespace declared in the {@link XmlRootElement} annotation of the given class,
* or the package annotation if none is found in the class.
*
* @param impl The implementation class, or {@code null}.
* @return The namespace, or {@code null} if none.
*/
private static String getNamespace(final Class<?> impl) {
if (impl == null) {
return null;
}
final XmlRootElement root = impl.getAnnotation(XmlRootElement.class);
if (root != null) {
final String namespace = root.namespace().trim();
if (!namespace.isEmpty() && !DEFAULT.equals(namespace)) {
return namespace;
}
}
return getNamespace(impl.getPackage());
}
/**
* Gets the {@link XmlElement} annotation for the no-argument method of the given name
* in the given implementation class. If the method is not annotated, then fallback on
* a field having the same name than the UML identifier. If no such field is found or
* is annotated, returns {@code null}.
*
* @param impl The implementation class.
* @param method The name of the getter method to search for.
* @param uml The UML annotation on the GeoAPI interface, or {@code null} if none.
* @return The {@code XmlElement}, or {@code null} if none.
*/
private static XmlElement getXmlElement(final Class<?> impl, final String method, final UML uml) {
XmlElement element = null;
try {
element = impl.getMethod(method, (Class<?>[]) null).getAnnotation(XmlElement.class);
if (element == null && uml != null) {
element = impl.getDeclaredField(uml.identifier()).getAnnotation(XmlElement.class);
}
} catch (NoSuchMethodException ex) {
fail("Missing implementation: " + ex);
} catch (NoSuchFieldException ex) {
// Ignore - we will consider that there is no annotation.
}
return element;
}
/**
* Returns {@code true} if the given method should be ignored.
* This method returns {@code true} of deprecated methods and
* some standard methods from the JDK.
*/
private static boolean isIgnored(final Method method) {
if (method.isAnnotationPresent(Deprecated.class)) {
return true;
}
final String name = method.getName();
if (name.equals("equals") || name.equals("hashCode") || name.equals("doubleValue")) {
return true;
}
return false;
}
/**
* Tests the annotations on every GeoAPI interfaces and code lists in the {@link #types} array.
* More specifically this method tests that:
*
* <ul>
* <li>All elements in {@link #types} except code lists are interfaces.</li>
* <li>All elements in {@code types} have a {@link UML} annotation.</li>
* <li>All methods expect deprecated methods and methods overriding JDK methods
* have a {@link UML} annotation.</li>
* </ul>
*/
@Test
public void testInterfaceAnnotations() {
for (final Class<?> type : types) {
testingMethod = null;
testingClass = type.getCanonicalName();
UML uml = type.getAnnotation(UML.class);
assertNotNull("Missing @UML annotation.", uml);
if (!CodeList.class.isAssignableFrom(type)) {
for (final Method method : type.getDeclaredMethods()) {
testingMethod = method.getName();
if (!isIgnored(method)) {
uml = method.getAnnotation(UML.class);
assertNotNull("Missing @UML annotation.", uml);
}
}
}
}
done();
}
/**
* Tests the annotations in the {@code package-info} files of SIS implementations of the
* interfaces enumerated in the {@code #types} array. More specifically this method tests that:
*
* <ul>
* <li>The prefixes declared in the {@link XmlNs} annotations match the
* {@linkplain Namespaces#getPreferredPrefix expected prefixes}.</li>
* </ul>
*/
@Test
public void testPackageAnnotations() {
final Set<Package> packages = new HashSet<Package>();
for (final Class<?> type : types) {
if (!CodeList.class.isAssignableFrom(type)) {
testingClass = type.getCanonicalName();
final Class<?> impl = getImplementation(type);
if (impl != null) {
testingClass = impl.getCanonicalName();
final Package p = impl.getPackage();
assertNotNull("Missing package information.", p);
packages.add(p);
}
}
}
for (final Package p : packages) {
for (final XmlNs ns : p.getAnnotation(XmlSchema.class).xmlns()) {
testingClass = p.getName();
final String namespace = ns.namespaceURI();
assertEquals("Unexpected namespace prefix.", Namespaces.getPreferredPrefix(namespace, null), ns.prefix());
}
}
done();
}
/**
* Tests the annotations on every SIS implementations of the interfaces enumerated
* in the {@link #types} array. More specifically this method tests that:
*
* <ul>
* <li>All implementation classes have {@link XmlRootElement} and {@link XmlType} annotations.</li>
* <li>The name declared in the {@code XmlType} annotations matches the
* {@linkplain #getExpectedTypeForElement expected value}.</li>
* <li>The name declared in the {@code XmlRootElement} annotations matches the identifier declared
* in the {@link UML} annotation of the GeoAPI interfaces.</li>
* <li>The namespace declared in the {@code XmlRootElement} annotations is not redundant with
* the {@link XmlSchema} annotation in the package.</li>
* </ul>
*
* This method does not check the method annotations, since it is {@link #testMethodAnnotations()} job.
*/
@Test
@DependsOnMethod("testInterfaceAnnotations")
public void testImplementationAnnotations() {
for (final Class<?> type : types) {
if (CodeList.class.isAssignableFrom(type)) {
// Skip code lists, since they are not the purpose of this test.
continue;
}
testingClass = type.getCanonicalName();
/*
* Get the implementation class, which is mandatory (otherwise the
* subclass shall not include the interface in the 'types' array).
*/
final Class<?> impl = getImplementation(type);
assertNotNull("No implementation found.", impl);
assertNotSame("No implementation found.", type, impl);
testingClass = impl.getCanonicalName();
/*
* Compare the XmlRootElement with the UML annotation, if any. The UML annotation
* is mandatory in the default implementation of the 'testInterfaceAnnotations()'
* method, but we don't require the UML to be non-null here since this is not the
* job of this test method. This is because subclasses may choose to override the
* 'testInterfaceAnnotations()' method.
*/
final XmlRootElement root = impl.getAnnotation(XmlRootElement.class);
assertNotNull("Missing @XmlRootElement annotation.", root);
final UML uml = type.getAnnotation(UML.class);
if (uml != null) {
assertEquals("Wrong @XmlRootElement.name().", uml.identifier(), root.name());
}
/*
* Check that the namespace is the expected one (according subclass)
* and is not redundant with the package @XmlSchema annotation.
*/
assertExpectedNamespace(root.namespace(), impl, uml);
/*
* Compare the XmlType annotation with the expected value.
*/
final XmlType xmlType = impl.getAnnotation(XmlType.class);
assertNotNull("Missing @XmlType annotation.", xmlType);
String expected = getExpectedTypeForElement(type, impl);
if (expected == null) {
expected = DEFAULT;
}
assertEquals("Wrong @XmlType.name().", expected, xmlType.name());
}
done();
}
/**
* Tests the annotations on every methods of SIS classes.
* More specifically this method tests that:
*
* <ul>
* <li>The name declared in {@link XmlElement} matches the UML identifier.</li>
* <li>The {@code XmlElement.required()} boolean is consistent with the UML {@linkplain Obligation obligation}.</li>
* <li>The namespace declared in {@code XmlElement} is not redundant with the one declared in the package.</li>
* </ul>
*/
@Test
@DependsOnMethod("testImplementationAnnotations")
public void testMethodAnnotations() {
for (final Class<?> type : types) {
if (CodeList.class.isAssignableFrom(type)) {
// Skip code lists, since they are not the purpose of this test.
continue;
}
testingMethod = null;
testingClass = type.getCanonicalName();
final Class<?> impl = getImplementation(type);
if (impl == null) {
// Implementation existence are tested by 'testImplementationAnnotations()'.
// It is not the purpose of this test to verify again their existence.
continue;
}
testingClass = impl.getCanonicalName();
for (final Method method : type.getDeclaredMethods()) {
if (isIgnored(method)) {
continue;
}
testingMethod = method.getName();
final UML uml = method.getAnnotation(UML.class);
final XmlElement element = getXmlElement(impl, testingMethod, uml);
/*
* Just display the missing @XmlElement annotation for the method, since we know
* that some elements are not yet implemented (and consequently can not yet be
* annotated).
*/
if (element == null) {
// Note: lines with the "[WARNING]" string are highlighted by Jenkins.
warning("[WARNING] Missing @XmlElement annotation for ");
continue;
}
/*
* The UML annotation is mandatory in the default implementation of the
* 'testInterfaceAnnotations()' method, but we don't require the UML to
* be non-null here since this is not the job of this test method. This
* is because subclasses may choose to override the above test method.
*/
if (uml != null) {
assertEquals("Wrong @XmlElement.name().", uml.identifier(), element.name());
assertEquals("Wrong @XmlElement.required().", uml.obligation() == Obligation.MANDATORY, element.required());
}
/*
* Check that the namespace is the expected one (according subclass)
* and is not redundant with the package @XmlSchema annotation.
*/
assertExpectedNamespace(element.namespace(), impl, uml);
}
}
done();
}
/**
* Tests the annotations on wrappers returned by {@link #getWrapperFor(Class)}.
* More specifically this method tests that:
*
* <ul>
* <li>The wrapper have a getter and a setter method declared in the same class.</li>
* <li>The getter method is annotated with {@code @XmlElement} or {@code @XmlElementRef}, but not both</li>
* <li>{@code @XmlElementRef} is used only in parent classes, not in leaf classes.</li>
* <li>The name declared in {@code @XmlElement} matches the {@code @UML} identifier.</li>
* </ul>
*/
@Test
public void testWrapperAnnotations() {
for (final Class<?> type : types) {
testingClass = type.getCanonicalName();
/*
* Check the annotation on the wrapper, if there is one. If no wrapper is declared
* specifically for the current type, check if a wrapper is defined for the parent
* interface. In such case, the getElement() method is required to be annotated by
* @XmlElementRef, not @XmlElement, in order to let JAXB infer the name from the
* actual subclass.
*/
final WrapperClass wrapper;
try {
wrapper = getWrapperInHierarchy(type);
} catch (ClassNotFoundException e) {
fail(e.toString());
continue;
}
if (wrapper.type == null) {
// If the wrapper is intentionally undefined, skip it.
continue;
}
/*
* Now fetch the getter/setter methods, ensure that they are declared in the same class
* and verify that exactly one of @XmlElement or @XmlElementRef annotation is declared.
*/
testingClass = wrapper.type.getCanonicalName();
final Method getter, setter;
try {
getter = wrapper.type.getMethod("getElement", (Class<?>[]) null);
setter = wrapper.type.getMethod("setElement", getter.getReturnType());
} catch (NoSuchMethodException e) {
fail(e.toString());
continue;
}
assertEquals("The setter method must be declared in the same class than the " +
"getter method - not in a parent class, to avoid issues with JAXB.",
getter.getDeclaringClass(), setter.getDeclaringClass());
assertEquals("The setter parameter type shall be the same than the getter return type.",
getter.getReturnType(), getSingleton(setter.getParameterTypes()));
final XmlElement element = getter.getAnnotation(XmlElement.class);
assertEquals("Expected @XmlElement XOR @XmlElementRef.", (element == null),
getter.isAnnotationPresent(XmlElementRef.class));
/*
* If the annotation is @XmlElement, ensure that XmlElement.name() is equals to
* the UML identifier. Then verify that the
*/
if (element != null) {
assertFalse("Expected @XmlElementRef.", wrapper.isInherited);
final UML uml = type.getAnnotation(UML.class);
if (uml != null) { // 'assertNotNull' is 'testInterfaceAnnotations()' job.
assertEquals("Wrong @XmlElement.", uml.identifier(), element.name());
}
final String namespace = assertExpectedNamespace(element.namespace(), wrapper.type, uml);
if (!CodeList.class.isAssignableFrom(type)) {
final String expected = getNamespace(getImplementation(type));
if (expected != null) { // 'assertNotNull' is 'testImplementationAnnotations()' job.
assertEquals("Inconsistent @XmlRootElement namespace.", expected, namespace);
}
}
}
}
done();
}
/**
* Shall be invoked after every successful test in order
* to disable the report of failed class or method.
*/
protected final void done() {
testingClass = null;
testingMethod = null;
}
/**
* Prints the given message followed by the name of the class being tested.
*/
private void warning(String message) {
if (testingClass != null) {
final StringBuilder buffer = new StringBuilder(message);
buffer.append(testingClass);
if (testingMethod != null) {
buffer.append('.').append(testingMethod).append("()");
}
message = buffer.toString();
}
out.println(message);
}
/**
* If a test failed, reports the class and method names were the failure occurred.
* The message will be written in the {@link #out} printer.
*
* @see #testingClass
* @see #testingMethod
*/
@After
public final void printFailureLocation() {
if (testingClass != null) {
warning("TEST FAILURE: ");
}
}
}