View Javadoc
1   /*
2    * Copyright (C) 2026 B3Partners B.V.
3    *
4    * SPDX-License-Identifier: MIT
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  
11  import io.micrometer.core.annotation.Timed;
12  import java.io.IOException;
13  import java.io.Serializable;
14  import java.lang.invoke.MethodHandles;
15  import java.util.regex.Pattern;
16  import org.geotools.api.data.Query;
17  import org.geotools.api.data.SimpleFeatureSource;
18  import org.geotools.api.filter.Filter;
19  import org.geotools.api.referencing.FactoryException;
20  import org.geotools.api.referencing.operation.MathTransform;
21  import org.geotools.data.oracle.OracleDialect;
22  import org.geotools.data.postgis.PostGISDialect;
23  import org.geotools.data.sqlserver.SQLServerDialect;
24  import org.geotools.filter.text.cql2.CQLException;
25  import org.geotools.filter.text.ecql.ECQL;
26  import org.geotools.geometry.jts.ReferencedEnvelope;
27  import org.geotools.jdbc.JDBCDataStore;
28  import org.locationtech.jts.geom.Envelope;
29  import org.slf4j.Logger;
30  import org.slf4j.LoggerFactory;
31  import org.springframework.http.HttpStatus;
32  import org.springframework.http.MediaType;
33  import org.springframework.http.ResponseEntity;
34  import org.springframework.transaction.annotation.Transactional;
35  import org.springframework.validation.annotation.Validated;
36  import org.springframework.web.bind.annotation.ModelAttribute;
37  import org.springframework.web.bind.annotation.RequestMapping;
38  import org.springframework.web.bind.annotation.RequestParam;
39  import org.springframework.web.server.ResponseStatusException;
40  import org.tailormap.api.annotation.AppRestController;
41  import org.tailormap.api.geotools.TransformationUtil;
42  import org.tailormap.api.geotools.featuresources.FeatureSourceFactoryHelper;
43  import org.tailormap.api.geotools.processing.GeometryProcessor;
44  import org.tailormap.api.persistence.Application;
45  import org.tailormap.api.persistence.GeoService;
46  import org.tailormap.api.persistence.TMFeatureType;
47  import org.tailormap.api.persistence.helper.GeoToolsHelper;
48  import org.tailormap.api.persistence.json.AppTreeLayerNode;
49  import org.tailormap.api.persistence.json.GeoServiceLayer;
50  import org.tailormap.api.repository.FeatureSourceRepository;
51  
52  @AppRestController
53  @Validated
54  @RequestMapping(
55      path = "${tailormap-api.base-path}/{viewerKind}/{viewerName}/layer/{appLayerId}/bounds",
56      produces = MediaType.APPLICATION_JSON_VALUE)
57  public class LayerBoundsController {
58    private static final Logger logger =
59        LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
60  
61    private final FeatureSourceFactoryHelper featureSourceFactoryHelper;
62  
63    private final FeatureSourceRepository featureSourceRepository;
64  
65    public LayerBoundsController(
66        FeatureSourceFactoryHelper featureSourceFactoryHelper, FeatureSourceRepository featureSourceRepository) {
67      this.featureSourceFactoryHelper = featureSourceFactoryHelper;
68      this.featureSourceRepository = featureSourceRepository;
69    }
70  
71    @Transactional
72    @RequestMapping(method = {GET, POST})
73    @Timed(value = "calculate_layer_bounds", description = "time spent calculating (filtered) layer bounds")
74    public ResponseEntity<Serializable> calculateLayerBounds(
75        @ModelAttribute AppTreeLayerNode appTreeLayerNode,
76        @ModelAttribute GeoService service,
77        @ModelAttribute GeoServiceLayer layer,
78        @ModelAttribute Application application,
79        @RequestParam(required = false, name = "filter") String filterCQL) {
80  
81      if (layer == null) {
82        throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Can't find layer " + appTreeLayerNode);
83      }
84  
85      TMFeatureType tmft = service.findFeatureTypeForLayer(layer, featureSourceRepository);
86      if (tmft == null) {
87        throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Layer does not have feature type");
88      }
89  
90      SimpleFeatureSource featureSource = null;
91      try {
92        featureSource = featureSourceFactoryHelper.openGeoToolsFeatureSource(tmft);
93        Query query = new Query(tmft.getName());
94        query.setHandle("calculateLayerBounds");
95  
96        if (filterCQL != null && !filterCQL.isEmpty()) {
97          try {
98            Filter parsedFilter = ECQL.toFilter(filterCQL);
99  
100           // check if the application is in the same CRS as the feature type and we have a query that applies
101           // to the default geometry
102           MathTransform transform =
103               TransformationUtil.getTransformationToDataSource(application, featureSource);
104           String defaultGeom = tmft.getDefaultGeometryAttribute();
105           if (transform != null
106               && defaultGeom != null
107               // the filter CQL will contain something like "INTERSECTS(geom,..." so look for the
108               // opening '(' and closing ',' allowing whitespace so it is not too brittle wrt. formatting
109               // note that more than 1 spatial filters may be present, just look for any atm
110               && Pattern.compile("\\(\\s*" + Pattern.quote(defaultGeom) + "\\s*,")
111                   .matcher(filterCQL)
112                   .find()) {
113             // TODO https://b3partners.atlassian.net/browse/HTM-2088
114             //      we need to transform the geometry/geometries in the filter to the feature source CRS
115             //       before applying the filter, for now log a warning and continue
116             logger.warn(
117                 "Application CRS is different from feature source CRS and filter '{}' contains spatial predicates on the default geometry, but filter geometries are not transformed. This will lead to incorrect results or errors.",
118                 filterCQL);
119           }
120 
121           query.setFilter(parsedFilter);
122         } catch (CQLException e) {
123           throw new ResponseStatusException(HttpStatus.BAD_REQUEST, e.getMessage());
124         }
125       }
126 
127       if ((filterCQL == null || filterCQL.isBlank())
128           && featureSource.getDataStore() instanceof JDBCDataStore jdbcDataStore) {
129         // turn off getting optimised/inaccurate bounds
130         logger.debug(
131             "Turning off estimated extents for layer {} because no filter is applied and the datastore is a JDBCDataStore",
132             appTreeLayerNode.getLayerName());
133 
134         switch (jdbcDataStore.getSQLDialect()) {
135           case SQLServerDialect sqlServerDialect -> sqlServerDialect.setEstimatedExtentsEnabled(false);
136           case PostGISDialect postGISDialect -> postGISDialect.setEstimatedExtentsEnabled(false);
137           case OracleDialect oracleDialect -> oracleDialect.setEstimatedExtentsEnabled(false);
138           default -> {
139             // no-op
140           }
141         }
142       }
143 
144       // some datastores optimize getting bounds by using metadata or spatial index; this is inaccurate.
145       // Also featureSource.getBounds(query) can return null in case the GT API thinks it is too costly...
146       ReferencedEnvelope referencedEnvelope = featureSource.getBounds(query);
147       if (referencedEnvelope == null) {
148         referencedEnvelope = featureSource.getFeatures(query).getBounds();
149       }
150 
151       if (referencedEnvelope == null || referencedEnvelope.isNull()) {
152         throw new ResponseStatusException(
153             HttpStatus.BAD_REQUEST,
154             "No features found for layer "
155                 + appTreeLayerNode.getLayerName()
156                 + " and filter '"
157                 + filterCQL
158                 + "'");
159       }
160 
161       // if the featuretype CRS is different from the application CRS we need to project to application
162       Envelope envelope = GeometryProcessor.transformEnvelope(
163           referencedEnvelope, TransformationUtil.getTransformationToApplication(application, featureSource));
164 
165       return ResponseEntity.ok(GeoToolsHelper.fromEnvelope(envelope));
166     } catch (FactoryException | IOException e) {
167       logger.error("Error calculating bounds for layer {} and filter '{}'", appTreeLayerNode, filterCQL, e);
168       throw new ResponseStatusException(
169           HttpStatus.INTERNAL_SERVER_ERROR,
170           "Error calculating layer bounds for: " + appTreeLayerNode.getLayerName() + ". " + e.getMessage(),
171           e);
172     } finally {
173       if (featureSource != null) {
174         featureSource.getDataStore().dispose();
175       }
176     }
177   }
178 }