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.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
196
197
198
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
209 sortAttrName = null;
210 } else {
211 if (propNames.isEmpty()) {
212 return featuresResponse;
213 }
214
215 if (tmft.getPrimaryKeyAttribute() != null && propNames.contains(tmft.getPrimaryKeyAttribute())) {
216
217 sortAttrName = tmft.getPrimaryKeyAttribute();
218 } else {
219 sortAttrName = propNames.getFirst();
220 }
221
222 if (null != sortBy) {
223
224
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
241 Query q = new Query(fs.getName().toString());
242 q.setPropertyNames(propNames);
243
244
245 int featureCount;
246 if (null != filterCQL) {
247 Filter filter = ECQL.toFilter(filterCQL);
248 q.setFilter(filter);
249 featureCount = fs.getCount(q);
250
251 if (featureCount == -1 && exactWfsCounts) {
252 featureCount = fs.getFeatures(q).size();
253 }
254 } else {
255 featureCount = fs.getCount(Query.ALL);
256
257 if (featureCount == -1 && exactWfsCounts) {
258 featureCount = fs.getFeatures(Query.ALL).size();
259 }
260 }
261 featuresResponse.setTotal(featureCount);
262
263
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
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
440 try (SimpleFeatureIterator feats =
441 featureSource.getFeatures(selectQuery).features()) {
442 while (feats.hasNext()) {
443 addFields = true;
444
445 SimpleFeature feature = feats.next();
446
447
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
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
497 featuresResponse.setAttachmentMetadata(
498 featureTypeHelper.getAttachmentAttributesWithMaxFileUploadSize(tmFeatureType));
499
500 if (withAttachments) {
501
502 Map<String, List<AttachmentMetadata>> attachmentsByFeatureId =
503 AttachmentsHelper.listAttachmentsForFeaturesByFeatureId(tmFeatureType, featurePKs);
504
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 }