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