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