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