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