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,
98        FeatureSourceRepository featureSourceRepository) {
99      this.featureSourceFactoryHelper = featureSourceFactoryHelper;
100     this.featureSourceRepository = featureSourceRepository;
101   }
102 
103   @Transactional
104   @RequestMapping(method = {GET, POST})
105   @Timed(value = "get_features", description = "time spent to process get features call")
106   public ResponseEntity<Serializable> getFeatures(
107       @ModelAttribute AppTreeLayerNode appTreeLayerNode,
108       @ModelAttribute GeoService service,
109       @ModelAttribute GeoServiceLayer layer,
110       @ModelAttribute Application application,
111       @RequestParam(required = false) Double x,
112       @RequestParam(required = false) Double y,
113       @RequestParam(defaultValue = "4") Double distance,
114       @RequestParam(required = false) String __fid,
115       @RequestParam(defaultValue = "false") Boolean simplify,
116       @RequestParam(required = false) String filter,
117       @RequestParam(required = false) Integer page,
118       @RequestParam(required = false) String sortBy,
119       @RequestParam(required = false, defaultValue = "asc") String sortOrder,
120       @RequestParam(defaultValue = "false") boolean onlyGeometries,
121       @RequestParam(defaultValue = "false") boolean geometryInAttributes) {
122 
123     if (layer == null) {
124       throw new ResponseStatusException(
125           HttpStatus.NOT_FOUND, "Can't find layer " + appTreeLayerNode);
126     }
127 
128     TMFeatureType tmft = service.findFeatureTypeForLayer(layer, featureSourceRepository);
129     if (tmft == null) {
130       throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Layer does not have feature type");
131     }
132     AppLayerSettings appLayerSettings = application.getAppLayerSettings(appTreeLayerNode);
133 
134     if (onlyGeometries) {
135       geometryInAttributes = true;
136     }
137 
138     FeaturesResponse featuresResponse;
139 
140     if (null != __fid) {
141       featuresResponse =
142           getFeatureByFID(tmft, appLayerSettings, __fid, application, !geometryInAttributes);
143     } else if (null != x && null != y) {
144       featuresResponse =
145           getFeaturesByXY(
146               tmft,
147               appLayerSettings,
148               filter,
149               x,
150               y,
151               application,
152               distance,
153               simplify,
154               !geometryInAttributes);
155     } else if (null != page && page > 0) {
156       featuresResponse =
157           getAllFeatures(
158               tmft,
159               application,
160               appLayerSettings,
161               page,
162               filter,
163               sortBy,
164               sortOrder,
165               onlyGeometries,
166               !geometryInAttributes);
167     } else {
168       throw new ResponseStatusException(
169           HttpStatus.BAD_REQUEST, "Unsupported combination of request parameters");
170     }
171 
172     return ResponseEntity.status(HttpStatus.OK).body(featuresResponse);
173   }
174 
175   @NotNull
176   private FeaturesResponse getAllFeatures(
177       @NotNull TMFeatureType tmft,
178       @NotNull Application application,
179       @NotNull AppLayerSettings appLayerSettings,
180       Integer page,
181       String filterCQL,
182       String sortBy,
183       String sortOrder,
184       boolean onlyGeometries,
185       boolean skipGeometryOutput) {
186     FeaturesResponse featuresResponse = new FeaturesResponse().page(page).pageSize(pageSize);
187 
188     SimpleFeatureSource fs = null;
189     try {
190       fs = featureSourceFactoryHelper.openGeoToolsFeatureSource(tmft);
191 
192       // TODO evaluate; do we want geometry in this response or not?
193       //  if we do the geometry attribute must not be removed from propNames
194 
195       // Property names for query: only non-geometry attributes that aren't hidden
196       List<String> propNames =
197           getConfiguredAttributes(tmft, appLayerSettings).values().stream()
198               .map(Pair::getLeft)
199               .filter(a -> !isGeometry(a.getType()))
200               .map(TMAttributeDescriptor::getName)
201               .collect(Collectors.toList());
202 
203       String sortAttrName;
204       if (onlyGeometries) {
205         propNames = List.of(tmft.getDefaultGeometryAttribute());
206         // do not try to sort by geometry
207         sortAttrName = null;
208       } else {
209         if (propNames.isEmpty()) {
210           return featuresResponse;
211         }
212         // Default sorting attribute if sortBy not specified or not a configured attribute
213         if (tmft.getPrimaryKeyAttribute() != null
214             && propNames.contains(tmft.getPrimaryKeyAttribute())) {
215           // There is a primary key and it is known, use that for sorting
216           sortAttrName = tmft.getPrimaryKeyAttribute();
217         } else {
218           sortAttrName = propNames.get(0);
219         }
220 
221         if (null != sortBy) {
222           // Only use sortBy attribute if it is in the list of configured attributes and not a
223           // geometry type (propNames does not contain geometry attributes, see above)
224           if (propNames.contains(sortBy)) {
225             sortAttrName = sortBy;
226           } else {
227             logger.warn(
228                 "Requested sortBy attribute {} was not found in configured attributes or is a geometry attribute",
229                 sortBy);
230           }
231         }
232       }
233 
234       SortOrder _sortOrder = SortOrder.ASCENDING;
235       if (null != sortOrder
236           && (sortOrder.equalsIgnoreCase("desc") || sortOrder.equalsIgnoreCase("asc"))) {
237         _sortOrder = SortOrder.valueOf(sortOrder.toUpperCase(Locale.ROOT));
238       }
239 
240       // setup query, attributes and filter
241       Query q = new Query(fs.getName().toString());
242       q.setPropertyNames(propNames);
243 
244       // count can be -1 if too costly eg. some WFS
245       int featureCount;
246       if (null != filterCQL) {
247         Filter filter = ECQL.toFilter(filterCQL);
248         q.setFilter(filter);
249         featureCount = fs.getCount(q);
250         // this will execute the query twice, once to get the count and once to get the data
251         if (featureCount == -1 && exactWfsCounts) {
252           featureCount = fs.getFeatures(q).size();
253         }
254       } else {
255         featureCount = fs.getCount(Query.ALL);
256         // this will execute the query twice, once to get the count and once to get the data
257         if (featureCount == -1 && exactWfsCounts) {
258           featureCount = fs.getFeatures(Query.ALL).size();
259         }
260       }
261       featuresResponse.setTotal(featureCount);
262 
263       // setup page query
264       if (sortAttrName != null) {
265         q.setSortBy(ff.sort(sortAttrName, _sortOrder));
266       }
267       q.setMaxFeatures(pageSize);
268       q.setStartIndex((page - 1) * pageSize);
269       logger.debug("Attribute query: {}", q);
270 
271       executeQueryOnFeatureSourceAndClose(
272           false,
273           featuresResponse,
274           tmft,
275           appLayerSettings,
276           onlyGeometries,
277           fs,
278           q,
279           application,
280           skipGeometryOutput);
281     } catch (IOException e) {
282       logger.error("Could not retrieve attribute data.", e);
283     } catch (CQLException e) {
284       logger.error("Could not parse requested filter.", e);
285       throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Could not parse requested filter");
286     } finally {
287       if (fs != null) {
288         fs.getDataStore().dispose();
289       }
290     }
291 
292     return featuresResponse;
293   }
294 
295   @NotNull
296   private FeaturesResponse getFeatureByFID(
297       @NotNull TMFeatureType tmFeatureType,
298       @NotNull AppLayerSettings appLayerSettings,
299       @NotNull String fid,
300       @NotNull Application application,
301       boolean skipGeometryOutput) {
302     FeaturesResponse featuresResponse = new FeaturesResponse();
303 
304     SimpleFeatureSource fs = null;
305     try {
306       fs = featureSourceFactoryHelper.openGeoToolsFeatureSource(tmFeatureType);
307       Query q = new Query(fs.getName().toString());
308       q.setFilter(ff.id(ff.featureId(fid)));
309       q.setMaxFeatures(1);
310       logger.debug("FID query: {}", q);
311 
312       executeQueryOnFeatureSourceAndClose(
313           false,
314           featuresResponse,
315           tmFeatureType,
316           appLayerSettings,
317           false,
318           fs,
319           q,
320           application,
321           skipGeometryOutput);
322     } catch (IOException e) {
323       logger.error("Could not retrieve attribute data", e);
324     } finally {
325       if (fs != null) {
326         fs.getDataStore().dispose();
327       }
328     }
329 
330     return featuresResponse;
331   }
332 
333   @NotNull
334   private FeaturesResponse getFeaturesByXY(
335       @NotNull TMFeatureType tmFeatureType,
336       @NotNull AppLayerSettings appLayerSettings,
337       String filterCQL,
338       @NotNull Double x,
339       @NotNull Double y,
340       @NotNull Application application,
341       @NotNull Double distance,
342       @NotNull Boolean simplifyGeometry,
343       boolean skipGeometryOutput) {
344 
345     if (null != distance && 0d >= distance) {
346       throw new ResponseStatusException(
347           HttpStatus.BAD_REQUEST, "Buffer distance must be greater than 0");
348     }
349 
350     FeaturesResponse featuresResponse = new FeaturesResponse();
351 
352     SimpleFeatureSource fs;
353     try {
354       GeometricShapeFactory shapeFact = new GeometricShapeFactory();
355       shapeFact.setNumPoints(32);
356       shapeFact.setCentre(new Coordinate(x, y));
357       //noinspection ConstantConditions
358       shapeFact.setSize(distance * 2d);
359       Geometry p = shapeFact.createCircle();
360       logger.debug("created geometry: {}", p);
361 
362       MathTransform transform = null;
363       fs = featureSourceFactoryHelper.openGeoToolsFeatureSource(tmFeatureType);
364       try {
365         transform = TransformationUtil.getTransformationToDataSource(application, fs);
366       } catch (FactoryException e) {
367         logger.warn("Unable to find transformation from query geometry to desired datasource", e);
368       }
369       if (null != transform) {
370         try {
371           p = JTS.transform(p, transform);
372           logger.debug("reprojected geometry to: {}", p);
373         } catch (TransformException e) {
374           logger.warn(
375               "Unable to transform query geometry to desired CRS, trying with original CRS");
376         }
377       }
378       logger.debug("using selection geometry: {}", p);
379       Filter spatialFilter =
380           ff.intersects(ff.property(tmFeatureType.getDefaultGeometryAttribute()), ff.literal(p));
381 
382       Filter finalFilter = spatialFilter;
383       if (null != filterCQL) {
384         Filter filter = ECQL.toFilter(filterCQL);
385         finalFilter = ff.and(spatialFilter, filter);
386       }
387       Query q = new Query(fs.getName().toString());
388       q.setFilter(finalFilter);
389       q.setMaxFeatures(maxFeatures);
390 
391       executeQueryOnFeatureSourceAndClose(
392           simplifyGeometry,
393           featuresResponse,
394           tmFeatureType,
395           appLayerSettings,
396           false,
397           fs,
398           q,
399           application,
400           skipGeometryOutput);
401     } catch (IOException e) {
402       logger.error("Could not retrieve attribute data", e);
403     } catch (CQLException e) {
404       logger.error("Could not parse requested filter.", e);
405       throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Could not parse requested filter");
406     }
407     return featuresResponse;
408   }
409 
410   private void executeQueryOnFeatureSourceAndClose(
411       boolean simplifyGeometry,
412       @NotNull FeaturesResponse featuresResponse,
413       @NotNull TMFeatureType tmFeatureType,
414       @NotNull AppLayerSettings appLayerSettings,
415       boolean onlyGeometries,
416       @NotNull SimpleFeatureSource featureSource,
417       @NotNull Query selectQuery,
418       @NotNull Application application,
419       boolean skipGeometryOutput)
420       throws IOException {
421     boolean addFields = false;
422 
423     MathTransform transform = null;
424     try {
425       transform = TransformationUtil.getTransformationToApplication(application, featureSource);
426     } catch (FactoryException e) {
427       logger.error("Can not transform geometry to desired CRS", e);
428     }
429 
430     Map<String, Pair<TMAttributeDescriptor, AttributeSettings>> configuredAttributes =
431         getConfiguredAttributes(tmFeatureType, appLayerSettings);
432 
433     // send request to attribute source
434     try (SimpleFeatureIterator feats = featureSource.getFeatures(selectQuery).features()) {
435       while (feats.hasNext()) {
436         addFields = true;
437         // transform found simplefeatures to list of Feature
438         SimpleFeature feature = feats.next();
439         // processedGeometry can be null
440         String processedGeometry =
441             GeometryProcessor.processGeometry(
442                 feature.getAttribute(tmFeatureType.getDefaultGeometryAttribute()),
443                 simplifyGeometry,
444                 true,
445                 transform);
446         Feature newFeat =
447             new Feature().fid(feature.getIdentifier().getID()).geometry(processedGeometry);
448 
449         if (!onlyGeometries) {
450           for (String attName : configuredAttributes.keySet()) {
451             Object value = feature.getAttribute(attName);
452             if (value instanceof Geometry) {
453               if (skipGeometryOutput) {
454                 value = null;
455               } else {
456                 value = GeometryProcessor.geometryToWKT((Geometry) value);
457               }
458             }
459             newFeat.putAttributesItem(attName, value);
460           }
461         }
462         featuresResponse.addFeaturesItem(newFeat);
463       }
464     } finally {
465       featureSource.getDataStore().dispose();
466     }
467     FeatureTypeTemplate ftt = tmFeatureType.getSettings().getTemplate();
468     if (ftt != null) {
469       featuresResponse.setTemplate(ftt.getTemplate());
470     }
471     if (addFields) {
472       configuredAttributes.values().stream()
473           .map(
474               pair -> {
475                 TMAttributeDescriptor attributeDescriptor = pair.getLeft();
476                 TMAttributeType type = attributeDescriptor.getType();
477                 AttributeSettings settings = pair.getRight();
478                 return new ColumnMetadata()
479                     .key(attributeDescriptor.getName())
480                     .alias(settings.getTitle())
481                     .type(isGeometry(type) ? TMAttributeType.GEOMETRY : type);
482               })
483           .forEach(featuresResponse::addColumnMetadataItem);
484     }
485   }
486 }