1
2
3
4
5
6 package org.tailormap.api.controller;
7
8 import static org.tailormap.api.persistence.helper.TMFeatureTypeHelper.getConfiguredAttributes;
9
10 import io.micrometer.core.annotation.Counted;
11 import io.micrometer.core.annotation.Timed;
12 import jakarta.validation.Valid;
13 import java.io.IOException;
14 import java.lang.invoke.MethodHandles;
15 import java.net.MalformedURLException;
16 import java.nio.file.Files;
17 import java.nio.file.Path;
18 import java.util.HashSet;
19 import java.util.List;
20 import java.util.Locale;
21 import java.util.Map;
22 import java.util.Set;
23 import java.util.regex.Pattern;
24 import org.apache.commons.lang3.StringUtils;
25 import org.geotools.api.data.Query;
26 import org.geotools.api.data.SimpleFeatureSource;
27 import org.geotools.api.filter.Filter;
28 import org.geotools.api.filter.sort.SortOrder;
29 import org.geotools.api.referencing.FactoryException;
30 import org.geotools.filter.text.cql2.CQLException;
31 import org.jspecify.annotations.Nullable;
32 import org.slf4j.Logger;
33 import org.slf4j.LoggerFactory;
34 import org.springframework.beans.factory.annotation.Value;
35 import org.springframework.core.io.Resource;
36 import org.springframework.core.io.UrlResource;
37 import org.springframework.http.HttpHeaders;
38 import org.springframework.http.HttpStatus;
39 import org.springframework.http.MediaType;
40 import org.springframework.http.ResponseEntity;
41 import org.springframework.transaction.annotation.Transactional;
42 import org.springframework.web.bind.annotation.GetMapping;
43 import org.springframework.web.bind.annotation.ModelAttribute;
44 import org.springframework.web.bind.annotation.PathVariable;
45 import org.springframework.web.bind.annotation.PostMapping;
46 import org.springframework.web.bind.annotation.RequestMapping;
47 import org.springframework.web.bind.annotation.RequestParam;
48 import org.springframework.web.server.ResponseStatusException;
49 import org.tailormap.api.annotation.AppRestController;
50 import org.tailormap.api.geotools.FilterUtil;
51 import org.tailormap.api.geotools.data.excel.ExcelDataStore;
52 import org.tailormap.api.geotools.featuresources.FeatureSourceFactoryHelper;
53 import org.tailormap.api.persistence.Application;
54 import org.tailormap.api.persistence.GeoService;
55 import org.tailormap.api.persistence.TMFeatureType;
56 import org.tailormap.api.persistence.json.AppLayerSettings;
57 import org.tailormap.api.persistence.json.AppTreeLayerNode;
58 import org.tailormap.api.persistence.json.GeoServiceLayer;
59 import org.tailormap.api.repository.FeatureSourceRepository;
60 import org.tailormap.api.service.CreateLayerExtractService;
61
62 @AppRestController
63 @RequestMapping(path = "${tailormap-api.base-path}/{viewerKind}/{viewerName}/layer/{appLayerId}/extract")
64 public class LayerExtractController {
65 private static final Logger logger =
66 LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
67 private static final Pattern SAFE_DOWNLOAD_ID = Pattern.compile("^[A-Za-z0-9._-]+$");
68 private final FeatureSourceRepository featureSourceRepository;
69 private final CreateLayerExtractService createLayerExtractService;
70 private final FeatureSourceFactoryHelper featureSourceFactoryHelper;
71
72 @Value("#{'${tailormap-api.extract.allowed-outputformats}'.split(',')}")
73 private List<ExtractOutputFormat> allowedExtractOutputFormats;
74
75 public LayerExtractController(
76 FeatureSourceRepository featureSourceRepository,
77 CreateLayerExtractService createLayerExtractService,
78 FeatureSourceFactoryHelper featureSourceFactoryHelper) {
79 this.featureSourceRepository = featureSourceRepository;
80 this.createLayerExtractService = createLayerExtractService;
81 this.featureSourceFactoryHelper = featureSourceFactoryHelper;
82 }
83
84
85
86
87
88 @GetMapping(path = "/download/{downloadId}")
89 @Counted(value = "tailormap_api_extract_download", description = "Count of layer extract downloads")
90 public ResponseEntity<?> download(
91 @ModelAttribute GeoService service,
92 @ModelAttribute GeoServiceLayer layer,
93 @ModelAttribute Application application,
94 @ModelAttribute AppTreeLayerNode appTreeLayerNode,
95 @PathVariable String downloadId)
96 throws MalformedURLException {
97
98 if (downloadId == null || !SAFE_DOWNLOAD_ID.matcher(downloadId).matches()) {
99 throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid downloadId");
100 }
101 Path exportRoot = Path.of(createLayerExtractService.getExportFilesLocation())
102 .toAbsolutePath()
103 .normalize();
104 Path filePath = exportRoot.resolve(downloadId).normalize();
105 if (!filePath.startsWith(exportRoot)) {
106 throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid downloadId");
107 }
108
109 Resource resource = new UrlResource(filePath.toUri());
110 if (!resource.exists() || !resource.isReadable() || !resource.isFile()) {
111 throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Download file not found");
112 }
113
114 String contentType = MediaType.APPLICATION_OCTET_STREAM_VALUE;
115 try {
116 String detectedContentType = Files.probeContentType(filePath);
117 if (detectedContentType != null) {
118 contentType = detectedContentType;
119 }
120 } catch (IOException e) {
121 logger.debug("Could not determine content type for {}", filePath, e);
122 }
123
124 return ResponseEntity.ok()
125 .contentType(MediaType.parseMediaType(contentType))
126 .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + filePath.getFileName() + "\"")
127 .body(resource);
128 }
129
130 @GetMapping("/formats")
131 public ResponseEntity<?> formats(
132 @Valid @ModelAttribute GeoServiceLayer layer,
133 @ModelAttribute GeoService service,
134 @ModelAttribute Application application,
135 @ModelAttribute AppTreeLayerNode appTreeLayerNode) {
136 return ResponseEntity.ok(allowedExtractOutputFormats.stream()
137 .map(ExtractOutputFormat::getValue)
138 .toList());
139 }
140
141 @Transactional
142 @PostMapping("/{clientId}")
143 @Timed(value = "tailormap_api_extract", description = "Time taken to process a layer extract request")
144 public ResponseEntity<?> extract(
145 @Valid @ModelAttribute GeoServiceLayer layer,
146 @ModelAttribute GeoService service,
147 @ModelAttribute Application application,
148 @ModelAttribute AppTreeLayerNode appTreeLayerNode,
149 @PathVariable String clientId,
150 @RequestParam ExtractOutputFormat outputFormat,
151 @RequestParam(required = false) Set<String> attributes,
152 @RequestParam(required = false) String filter,
153 @RequestParam(required = false) String sortBy,
154 @RequestParam(required = false, defaultValue = "asc") String sortOrder) {
155
156 try {
157 createLayerExtractService.validateClientId(clientId);
158 } catch (IllegalArgumentException e) {
159 logger.warn("Invalid clientId for extract request: {}", clientId);
160 throw new ResponseStatusException(HttpStatus.BAD_REQUEST, e.getMessage());
161 }
162
163 if (!allowedExtractOutputFormats.contains(outputFormat)) {
164 logger.debug("Invalid output format requested: {}", outputFormat);
165 throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid output format");
166 }
167
168 TMFeatureType sourceFT = service.findFeatureTypeForLayer(layer, featureSourceRepository);
169 if (sourceFT == null) {
170 logger.debug("Layer export requested for layer without feature type");
171 throw new ResponseStatusException(HttpStatus.NOT_FOUND);
172 }
173 if (attributes == null) {
174 attributes = new HashSet<>();
175 }
176
177 AppLayerSettings appLayerSettings = application.getAppLayerSettings(appTreeLayerNode);
178
179 Set<String> nonHiddenAttributes =
180 getConfiguredAttributes(sourceFT, appLayerSettings).keySet();
181
182 if (!attributes.isEmpty()) {
183
184 if (!nonHiddenAttributes.containsAll(attributes)) {
185 throw new ResponseStatusException(
186 HttpStatus.BAD_REQUEST,
187 "One or more requested attributes are not available on the feature type");
188 }
189 } else if (!sourceFT.getSettings().getHideAttributes().isEmpty()) {
190
191
192
193 attributes = new HashSet<>(nonHiddenAttributes);
194 }
195
196
197
198
199 if (!attributes.isEmpty() && sourceFT.getDefaultGeometryAttribute() != null) {
200 attributes.add(sourceFT.getDefaultGeometryAttribute());
201 }
202
203
204 Filter parsedCQL = null;
205 SimpleFeatureSource simpleFeatureSource = null;
206 try {
207 if (!StringUtils.isBlank(filter)) {
208 simpleFeatureSource = featureSourceFactoryHelper.openGeoToolsFeatureSource(sourceFT);
209 parsedCQL = FilterUtil.parseFilter(filter, application, simpleFeatureSource);
210 }
211 } catch (CQLException | FactoryException | UnsupportedOperationException e) {
212 throw new ResponseStatusException(
213 HttpStatus.BAD_REQUEST, "Could not parse requested filter: " + e.getMessage(), e);
214 } catch (IOException e) {
215 throw new ResponseStatusException(
216 HttpStatus.INTERNAL_SERVER_ERROR, "Failed to connect to datasource: " + e.getMessage(), e);
217 } finally {
218 if (simpleFeatureSource != null) {
219 try {
220 simpleFeatureSource.getDataStore().dispose();
221 } catch (Exception e) {
222 logger.warn("Failed to dispose feature source", e);
223 }
224 }
225 }
226 if (ExtractOutputFormat.XLSX.equals(outputFormat)) {
227 validateExcelLimits(sourceFT, attributes, parsedCQL);
228 }
229
230 SortOrder sortingOrder = SortOrder.ASCENDING;
231 if (null != sortOrder && (sortOrder.equalsIgnoreCase("desc") || sortOrder.equalsIgnoreCase("asc"))) {
232 sortingOrder = SortOrder.valueOf(sortOrder.toUpperCase(Locale.ROOT));
233 }
234
235 final String outputFileName =
236 this.createLayerExtractService.createExtractFilename(clientId, sourceFT, outputFormat);
237 this.createLayerExtractService.emitProgress(clientId, outputFileName, 0, false, "Extract task received");
238
239
240 this.createLayerExtractService.createLayerExtract(
241 clientId, sourceFT, attributes, parsedCQL, sortBy, sortingOrder, outputFormat, outputFileName);
242
243
244 return ResponseEntity.accepted()
245 .body(Map.of("message", "Extract request accepted", "downloadId", outputFileName));
246 }
247
248
249
250
251
252
253
254
255
256
257 private void validateExcelLimits(TMFeatureType featureType, Set<String> attributes, @Nullable Filter filter) {
258 if (attributes.size() > ExcelDataStore.getMaxColumns()) {
259 throw new ResponseStatusException(
260 HttpStatus.BAD_REQUEST,
261 "Excel format does not support more than " + ExcelDataStore.getMaxColumns() + " columns");
262 }
263 SimpleFeatureSource inputFeatureSource = null;
264 try {
265
266
267 inputFeatureSource = featureSourceFactoryHelper.openGeoToolsFeatureSource(featureType);
268 Query q = new Query(inputFeatureSource.getName().toString());
269 if (!attributes.isEmpty()) {
270 q.setPropertyNames(attributes.toArray(new String[0]));
271 }
272
273 if (filter != null) {
274 q.setFilter(filter);
275 }
276 final int featCount = inputFeatureSource.getCount(q);
277 if (featCount >= ExcelDataStore.getMaxRows()) {
278 throw new ResponseStatusException(
279 HttpStatus.BAD_REQUEST,
280 "Excel format does not support more than " + ExcelDataStore.getMaxRows() + " rows");
281 }
282 } catch (IOException e) {
283 throw new ResponseStatusException(
284 HttpStatus.INTERNAL_SERVER_ERROR,
285 "Failed to count all features for Excel extract: " + e.getMessage());
286 } catch (IllegalArgumentException e) {
287 throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid filter");
288 } finally {
289 if (inputFeatureSource != null) {
290 inputFeatureSource.getDataStore().dispose();
291 }
292 }
293 }
294
295 public enum ExtractOutputFormat {
296 GEOPACKAGE("geopackage", ".gpkg"),
297 CSV("csv", ".csv"),
298 GEOJSON("geojson", ".geojson"),
299 XLSX("xlsx", ".xlsx"),
300 SHAPE("shape", ".zip");
301
302 private final String value;
303 private final String extension;
304
305 ExtractOutputFormat(String value, String extension) {
306 this.value = value;
307 this.extension = extension;
308 }
309
310 public static ExtractOutputFormat fromValue(String value) {
311 for (ExtractOutputFormat format : ExtractOutputFormat.values()) {
312 if (format.value.equalsIgnoreCase(value)) {
313 return format;
314 }
315 }
316 throw new IllegalArgumentException("Invalid output format: " + value);
317 }
318
319 public String getValue() {
320 return this.value;
321 }
322
323 public String getExtension() {
324 return this.extension;
325 }
326
327 @Override
328 public String toString() {
329 return String.valueOf(this.value);
330 }
331 }
332 }