| /* |
| * 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())); |
| } |
| } |
| } |
| } |