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