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