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