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.pageSize:100}")
90    private int pageSize;
91  
92    @Value("${tailormap-api.feature.info.maxitems:30}")
93    private int maxFeatures;
94  
95    @Value("${tailormap-api.features.wfs_count_exact:false}")
96    private boolean exactWfsCounts;
97  
98    public FeaturesController(
99        FeatureSourceFactoryHelper featureSourceFactoryHelper,
100       TMFeatureTypeHelper featureTypeHelper,
101       FeatureSourceRepository featureSourceRepository) {
102     this.featureSourceFactoryHelper = featureSourceFactoryHelper;
103     this.featureTypeHelper = featureTypeHelper;
104     this.featureSourceRepository = featureSourceRepository;
105   }
106 
107   @Transactional
108   @RequestMapping(method = {GET, POST})
109   @Timed(value = "get_features", description = "time spent to process get features call")
110   public ResponseEntity<Serializable> getFeatures(
111       @ModelAttribute AppTreeLayerNode appTreeLayerNode,
112       @ModelAttribute GeoService service,
113       @ModelAttribute GeoServiceLayer layer,
114       @ModelAttribute Application application,
115       @RequestParam(required = false) Double x,
116       @RequestParam(required = false) Double y,
117       @RequestParam(defaultValue = "4") Double distance,
118       @RequestParam(required = false) String __fid,
119       @RequestParam(defaultValue = "false") Boolean simplify,
120       @RequestParam(required = false) String filter,
121       @RequestParam(required = false) Integer page,
122       @RequestParam(required = false) String sortBy,
123       @RequestParam(required = false, defaultValue = "asc") String sortOrder,
124       @RequestParam(defaultValue = "false") boolean onlyGeometries,
125       @RequestParam(defaultValue = "false") boolean geometryInAttributes,
126       @RequestParam(defaultValue = "false") boolean withAttachments) {
127 
128     if (layer == null) {
129       throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Can't find layer " + appTreeLayerNode);
130     }
131 
132     TMFeatureType tmft = service.findFeatureTypeForLayer(layer, featureSourceRepository);
133     if (tmft == null) {
134       throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Layer does not have feature type");
135     }
136     AppLayerSettings appLayerSettings = application.getAppLayerSettings(appTreeLayerNode);
137 
138     if (onlyGeometries) {
139       geometryInAttributes = true;
140     }
141 
142     FeaturesResponse featuresResponse;
143 
144     if (null != __fid) {
145       featuresResponse =
146           getFeatureByFID(tmft, appLayerSettings, __fid, application, !geometryInAttributes, withAttachments);
147     } else if (null != x && null != y) {
148       featuresResponse = getFeaturesByXY(
149           tmft,
150           appLayerSettings,
151           filter,
152           x,
153           y,
154           application,
155           distance,
156           simplify,
157           !geometryInAttributes,
158           withAttachments);
159     } else if (null != page && page > 0) {
160       featuresResponse = getAllFeatures(
161           tmft,
162           application,
163           appLayerSettings,
164           page,
165           filter,
166           sortBy,
167           sortOrder,
168           onlyGeometries,
169           !geometryInAttributes,
170           withAttachments);
171     } else {
172       throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Unsupported combination of request parameters");
173     }
174 
175     return ResponseEntity.status(HttpStatus.OK).body(featuresResponse);
176   }
177 
178   @NotNull private FeaturesResponse getAllFeatures(
179       @NotNull TMFeatureType tmft,
180       @NotNull Application application,
181       @NotNull AppLayerSettings appLayerSettings,
182       Integer page,
183       String filterCQL,
184       String sortBy,
185       String sortOrder,
186       boolean onlyGeometries,
187       boolean skipGeometryOutput,
188       boolean withAttachments) {
189     FeaturesResponse featuresResponse = new FeaturesResponse().page(page).pageSize(pageSize);
190 
191     SimpleFeatureSource fs = null;
192     try {
193       fs = featureSourceFactoryHelper.openGeoToolsFeatureSource(tmft);
194 
195       // TODO evaluate; do we want geometry in this response or not?
196       //  if we do the geometry attribute must not be removed from propNames
197 
198       // Property names for query: only non-geometry attributes that aren't hidden
199       List<String> propNames = getConfiguredAttributes(tmft, appLayerSettings).values().stream()
200           .map(TMFeatureTypeHelper.AttributeWithSettings::attributeDescriptor)
201           .filter(a -> !isGeometry(a.getType()))
202           .map(TMAttributeDescriptor::getName)
203           .collect(Collectors.toList());
204 
205       String sortAttrName;
206       if (onlyGeometries) {
207         propNames = List.of(tmft.getDefaultGeometryAttribute());
208         // do not try to sort by geometry
209         sortAttrName = null;
210       } else {
211         if (propNames.isEmpty()) {
212           return featuresResponse;
213         }
214         // Default sorting attribute if sortBy not specified or not a configured attribute
215         if (tmft.getPrimaryKeyAttribute() != null && propNames.contains(tmft.getPrimaryKeyAttribute())) {
216           // There is a primary key and it is known, use that for sorting
217           sortAttrName = tmft.getPrimaryKeyAttribute();
218         } else {
219           sortAttrName = propNames.getFirst();
220         }
221 
222         if (null != sortBy) {
223           // Only use sortBy attribute if it is in the list of configured attributes and not a
224           // geometry type (propNames does not contain geometry attributes, see above)
225           if (propNames.contains(sortBy)) {
226             sortAttrName = sortBy;
227           } else {
228             logger.warn(
229                 "Requested sortBy attribute {} was not found in configured attributes or is a geometry attribute",
230                 sortBy);
231           }
232         }
233       }
234 
235       SortOrder _sortOrder = SortOrder.ASCENDING;
236       if (null != sortOrder && (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           withAttachments);
282     } catch (IOException e) {
283       logger.error("Could not retrieve attribute data.", e);
284     } catch (CQLException e) {
285       logger.error("Could not parse requested filter.", e);
286       throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Could not parse requested filter");
287     } finally {
288       if (fs != null) {
289         fs.getDataStore().dispose();
290       }
291     }
292 
293     return featuresResponse;
294   }
295 
296   @NotNull private FeaturesResponse getFeatureByFID(
297       @NotNull TMFeatureType tmFeatureType,
298       @NotNull AppLayerSettings appLayerSettings,
299       @NotNull String fid,
300       @NotNull Application application,
301       boolean skipGeometryOutput,
302       boolean withAttachments) {
303     FeaturesResponse featuresResponse = new FeaturesResponse();
304 
305     SimpleFeatureSource fs = null;
306     try {
307       fs = featureSourceFactoryHelper.openGeoToolsFeatureSource(tmFeatureType);
308       Query q = new Query(fs.getName().toString());
309       q.setFilter(ff.id(ff.featureId(fid)));
310       q.setMaxFeatures(1);
311       logger.debug("FID query: {}", q);
312 
313       executeQueryOnFeatureSourceAndClose(
314           false,
315           featuresResponse,
316           tmFeatureType,
317           appLayerSettings,
318           false,
319           fs,
320           q,
321           application,
322           skipGeometryOutput,
323           withAttachments);
324     } catch (IOException e) {
325       logger.error("Could not retrieve attribute data", e);
326     } finally {
327       if (fs != null) {
328         fs.getDataStore().dispose();
329       }
330     }
331 
332     return featuresResponse;
333   }
334 
335   @NotNull private FeaturesResponse getFeaturesByXY(
336       @NotNull TMFeatureType tmFeatureType,
337       @NotNull AppLayerSettings appLayerSettings,
338       String filterCQL,
339       @NotNull Double x,
340       @NotNull Double y,
341       @NotNull Application application,
342       @NotNull Double distance,
343       @NotNull Boolean simplifyGeometry,
344       boolean skipGeometryOutput,
345       boolean withAttachments) {
346 
347     if (null != distance && 0d >= distance) {
348       throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Buffer distance must be greater than 0");
349     }
350 
351     FeaturesResponse featuresResponse = new FeaturesResponse();
352 
353     SimpleFeatureSource fs;
354     try {
355       GeometricShapeFactory shapeFact = new GeometricShapeFactory();
356       shapeFact.setNumPoints(32);
357       shapeFact.setCentre(new Coordinate(x, y));
358       //noinspection ConstantConditions
359       shapeFact.setSize(distance * 2d);
360       Geometry p = shapeFact.createCircle();
361       logger.trace("created selection geometry: {}", p);
362 
363       MathTransform transform = null;
364       fs = featureSourceFactoryHelper.openGeoToolsFeatureSource(tmFeatureType);
365       try {
366         transform = TransformationUtil.getTransformationToDataSource(application, fs);
367       } catch (FactoryException e) {
368         logger.warn("Unable to find transformation from query geometry to desired datasource", e);
369       }
370       if (null != transform) {
371         try {
372           p = JTS.transform(p, transform);
373           logger.trace("reprojected selection geometry to: {}", p);
374         } catch (TransformException e) {
375           logger.warn("Unable to transform query geometry to desired CRS, trying with original CRS");
376         }
377       }
378       logger.trace("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           withAttachments);
402     } catch (IOException e) {
403       logger.error("Could not retrieve attribute data", e);
404     } catch (CQLException e) {
405       logger.error("Could not parse requested filter.", e);
406       throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Could not parse requested filter");
407     }
408     return featuresResponse;
409   }
410 
411   private void executeQueryOnFeatureSourceAndClose(
412       boolean simplifyGeometry,
413       @NotNull FeaturesResponse featuresResponse,
414       @NotNull TMFeatureType tmFeatureType,
415       @NotNull AppLayerSettings appLayerSettings,
416       boolean onlyGeometries,
417       @NotNull SimpleFeatureSource featureSource,
418       @NotNull Query selectQuery,
419       @NotNull Application application,
420       boolean skipGeometryOutput,
421       boolean withAttachments)
422       throws IOException {
423     boolean addFields = false;
424 
425     MathTransform transform = null;
426     try {
427       transform = TransformationUtil.getTransformationToApplication(application, featureSource);
428     } catch (FactoryException e) {
429       logger.error("Can not transform geometry to desired CRS", e);
430     }
431 
432     boolean ftSupportsAttachments = tmFeatureType.getSettings().getAttachmentAttributes() != null
433         && !tmFeatureType.getSettings().getAttachmentAttributes().isEmpty();
434 
435     List<Object> featurePKs = new ArrayList<>();
436     Map<String, TMFeatureTypeHelper.AttributeWithSettings> configuredAttributes =
437         getConfiguredAttributes(tmFeatureType, appLayerSettings);
438 
439     // send request to attribute source
440     try (SimpleFeatureIterator feats =
441         featureSource.getFeatures(selectQuery).features()) {
442       while (feats.hasNext()) {
443         addFields = true;
444         // transform found simplefeatures to list of Feature
445         SimpleFeature feature = feats.next();
446 
447         // processedGeometry can be null
448         String processedGeometry = GeometryProcessor.processGeometry(
449             feature.getAttribute(tmFeatureType.getDefaultGeometryAttribute()),
450             simplifyGeometry,
451             true,
452             transform);
453         Feature newFeat = new Feature().fid(feature.getID()).geometry(processedGeometry);
454 
455         if (!onlyGeometries) {
456           for (String attName : configuredAttributes.keySet()) {
457             Object value = feature.getAttribute(attName);
458             if (value instanceof Geometry geometry) {
459               if (skipGeometryOutput) {
460                 value = null;
461               } else {
462                 value = GeometryProcessor.geometryToWKT(geometry);
463               }
464             }
465             newFeat.putAttributesItem(attName, value);
466           }
467           if (withAttachments && ftSupportsAttachments) {
468             // Just add the PK as is, no conversion needed
469             featurePKs.add(feature.getAttribute(tmFeatureType.getPrimaryKeyAttribute()));
470           }
471         }
472         featuresResponse.addFeaturesItem(newFeat);
473       }
474     } finally {
475       featureSource.getDataStore().dispose();
476     }
477     FeatureTypeTemplate ftt = tmFeatureType.getSettings().getTemplate();
478     if (ftt != null) {
479       featuresResponse.setTemplate(ftt.getTemplate());
480     }
481     if (addFields) {
482       configuredAttributes.values().stream()
483           .map(attributeWithSettings -> {
484             TMAttributeType type =
485                 attributeWithSettings.attributeDescriptor().getType();
486             return new ColumnMetadata()
487                 .name(attributeWithSettings
488                     .attributeDescriptor()
489                     .getName())
490                 .alias(attributeWithSettings.settings().getTitle())
491                 .type(isGeometry(type) ? TMAttributeType.GEOMETRY : type);
492           })
493           .forEach(featuresResponse::addColumnMetadataItem);
494     }
495     if (ftSupportsAttachments) {
496       //  add attachment metadata
497       featuresResponse.setAttachmentMetadata(
498           featureTypeHelper.getAttachmentAttributesWithMaxFileUploadSize(tmFeatureType));
499 
500       if (withAttachments) {
501         //  fetch all attachments for all features, grouped by feature fid
502         Map<String, List<AttachmentMetadata>> attachmentsByFeatureId =
503             AttachmentsHelper.listAttachmentsForFeaturesByFeatureId(tmFeatureType, featurePKs);
504         //  add attachment data to features using the feature FID to match
505         for (Feature feature : featuresResponse.getFeatures()) {
506           String primaryKey = feature.getFid();
507           List<AttachmentMetadata> attachments = attachmentsByFeatureId.get(primaryKey);
508           if (attachments != null) {
509             feature.setAttachments(attachments);
510           }
511         }
512       }
513     }
514   }
515 }