View Javadoc
1   /*
2    * Copyright (C) 2022 B3Partners B.V.
3    *
4    * SPDX-License-Identifier: MIT
5    */
6   package org.tailormap.api.controller;
7   
8   import static org.springframework.web.bind.annotation.RequestMethod.GET;
9   import static org.springframework.web.bind.annotation.RequestMethod.POST;
10  import static org.tailormap.api.persistence.helper.TMAttributeTypeHelper.isGeometry;
11  import static org.tailormap.api.persistence.helper.TMFeatureTypeHelper.getConfiguredAttributes;
12  
13  import io.micrometer.core.annotation.Timed;
14  import jakarta.validation.constraints.NotNull;
15  import java.io.IOException;
16  import java.io.Serializable;
17  import java.lang.invoke.MethodHandles;
18  import java.util.List;
19  import java.util.Locale;
20  import java.util.Map;
21  import java.util.stream.Collectors;
22  import org.apache.commons.lang3.tuple.Pair;
23  import org.geotools.api.data.Query;
24  import org.geotools.api.data.SimpleFeatureSource;
25  import org.geotools.api.feature.simple.SimpleFeature;
26  import org.geotools.api.filter.Filter;
27  import org.geotools.api.filter.FilterFactory;
28  import org.geotools.api.filter.sort.SortOrder;
29  import org.geotools.api.referencing.FactoryException;
30  import org.geotools.api.referencing.operation.MathTransform;
31  import org.geotools.api.referencing.operation.TransformException;
32  import org.geotools.data.simple.SimpleFeatureIterator;
33  import org.geotools.factory.CommonFactoryFinder;
34  import org.geotools.filter.text.cql2.CQLException;
35  import org.geotools.filter.text.ecql.ECQL;
36  import org.geotools.geometry.jts.JTS;
37  import org.geotools.util.factory.GeoTools;
38  import org.locationtech.jts.geom.Coordinate;
39  import org.locationtech.jts.geom.Geometry;
40  import org.locationtech.jts.util.GeometricShapeFactory;
41  import org.slf4j.Logger;
42  import org.slf4j.LoggerFactory;
43  import org.springframework.beans.factory.annotation.Value;
44  import org.springframework.http.HttpStatus;
45  import org.springframework.http.MediaType;
46  import org.springframework.http.ResponseEntity;
47  import org.springframework.transaction.annotation.Transactional;
48  import org.springframework.validation.annotation.Validated;
49  import org.springframework.web.bind.annotation.ModelAttribute;
50  import org.springframework.web.bind.annotation.RequestMapping;
51  import org.springframework.web.bind.annotation.RequestParam;
52  import org.springframework.web.server.ResponseStatusException;
53  import org.tailormap.api.annotation.AppRestController;
54  import org.tailormap.api.geotools.TransformationUtil;
55  import org.tailormap.api.geotools.featuresources.FeatureSourceFactoryHelper;
56  import org.tailormap.api.geotools.processing.GeometryProcessor;
57  import org.tailormap.api.persistence.Application;
58  import org.tailormap.api.persistence.GeoService;
59  import org.tailormap.api.persistence.TMFeatureType;
60  import org.tailormap.api.persistence.json.AppLayerSettings;
61  import org.tailormap.api.persistence.json.AppTreeLayerNode;
62  import org.tailormap.api.persistence.json.AttributeSettings;
63  import org.tailormap.api.persistence.json.FeatureTypeTemplate;
64  import org.tailormap.api.persistence.json.GeoServiceLayer;
65  import org.tailormap.api.persistence.json.TMAttributeDescriptor;
66  import org.tailormap.api.persistence.json.TMAttributeType;
67  import org.tailormap.api.repository.FeatureSourceRepository;
68  import org.tailormap.api.util.Constants;
69  import org.tailormap.api.viewer.model.ColumnMetadata;
70  import org.tailormap.api.viewer.model.Feature;
71  import org.tailormap.api.viewer.model.FeaturesResponse;
72  
73  @AppRestController
74  @Validated
75  @RequestMapping(
76      path = "${tailormap-api.base-path}/{viewerKind}/{viewerName}/layer/{appLayerId}/features",
77      produces = MediaType.APPLICATION_JSON_VALUE)
78  public class FeaturesController implements Constants {
79    private static final Logger logger =
80        LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
81  
82    private final FeatureSourceFactoryHelper featureSourceFactoryHelper;
83  
84    private final FeatureSourceRepository featureSourceRepository;
85    private final FilterFactory ff = CommonFactoryFinder.getFilterFactory(GeoTools.getDefaultHints());
86  
87    @Value("${tailormap-api.pageSize:100}")
88    private int pageSize;
89  
90    @Value("${tailormap-api.feature.info.maxitems:30}")
91    private int maxFeatures;
92  
93    @Value("${tailormap-api.features.wfs_count_exact:false}")
94    private boolean exactWfsCounts;
95  
96    public FeaturesController(
97        FeatureSourceFactoryHelper featureSourceFactoryHelper, FeatureSourceRepository featureSourceRepository) {
98      this.featureSourceFactoryHelper = featureSourceFactoryHelper;
99      this.featureSourceRepository = featureSourceRepository;
100   }
101 
102   @Transactional
103   @RequestMapping(method = {GET, POST})
104   @Timed(value = "get_features", description = "time spent to process get features call")
105   public ResponseEntity<Serializable> getFeatures(
106       @ModelAttribute AppTreeLayerNode appTreeLayerNode,
107       @ModelAttribute GeoService service,
108       @ModelAttribute GeoServiceLayer layer,
109       @ModelAttribute Application application,
110       @RequestParam(required = false) Double x,
111       @RequestParam(required = false) Double y,
112       @RequestParam(defaultValue = "4") Double distance,
113       @RequestParam(required = false) String __fid,
114       @RequestParam(defaultValue = "false") Boolean simplify,
115       @RequestParam(required = false) String filter,
116       @RequestParam(required = false) Integer page,
117       @RequestParam(required = false) String sortBy,
118       @RequestParam(required = false, defaultValue = "asc") String sortOrder,
119       @RequestParam(defaultValue = "false") boolean onlyGeometries,
120       @RequestParam(defaultValue = "false") boolean geometryInAttributes) {
121 
122     if (layer == null) {
123       throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Can't find layer " + appTreeLayerNode);
124     }
125 
126     TMFeatureType tmft = service.findFeatureTypeForLayer(layer, featureSourceRepository);
127     if (tmft == null) {
128       throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Layer does not have feature type");
129     }
130     AppLayerSettings appLayerSettings = application.getAppLayerSettings(appTreeLayerNode);
131 
132     if (onlyGeometries) {
133       geometryInAttributes = true;
134     }
135 
136     FeaturesResponse featuresResponse;
137 
138     if (null != __fid) {
139       featuresResponse = getFeatureByFID(tmft, appLayerSettings, __fid, application, !geometryInAttributes);
140     } else if (null != x && null != y) {
141       featuresResponse = getFeaturesByXY(
142           tmft, appLayerSettings, filter, x, y, application, distance, simplify, !geometryInAttributes);
143     } else if (null != page && page > 0) {
144       featuresResponse = getAllFeatures(
145           tmft,
146           application,
147           appLayerSettings,
148           page,
149           filter,
150           sortBy,
151           sortOrder,
152           onlyGeometries,
153           !geometryInAttributes);
154     } else {
155       throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Unsupported combination of request parameters");
156     }
157 
158     return ResponseEntity.status(HttpStatus.OK).body(featuresResponse);
159   }
160 
161   @NotNull private FeaturesResponse getAllFeatures(
162       @NotNull TMFeatureType tmft,
163       @NotNull Application application,
164       @NotNull AppLayerSettings appLayerSettings,
165       Integer page,
166       String filterCQL,
167       String sortBy,
168       String sortOrder,
169       boolean onlyGeometries,
170       boolean skipGeometryOutput) {
171     FeaturesResponse featuresResponse = new FeaturesResponse().page(page).pageSize(pageSize);
172 
173     SimpleFeatureSource fs = null;
174     try {
175       fs = featureSourceFactoryHelper.openGeoToolsFeatureSource(tmft);
176 
177       // TODO evaluate; do we want geometry in this response or not?
178       //  if we do the geometry attribute must not be removed from propNames
179 
180       // Property names for query: only non-geometry attributes that aren't hidden
181       List<String> propNames = getConfiguredAttributes(tmft, appLayerSettings).values().stream()
182           .map(Pair::getLeft)
183           .filter(a -> !isGeometry(a.getType()))
184           .map(TMAttributeDescriptor::getName)
185           .collect(Collectors.toList());
186 
187       String sortAttrName;
188       if (onlyGeometries) {
189         propNames = List.of(tmft.getDefaultGeometryAttribute());
190         // do not try to sort by geometry
191         sortAttrName = null;
192       } else {
193         if (propNames.isEmpty()) {
194           return featuresResponse;
195         }
196         // Default sorting attribute if sortBy not specified or not a configured attribute
197         if (tmft.getPrimaryKeyAttribute() != null && propNames.contains(tmft.getPrimaryKeyAttribute())) {
198           // There is a primary key and it is known, use that for sorting
199           sortAttrName = tmft.getPrimaryKeyAttribute();
200         } else {
201           sortAttrName = propNames.get(0);
202         }
203 
204         if (null != sortBy) {
205           // Only use sortBy attribute if it is in the list of configured attributes and not a
206           // geometry type (propNames does not contain geometry attributes, see above)
207           if (propNames.contains(sortBy)) {
208             sortAttrName = sortBy;
209           } else {
210             logger.warn(
211                 "Requested sortBy attribute {} was not found in configured attributes or is a geometry attribute",
212                 sortBy);
213           }
214         }
215       }
216 
217       SortOrder _sortOrder = SortOrder.ASCENDING;
218       if (null != sortOrder && (sortOrder.equalsIgnoreCase("desc") || sortOrder.equalsIgnoreCase("asc"))) {
219         _sortOrder = SortOrder.valueOf(sortOrder.toUpperCase(Locale.ROOT));
220       }
221 
222       // setup query, attributes and filter
223       Query q = new Query(fs.getName().toString());
224       q.setPropertyNames(propNames);
225 
226       // count can be -1 if too costly eg. some WFS
227       int featureCount;
228       if (null != filterCQL) {
229         Filter filter = ECQL.toFilter(filterCQL);
230         q.setFilter(filter);
231         featureCount = fs.getCount(q);
232         // this will execute the query twice, once to get the count and once to get the data
233         if (featureCount == -1 && exactWfsCounts) {
234           featureCount = fs.getFeatures(q).size();
235         }
236       } else {
237         featureCount = fs.getCount(Query.ALL);
238         // this will execute the query twice, once to get the count and once to get the data
239         if (featureCount == -1 && exactWfsCounts) {
240           featureCount = fs.getFeatures(Query.ALL).size();
241         }
242       }
243       featuresResponse.setTotal(featureCount);
244 
245       // setup page query
246       if (sortAttrName != null) {
247         q.setSortBy(ff.sort(sortAttrName, _sortOrder));
248       }
249       q.setMaxFeatures(pageSize);
250       q.setStartIndex((page - 1) * pageSize);
251       logger.debug("Attribute query: {}", q);
252 
253       executeQueryOnFeatureSourceAndClose(
254           false,
255           featuresResponse,
256           tmft,
257           appLayerSettings,
258           onlyGeometries,
259           fs,
260           q,
261           application,
262           skipGeometryOutput);
263     } catch (IOException e) {
264       logger.error("Could not retrieve attribute data.", e);
265     } catch (CQLException e) {
266       logger.error("Could not parse requested filter.", e);
267       throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Could not parse requested filter");
268     } finally {
269       if (fs != null) {
270         fs.getDataStore().dispose();
271       }
272     }
273 
274     return featuresResponse;
275   }
276 
277   @NotNull private FeaturesResponse getFeatureByFID(
278       @NotNull TMFeatureType tmFeatureType,
279       @NotNull AppLayerSettings appLayerSettings,
280       @NotNull String fid,
281       @NotNull Application application,
282       boolean skipGeometryOutput) {
283     FeaturesResponse featuresResponse = new FeaturesResponse();
284 
285     SimpleFeatureSource fs = null;
286     try {
287       fs = featureSourceFactoryHelper.openGeoToolsFeatureSource(tmFeatureType);
288       Query q = new Query(fs.getName().toString());
289       q.setFilter(ff.id(ff.featureId(fid)));
290       q.setMaxFeatures(1);
291       logger.debug("FID query: {}", q);
292 
293       executeQueryOnFeatureSourceAndClose(
294           false,
295           featuresResponse,
296           tmFeatureType,
297           appLayerSettings,
298           false,
299           fs,
300           q,
301           application,
302           skipGeometryOutput);
303     } catch (IOException e) {
304       logger.error("Could not retrieve attribute data", e);
305     } finally {
306       if (fs != null) {
307         fs.getDataStore().dispose();
308       }
309     }
310 
311     return featuresResponse;
312   }
313 
314   @NotNull private FeaturesResponse getFeaturesByXY(
315       @NotNull TMFeatureType tmFeatureType,
316       @NotNull AppLayerSettings appLayerSettings,
317       String filterCQL,
318       @NotNull Double x,
319       @NotNull Double y,
320       @NotNull Application application,
321       @NotNull Double distance,
322       @NotNull Boolean simplifyGeometry,
323       boolean skipGeometryOutput) {
324 
325     if (null != distance && 0d >= distance) {
326       throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Buffer distance must be greater than 0");
327     }
328 
329     FeaturesResponse featuresResponse = new FeaturesResponse();
330 
331     SimpleFeatureSource fs;
332     try {
333       GeometricShapeFactory shapeFact = new GeometricShapeFactory();
334       shapeFact.setNumPoints(32);
335       shapeFact.setCentre(new Coordinate(x, y));
336       //noinspection ConstantConditions
337       shapeFact.setSize(distance * 2d);
338       Geometry p = shapeFact.createCircle();
339       logger.debug("created geometry: {}", p);
340 
341       MathTransform transform = null;
342       fs = featureSourceFactoryHelper.openGeoToolsFeatureSource(tmFeatureType);
343       try {
344         transform = TransformationUtil.getTransformationToDataSource(application, fs);
345       } catch (FactoryException e) {
346         logger.warn("Unable to find transformation from query geometry to desired datasource", e);
347       }
348       if (null != transform) {
349         try {
350           p = JTS.transform(p, transform);
351           logger.debug("reprojected geometry to: {}", p);
352         } catch (TransformException e) {
353           logger.warn("Unable to transform query geometry to desired CRS, trying with original CRS");
354         }
355       }
356       logger.debug("using selection geometry: {}", p);
357       Filter spatialFilter =
358           ff.intersects(ff.property(tmFeatureType.getDefaultGeometryAttribute()), ff.literal(p));
359 
360       Filter finalFilter = spatialFilter;
361       if (null != filterCQL) {
362         Filter filter = ECQL.toFilter(filterCQL);
363         finalFilter = ff.and(spatialFilter, filter);
364       }
365       Query q = new Query(fs.getName().toString());
366       q.setFilter(finalFilter);
367       q.setMaxFeatures(maxFeatures);
368 
369       executeQueryOnFeatureSourceAndClose(
370           simplifyGeometry,
371           featuresResponse,
372           tmFeatureType,
373           appLayerSettings,
374           false,
375           fs,
376           q,
377           application,
378           skipGeometryOutput);
379     } catch (IOException e) {
380       logger.error("Could not retrieve attribute data", e);
381     } catch (CQLException e) {
382       logger.error("Could not parse requested filter.", e);
383       throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Could not parse requested filter");
384     }
385     return featuresResponse;
386   }
387 
388   private void executeQueryOnFeatureSourceAndClose(
389       boolean simplifyGeometry,
390       @NotNull FeaturesResponse featuresResponse,
391       @NotNull TMFeatureType tmFeatureType,
392       @NotNull AppLayerSettings appLayerSettings,
393       boolean onlyGeometries,
394       @NotNull SimpleFeatureSource featureSource,
395       @NotNull Query selectQuery,
396       @NotNull Application application,
397       boolean skipGeometryOutput)
398       throws IOException {
399     boolean addFields = false;
400 
401     MathTransform transform = null;
402     try {
403       transform = TransformationUtil.getTransformationToApplication(application, featureSource);
404     } catch (FactoryException e) {
405       logger.error("Can not transform geometry to desired CRS", e);
406     }
407 
408     Map<String, Pair<TMAttributeDescriptor, AttributeSettings>> configuredAttributes =
409         getConfiguredAttributes(tmFeatureType, appLayerSettings);
410 
411     // send request to attribute source
412     try (SimpleFeatureIterator feats =
413         featureSource.getFeatures(selectQuery).features()) {
414       while (feats.hasNext()) {
415         addFields = true;
416         // transform found simplefeatures to list of Feature
417         SimpleFeature feature = feats.next();
418         // processedGeometry can be null
419         String processedGeometry = GeometryProcessor.processGeometry(
420             feature.getAttribute(tmFeatureType.getDefaultGeometryAttribute()),
421             simplifyGeometry,
422             true,
423             transform);
424         Feature newFeat =
425             new Feature().fid(feature.getIdentifier().getID()).geometry(processedGeometry);
426 
427         if (!onlyGeometries) {
428           for (String attName : configuredAttributes.keySet()) {
429             Object value = feature.getAttribute(attName);
430             if (value instanceof Geometry) {
431               if (skipGeometryOutput) {
432                 value = null;
433               } else {
434                 value = GeometryProcessor.geometryToWKT((Geometry) value);
435               }
436             }
437             newFeat.putAttributesItem(attName, value);
438           }
439         }
440         featuresResponse.addFeaturesItem(newFeat);
441       }
442     } finally {
443       featureSource.getDataStore().dispose();
444     }
445     FeatureTypeTemplate ftt = tmFeatureType.getSettings().getTemplate();
446     if (ftt != null) {
447       featuresResponse.setTemplate(ftt.getTemplate());
448     }
449     if (addFields) {
450       configuredAttributes.values().stream()
451           .map(pair -> {
452             TMAttributeDescriptor attributeDescriptor = pair.getLeft();
453             TMAttributeType type = attributeDescriptor.getType();
454             AttributeSettings settings = pair.getRight();
455             return new ColumnMetadata()
456                 .key(attributeDescriptor.getName())
457                 .alias(settings.getTitle())
458                 .type(isGeometry(type) ? TMAttributeType.GEOMETRY : type);
459           })
460           .forEach(featuresResponse::addColumnMetadataItem);
461     }
462   }
463 }