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.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.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           onlyGeometries,
174           !geometryInAttributes,
175           withAttachments);
176     } else {
177       throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Unsupported combination of request parameters");
178     }
179 
180     return ResponseEntity.status(HttpStatus.OK).body(featuresResponse);
181   }
182 
183   @NotNull private FeaturesResponse getAllFeatures(
184       @NotNull TMFeatureType tmft,
185       @NotNull Application application,
186       @NotNull AppLayerSettings appLayerSettings,
187       Integer page,
188       Integer pageSize,
189       String filterCQL,
190       String sortBy,
191       String sortOrder,
192       boolean onlyGeometries,
193       boolean skipGeometryOutput,
194       boolean withAttachments) {
195     int requestedPageSize = pageSize != null ? pageSize : defaultPageSize;
196     requestedPageSize = Math.max(1, requestedPageSize);
197     int requestPageSize = Math.min(maxPageSize, requestedPageSize);
198     FeaturesResponse featuresResponse = new FeaturesResponse().page(page).pageSize(requestPageSize);
199 
200     SimpleFeatureSource fs = null;
201     try {
202       fs = featureSourceFactoryHelper.openGeoToolsFeatureSource(tmft);
203 
204       // TODO evaluate; do we want geometry in this response or not?
205       //  if we do the geometry attribute must not be removed from propNames
206 
207       // Property names for query: only non-geometry attributes that aren't hidden
208       List<String> propNames = getConfiguredAttributes(tmft, appLayerSettings).values().stream()
209           .map(TMFeatureTypeHelper.AttributeWithSettings::attributeDescriptor)
210           .filter(a -> !isGeometry(a.getType()))
211           .map(TMAttributeDescriptor::getName)
212           .collect(Collectors.toList());
213 
214       String sortAttrName;
215       if (onlyGeometries) {
216         propNames = List.of(tmft.getDefaultGeometryAttribute());
217         // do not try to sort by geometry
218         sortAttrName = null;
219       } else {
220         if (propNames.isEmpty()) {
221           return featuresResponse;
222         }
223         // Default sorting attribute if sortBy not specified or not a configured attribute
224         if (tmft.getPrimaryKeyAttribute() != null && propNames.contains(tmft.getPrimaryKeyAttribute())) {
225           // There is a primary key and it is known, use that for sorting
226           sortAttrName = tmft.getPrimaryKeyAttribute();
227         } else {
228           sortAttrName = propNames.getFirst();
229         }
230 
231         if (null != sortBy) {
232           // Only use sortBy attribute if it is in the list of configured attributes and not a
233           // geometry type (propNames does not contain geometry attributes, see above)
234           if (propNames.contains(sortBy)) {
235             sortAttrName = sortBy;
236           } else {
237             logger.warn(
238                 "Requested sortBy attribute {} was not found in configured attributes or is a geometry attribute",
239                 sortBy);
240           }
241         }
242       }
243 
244       SortOrder _sortOrder = SortOrder.ASCENDING;
245       if (null != sortOrder && (sortOrder.equalsIgnoreCase("desc") || sortOrder.equalsIgnoreCase("asc"))) {
246         _sortOrder = SortOrder.valueOf(sortOrder.toUpperCase(Locale.ROOT));
247       }
248 
249       // setup query, attributes and filter
250       Query q = new Query(fs.getName().toString());
251       q.setPropertyNames(propNames);
252 
253       // count can be -1 if too costly eg. some WFS
254       int featureCount;
255       if (null != filterCQL) {
256         Filter filter = ECQL.toFilter(filterCQL);
257         q.setFilter(filter);
258         featureCount = fs.getCount(q);
259         // this will execute the query twice, once to get the count and once to get the data
260         if (featureCount == -1 && exactWfsCounts) {
261           featureCount = fs.getFeatures(q).size();
262         }
263       } else {
264         featureCount = fs.getCount(Query.ALL);
265         // this will execute the query twice, once to get the count and once to get the data
266         if (featureCount == -1 && exactWfsCounts) {
267           featureCount = fs.getFeatures(Query.ALL).size();
268         }
269       }
270       featuresResponse.setTotal(featureCount);
271 
272       // setup page query
273       if (sortAttrName != null) {
274         q.setSortBy(ff.sort(sortAttrName, _sortOrder));
275       }
276       q.setMaxFeatures(requestPageSize);
277       q.setStartIndex((page - 1) * requestPageSize);
278       logger.debug("Attribute query: {}", q);
279 
280       executeQueryOnFeatureSourceAndClose(
281           false,
282           featuresResponse,
283           tmft,
284           appLayerSettings,
285           onlyGeometries,
286           fs,
287           q,
288           application,
289           skipGeometryOutput,
290           withAttachments);
291     } catch (IOException e) {
292       logger.error("Could not retrieve attribute data.", e);
293     } catch (CQLException e) {
294       logger.error("Could not parse requested filter.", e);
295       throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Could not parse requested filter");
296     } finally {
297       if (fs != null) {
298         fs.getDataStore().dispose();
299       }
300     }
301 
302     return featuresResponse;
303   }
304 
305   @NotNull private FeaturesResponse getFeatureByFID(
306       @NotNull TMFeatureType tmFeatureType,
307       @NotNull AppLayerSettings appLayerSettings,
308       @NotNull String fid,
309       @NotNull Application application,
310       boolean skipGeometryOutput,
311       boolean withAttachments) {
312     FeaturesResponse featuresResponse = new FeaturesResponse();
313 
314     SimpleFeatureSource fs = null;
315     try {
316       fs = featureSourceFactoryHelper.openGeoToolsFeatureSource(tmFeatureType);
317       Query q = new Query(fs.getName().toString());
318       q.setFilter(ff.id(ff.featureId(fid)));
319       q.setMaxFeatures(1);
320       logger.debug("FID query: {}", q);
321 
322       executeQueryOnFeatureSourceAndClose(
323           false,
324           featuresResponse,
325           tmFeatureType,
326           appLayerSettings,
327           false,
328           fs,
329           q,
330           application,
331           skipGeometryOutput,
332           withAttachments);
333     } catch (IOException e) {
334       logger.error("Could not retrieve attribute data", e);
335     } finally {
336       if (fs != null) {
337         fs.getDataStore().dispose();
338       }
339     }
340 
341     return featuresResponse;
342   }
343 
344   @NotNull private FeaturesResponse getFeaturesByXY(
345       @NotNull TMFeatureType tmFeatureType,
346       @NotNull AppLayerSettings appLayerSettings,
347       String filterCQL,
348       @NotNull Double x,
349       @NotNull Double y,
350       @NotNull Application application,
351       @NotNull Double distance,
352       @NotNull Boolean simplifyGeometry,
353       boolean skipGeometryOutput,
354       boolean withAttachments) {
355 
356     if (null != distance && 0d >= distance) {
357       throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Buffer distance must be greater than 0");
358     }
359 
360     FeaturesResponse featuresResponse = new FeaturesResponse();
361 
362     SimpleFeatureSource fs;
363     try {
364       GeometricShapeFactory shapeFact = new GeometricShapeFactory();
365       shapeFact.setNumPoints(32);
366       shapeFact.setCentre(new Coordinate(x, y));
367       //noinspection ConstantConditions
368       shapeFact.setSize(distance * 2d);
369       Geometry p = shapeFact.createCircle();
370       logger.trace("created selection geometry: {}", p);
371 
372       MathTransform transform = null;
373       fs = featureSourceFactoryHelper.openGeoToolsFeatureSource(tmFeatureType);
374       try {
375         transform = TransformationUtil.getTransformationToDataSource(application, fs);
376       } catch (FactoryException e) {
377         logger.warn("Unable to find transformation from query geometry to desired datasource", e);
378       }
379       if (null != transform) {
380         try {
381           p = JTS.transform(p, transform);
382           logger.trace("reprojected selection geometry to: {}", p);
383         } catch (TransformException e) {
384           logger.warn("Unable to transform query geometry to desired CRS, trying with original CRS");
385         }
386       }
387       logger.trace("using selection geometry: {}", p);
388       Filter spatialFilter =
389           ff.intersects(ff.property(tmFeatureType.getDefaultGeometryAttribute()), ff.literal(p));
390 
391       Filter finalFilter = spatialFilter;
392       if (null != filterCQL) {
393         Filter filter = ECQL.toFilter(filterCQL);
394         finalFilter = ff.and(spatialFilter, filter);
395       }
396       Query q = new Query(fs.getName().toString());
397       q.setFilter(finalFilter);
398       q.setMaxFeatures(maxFeatures);
399 
400       executeQueryOnFeatureSourceAndClose(
401           simplifyGeometry,
402           featuresResponse,
403           tmFeatureType,
404           appLayerSettings,
405           false,
406           fs,
407           q,
408           application,
409           skipGeometryOutput,
410           withAttachments);
411     } catch (IOException e) {
412       logger.error("Could not retrieve attribute data", e);
413     } catch (CQLException e) {
414       logger.error("Could not parse requested filter.", e);
415       throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Could not parse requested filter");
416     }
417     return featuresResponse;
418   }
419 
420   private void executeQueryOnFeatureSourceAndClose(
421       boolean simplifyGeometry,
422       @NotNull FeaturesResponse featuresResponse,
423       @NotNull TMFeatureType tmFeatureType,
424       @NotNull AppLayerSettings appLayerSettings,
425       boolean onlyGeometries,
426       @NotNull SimpleFeatureSource featureSource,
427       @NotNull Query selectQuery,
428       @NotNull Application application,
429       boolean skipGeometryOutput,
430       boolean withAttachments)
431       throws IOException {
432     boolean addFields = false;
433 
434     MathTransform transform = null;
435     try {
436       transform = TransformationUtil.getTransformationToApplication(application, featureSource);
437     } catch (FactoryException e) {
438       logger.error("Can not transform geometry to desired CRS", e);
439     }
440 
441     boolean ftSupportsAttachments = tmFeatureType.getSettings().getAttachmentAttributes() != null
442         && !tmFeatureType.getSettings().getAttachmentAttributes().isEmpty();
443 
444     List<Object> featurePKs = new ArrayList<>();
445     Map<String, TMFeatureTypeHelper.AttributeWithSettings> configuredAttributes =
446         getConfiguredAttributes(tmFeatureType, appLayerSettings);
447 
448     // send request to attribute source
449     try (SimpleFeatureIterator feats =
450         featureSource.getFeatures(selectQuery).features()) {
451       while (feats.hasNext()) {
452         addFields = true;
453         // transform found simplefeatures to list of Feature
454         SimpleFeature feature = feats.next();
455 
456         // processedGeometry can be null
457         String processedGeometry = GeometryProcessor.processGeometry(
458             feature.getAttribute(tmFeatureType.getDefaultGeometryAttribute()),
459             simplifyGeometry,
460             true,
461             transform);
462         Feature newFeat = new Feature().fid(feature.getID()).geometry(processedGeometry);
463 
464         if (!onlyGeometries) {
465           for (String attName : configuredAttributes.keySet()) {
466             Object value = feature.getAttribute(attName);
467             if (value instanceof Geometry geometry) {
468               if (skipGeometryOutput) {
469                 value = null;
470               } else {
471                 value = GeometryProcessor.geometryToWKT(geometry);
472               }
473             }
474             newFeat.putAttributesItem(attName, value);
475           }
476           if (withAttachments && ftSupportsAttachments) {
477             // Just add the PK as is, no conversion needed
478             featurePKs.add(feature.getAttribute(tmFeatureType.getPrimaryKeyAttribute()));
479           }
480         }
481         featuresResponse.addFeaturesItem(newFeat);
482       }
483     } finally {
484       featureSource.getDataStore().dispose();
485     }
486     FeatureTypeTemplate ftt = tmFeatureType.getSettings().getTemplate();
487     if (ftt != null) {
488       featuresResponse.setTemplate(ftt.getTemplate());
489     }
490     if (addFields) {
491       configuredAttributes.values().stream()
492           .map(attributeWithSettings -> {
493             TMAttributeType type =
494                 attributeWithSettings.attributeDescriptor().getType();
495             return new ColumnMetadata()
496                 .name(attributeWithSettings
497                     .attributeDescriptor()
498                     .getName())
499                 .alias(attributeWithSettings.settings().getTitle())
500                 .type(isGeometry(type) ? TMAttributeType.GEOMETRY : type);
501           })
502           .forEach(featuresResponse::addColumnMetadataItem);
503     }
504     if (ftSupportsAttachments) {
505       //  add attachment metadata
506       featuresResponse.setAttachmentMetadata(
507           featureTypeHelper.getAttachmentAttributesWithMaxFileUploadSize(tmFeatureType));
508 
509       if (withAttachments) {
510         //  fetch all attachments for all features, grouped by feature fid
511         Map<String, List<AttachmentMetadata>> attachmentsByFeatureId =
512             AttachmentsHelper.listAttachmentsForFeaturesByFeatureId(tmFeatureType, featurePKs);
513         //  add attachment data to features using the feature FID to match
514         for (Feature feature : featuresResponse.getFeatures()) {
515           String primaryKey = feature.getFid();
516           List<AttachmentMetadata> attachments = attachmentsByFeatureId.get(primaryKey);
517           if (attachments != null) {
518             feature.setAttachments(attachments);
519           }
520         }
521       }
522     }
523   }
524 }