/* (c) 2017 Open Source Geospatial Foundation - all rights reserved * This code is licensed under the GPL 2.0 license, available at the root * application directory. */ package org.geoserver.wfs.json; import com.vividsolutions.jts.geom.Geometry; import org.geotools.feature.FeatureCollection; import org.geotools.feature.FeatureIterator; import org.geotools.referencing.CRS; import org.opengis.feature.Attribute; import org.opengis.feature.ComplexAttribute; import org.opengis.feature.Feature; import org.opengis.feature.Property; import org.opengis.feature.type.ComplexType; import org.opengis.feature.type.GeometryDescriptor; import org.opengis.feature.type.Name; import org.opengis.feature.type.PropertyDescriptor; import org.opengis.feature.type.PropertyType; import org.opengis.filter.identity.Identifier; import org.opengis.referencing.crs.CoordinateReferenceSystem; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.HashMap; import java.util.Iterator; import java.util.List; import java.util.Map; /** * GeoJSON writer capable of handling complex features. */ public final class ComplexGeoJsonWriter { private final GeoJSONBuilder jsonWriter; private boolean geometryFound = false; private CoordinateReferenceSystem crs; private long featuresCount = 0; public ComplexGeoJsonWriter(GeoJSONBuilder jsonWriter) { this.jsonWriter = jsonWriter; } public void write(List<FeatureCollection> collections) { for (FeatureCollection collection : collections) { // encode the feature collection making sure that the collection is closed try (FeatureIterator iterator = collection.features()) { encodeFeatureCollection(iterator); } } } /** * Encode all available features by iterating over the iterator. */ private void encodeFeatureCollection(FeatureIterator iterator) { while (iterator.hasNext()) { // encode the next feature encodeFeature(iterator.next()); featuresCount++; } } /** * Encode a feature in GeoJSON. */ private void encodeFeature(Feature feature) { // start the feature JSON object jsonWriter.object(); jsonWriter.key("type").value("Feature"); // encode the feature identifier if available Identifier identifier = feature.getIdentifier(); if (identifier != null) { jsonWriter.key("id").value(identifier.getID()); } // geometry attribute has some special handling Property geometryAttribute = encodeGeometry(feature); // start the JSON object that will contain all the others properties jsonWriter.key("properties"); jsonWriter.object(); // encode object properties, we pass the geometry attribute to avoid duplicate encodings encodeProperties(geometryAttribute, feature.getType(), feature.getProperties()); // close the feature JSON object jsonWriter.endObject(); // close the properties JSON object jsonWriter.endObject(); } /** * Encode feature geometry attribute which may not exist or be NULL. * Returns the geometry attribute name for the provided feature, NULL * will be returned if the provided feature has no geometry attribute. */ private Property encodeGeometry(Feature feature) { // get feature geometry attribute description GeometryDescriptor geometryType = feature.getType().getGeometryDescriptor(); Property geometryAttribute = null; Geometry geometry = null; if (geometryType != null) { // extract CRS information from the geometry attribute description CoordinateReferenceSystem crs = geometryType.getCoordinateReferenceSystem(); // we let the setAxisOrder method handle the NULL case jsonWriter.setAxisOrder(CRS.getAxisOrder(crs)); if (crs != null) { // store the found CRS, this may be useful for the invoker this.crs = crs; } // store the attribute name and geometry value of the current feature geometryAttribute = feature.getProperty(geometryType.getName()); geometry = (Geometry) geometryAttribute.getValue(); } else { // this feature seems to not have a geometry, write the default axis order jsonWriter.setAxisOrder(CRS.AxisOrder.EAST_NORTH); } // start the JSON geometry object jsonWriter.key("geometry"); if (geometry != null) { // the feature has a geometry so encode it jsonWriter.writeGeom(geometry); // store that we found a geometry, this may be useful for the invoker geometryFound = true; } else { // no geometry just write a NULL value jsonWriter.value(null); } // return the found geometry attribute, may be NULL return geometryAttribute; } /** * Encode a feature properties. Geometry attribute will be ignored. */ private void encodeProperties(Property geometryAttribute, PropertyType parentType, Collection<Property> properties) { // index all the feature available properties by their type Map<PropertyType, List<Property>> index = indexPropertiesByType(geometryAttribute, properties); for (Map.Entry<PropertyType, List<Property>> entry : index.entrySet()) { // encode properties per type encodePropertiesByType(parentType, entry.getKey(), entry.getValue()); } } /** * Index the provided properties by their type, geometry property * will be ignored. */ private Map<PropertyType, List<Property>> indexPropertiesByType(Property geometryAttribute, Collection<Property> properties) { Map<PropertyType, List<Property>> index = new HashMap<>(); for (Property property : properties) { if (geometryAttribute != null && property.equals(geometryAttribute)) { // ignore the geometry attribute that should have been encoded already continue; } // update the index with the current property List<Property> propertiesWithSameType = index.get(property.getType()); if (propertiesWithSameType == null) { // first time we see a property fo this type propertiesWithSameType = new ArrayList<>(); index.put(property.getType(), propertiesWithSameType); } propertiesWithSameType.add(property); } return index; } /** * Encode feature properties by type, we do this way so we can handle the case were * these properties should be encoded as a list or as elements that appear multiple * times. */ private void encodePropertiesByType(PropertyType parentType, PropertyType type, List<Property> properties) { PropertyDescriptor multipleType = isMultipleType(parentType, type); if (multipleType == null) { // simple JSON objects properties.forEach(this::encodeProperty); } else { // possible chained features that need to be encoded as a list List<Feature> chainedFeatures = getChainedFeatures(properties); if (chainedFeatures == null || chainedFeatures.isEmpty()) { // no chained features just encode each property properties.forEach(this::encodeProperty); } else { // chained features so we need to encode the chained features as an array encodeChainedFeatures(multipleType.getName().getLocalPart(), chainedFeatures); } } } /** * Encodes a list of features (chained features) as a JSON array. */ private void encodeChainedFeatures(String attributeName, List<Feature> chainedFeatures) { // start the JSON object jsonWriter.key(attributeName); jsonWriter.array(); for (Feature feature : chainedFeatures) { // encode each chained feature jsonWriter.object(); encodeProperties(null, feature.getType(), feature.getProperties()); jsonWriter.endObject(); } // end the JSON chained features array jsonWriter.endArray(); } /** * Check if a property type should appear multiple times or be encoded as a list. */ private PropertyDescriptor isMultipleType(PropertyType parentType, PropertyType type) { if (!(parentType instanceof ComplexType)) { // only properties that belong to a complex type can be chained features return null; } // search the current type on the parent properties ComplexType complexType = (ComplexType) parentType; PropertyDescriptor foundType = null; for (PropertyDescriptor descriptor : complexType.getDescriptors()) { if (descriptor.getType().equals(type)) { // found our type foundType = descriptor; } } // if the found type can appear multiples time is not a chained feature if (foundType == null) { return null; } if (foundType.getMaxOccurs() > 1) { // this type can appear more than once so it should not be encoded as a list return foundType; } return null; } /** * Get a list of chained features, NULL will be returned if this properties * are not chained features. */ private List<Feature> getChainedFeatures(List<Property> properties) { List<Feature> features = new ArrayList<>(); for (Property property : properties) { if (!(property instanceof ComplexAttribute)) { // only the chaining of complex features is supported return null; } ComplexAttribute complexProperty = (ComplexAttribute) property; Collection<Property> subProperties = complexProperty.getProperties(); if (subProperties.size() > 1) { // more than one property means that this are not chained features return null; } Property subProperty = getElementAt(subProperties, 0); if (!(subProperty instanceof Feature)) { // if the only property is not a feature this are no chained features return null; } features.add((Feature) subProperty); } // this are chained features return features; } /** * Helper method that just gets an element from a collection at a certain index. */ private <T> T getElementAt(Collection<T> collection, int index) { Iterator<T> iterator = collection.iterator(); T element = null; for (int i = 0; i <= index && iterator.hasNext(); i++) { element = iterator.next(); } return element; } /** * Encode a feature property, we only support complex attributes and * simple attributes, if another tye of attribute is used an exception * will be throw. */ private void encodeProperty(Property property) { if (property instanceof ComplexAttribute) { // check if we have a simple content ComplexAttribute complexAttribute = (ComplexAttribute) property; Object simpleValue = getSimpleContent(complexAttribute); if (simpleValue != null) { encodeSimpleAttribute(complexAttribute.getName().getLocalPart(), simpleValue); } else { // we need to encode a complex attribute encodeComplexAttribute((ComplexAttribute) property); } } else if (property instanceof Attribute) { // check if we have a feature or list of features (chained features) List<Feature> features = getFeatures((Attribute) property); if (features != null) { encodeChainedFeatures(property.getName().getLocalPart(), features); } else { // we need to encode a simple attribute encodeSimpleAttribute((Attribute) property); } } else { // unsupported attribute type provided, this will unlikely happen throw new RuntimeException(String.format( "Invalid property '%s' of type '%s', only 'Attribute' and 'ComplexAttribute' properties types are supported.", property.getName(), property.getClass().getCanonicalName())); } } /** * Helper method that try to extract a list of features from * an attribute. If no features can be found NULL is returned. */ private List<Feature> getFeatures(Attribute attribute) { Object value = attribute.getValue(); if (value instanceof Feature) { // feature found return it in a single ton list return Collections.singletonList((Feature) value); } if (!(value instanceof Collection)) { // not a feature or list of features return null; } Collection collection = (Collection) value; if (collection.isEmpty()) { // we cannot be sure that this is a list of features return Collections.emptyList(); } if (!(getElementAt(collection, 0) instanceof Feature)) { // list doesn't contain features return null; } // make sure we have only features List<Feature> features = new ArrayList<>(); for (Object object : collection) { if (!(object instanceof Feature)) { // not a feature this is a mixed collection throw new RuntimeException(String.format( "Unable to handle attribute '%s'.", attribute)); } features.add((Feature) object); } return features; } /** * Helper method that try to extract a simple content from a complex * attribute, NULL is returned if no simple content is present. */ private Object getSimpleContent(ComplexAttribute property) { Collection<Property> properties = property.getProperties(); if (properties.isEmpty() || properties.size() > 1) { // no properties or more than property mean no simple content return null; } Property simpleContent = getElementAt(properties, 0); if (simpleContent == null) { // simple content is NULL end extraction here return null; } Name name = simpleContent.getName(); if (name == null || !name.getLocalPart().equals("simpleContent")) { // not a simple content node return null; } Object value = simpleContent.getValue(); if (value instanceof Number || value instanceof String || value instanceof Character) { // the extract value is a simple Java type return value; } // not a valid simple content type return null; } /** * Encode a complex attribute as a JSON object. */ private void encodeComplexAttribute(ComplexAttribute attribute) { // get the attribute name and start a JSON object String name = attribute.getName().getLocalPart(); jsonWriter.key(name); jsonWriter.object(); // encode the object properties, since this is not a top feature or a // chained feature we don't need to explicitly handle the geometry attribute encodeProperties(null, attribute.getType(), attribute.getProperties()); // end the attribute JSON object jsonWriter.endObject(); } /** * Encode a simple attribute, this means that this property * will be encoded as a simple JSON attribute. */ private void encodeSimpleAttribute(Attribute attribute) { String name = attribute.getName().getLocalPart(); Object value = attribute.getValue(); encodeSimpleAttribute(name, value); } /** * Encode a simple attribute, this means that this property * will be encoded as a simple JSON attribute. */ private void encodeSimpleAttribute(String name, Object value) { // add a simple JSON attribute to the current object jsonWriter.key(name).value(value); } /** * Return TRUE if a geometry was found during the features collections encoding. */ public boolean geometryFound() { return geometryFound; } /** * Return a CRS if one was found during the features collections encoding. */ public CoordinateReferenceSystem foundCrs() { return crs; } /** * Return the number of top encoded features. */ public long getFeaturesCount() { return featuresCount; } }