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