Skip to content

Commit 54eb6dd

Browse files
committed
HTM-1961 | HTM-1962: Initial implemententation of /extract endpoint with CSV output format
1 parent e1de6cd commit 54eb6dd

10 files changed

Lines changed: 1218 additions & 0 deletions

File tree

src/main/java/org/tailormap/api/configuration/AsyncConfig.java

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,4 +28,16 @@ public Executor passwordResetTaskExecutor() {
2828
executor.initialize();
2929
return executor;
3030
}
31+
32+
@Bean(name = "extractTaskExecutor")
33+
public Executor extractTaskExecutor() {
34+
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
35+
executor.setCorePoolSize(1);
36+
executor.setMaxPoolSize(10);
37+
executor.setQueueCapacity(100);
38+
executor.setThreadNamePrefix("create-extract-");
39+
executor.setWaitForTasksToCompleteOnShutdown(false);
40+
executor.initialize();
41+
return executor;
42+
}
3143
}

src/main/java/org/tailormap/api/configuration/base/WebMvcConfig.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
1717
import org.springframework.web.servlet.resource.EncodedResourceResolver;
1818
import org.tailormap.api.configuration.CaseInsensitiveEnumConverter;
19+
import org.tailormap.api.controller.LayerExtractController;
1920
import org.tailormap.api.persistence.json.GeoServiceProtocol;
2021
import org.tailormap.api.scheduling.TaskType;
2122

@@ -63,5 +64,9 @@ public void addFormatters(@NonNull FormatterRegistry registry) {
6364
String.class, GeoServiceProtocol.class, new CaseInsensitiveEnumConverter<>(GeoServiceProtocol.class));
6465

6566
registry.addConverter(String.class, TaskType.class, new CaseInsensitiveEnumConverter<>(TaskType.class));
67+
registry.addConverter(
68+
String.class,
69+
LayerExtractController.ExtractOutputFormat.class,
70+
new CaseInsensitiveEnumConverter<>(LayerExtractController.ExtractOutputFormat.class));
6671
}
6772
}
Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
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.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.geotools.api.filter.sort.SortOrder;
25+
import org.slf4j.Logger;
26+
import org.slf4j.LoggerFactory;
27+
import org.springframework.beans.factory.annotation.Value;
28+
import org.springframework.core.io.Resource;
29+
import org.springframework.core.io.UrlResource;
30+
import org.springframework.http.HttpHeaders;
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.web.bind.annotation.GetMapping;
36+
import org.springframework.web.bind.annotation.ModelAttribute;
37+
import org.springframework.web.bind.annotation.PathVariable;
38+
import org.springframework.web.bind.annotation.PostMapping;
39+
import org.springframework.web.bind.annotation.RequestMapping;
40+
import org.springframework.web.bind.annotation.RequestParam;
41+
import org.springframework.web.server.ResponseStatusException;
42+
import org.tailormap.api.annotation.AppRestController;
43+
import org.tailormap.api.persistence.Application;
44+
import org.tailormap.api.persistence.GeoService;
45+
import org.tailormap.api.persistence.TMFeatureType;
46+
import org.tailormap.api.persistence.json.AppLayerSettings;
47+
import org.tailormap.api.persistence.json.AppTreeLayerNode;
48+
import org.tailormap.api.persistence.json.GeoServiceLayer;
49+
import org.tailormap.api.repository.FeatureSourceRepository;
50+
import org.tailormap.api.service.CreateLayerExtractService;
51+
52+
@AppRestController
53+
@RequestMapping(path = "${tailormap-api.base-path}/{viewerKind}/{viewerName}/layer/{appLayerId}/extract")
54+
public class LayerExtractController {
55+
private static final Logger logger =
56+
LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
57+
private static final Pattern SAFE_DOWNLOAD_ID = Pattern.compile("^[A-Za-z0-9._-]+$");
58+
private final FeatureSourceRepository featureSourceRepository;
59+
private final CreateLayerExtractService createLayerExtractService;
60+
61+
@Value("#{'${tailormap-api.extract.allowed-outputformats}'.split(',')}")
62+
private List<ExtractOutputFormat> allowedExtractOutputFormats;
63+
64+
public LayerExtractController(
65+
FeatureSourceRepository featureSourceRepository, CreateLayerExtractService createLayerExtractService) {
66+
this.featureSourceRepository = featureSourceRepository;
67+
this.createLayerExtractService = createLayerExtractService;
68+
}
69+
70+
/**
71+
* Download the result of an extract request. The extract generation should be initiated first by a POST to
72+
* {@code /{viewerKind}/{viewerName}/layer/{appLayerId}/extract}.
73+
*/
74+
@GetMapping(path = "/download/{downloadId}")
75+
@Counted(value = "tailormap_api_extract_download", description = "Count of layer extract downloads")
76+
public ResponseEntity<?> download(
77+
@ModelAttribute GeoService service,
78+
@ModelAttribute GeoServiceLayer layer,
79+
@ModelAttribute Application application,
80+
@ModelAttribute AppTreeLayerNode appTreeLayerNode,
81+
@PathVariable String downloadId)
82+
throws MalformedURLException {
83+
84+
if (downloadId == null || !SAFE_DOWNLOAD_ID.matcher(downloadId).matches()) {
85+
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid downloadId");
86+
}
87+
Path exportRoot = Path.of(createLayerExtractService.getExportFilesLocation())
88+
.toAbsolutePath()
89+
.normalize();
90+
Path filePath = exportRoot.resolve(downloadId).normalize();
91+
if (!filePath.startsWith(exportRoot)) {
92+
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid downloadId");
93+
}
94+
95+
Resource resource = new UrlResource(filePath.toUri());
96+
if (!resource.exists() || !resource.isReadable() || !resource.isFile()) {
97+
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Download file not found");
98+
}
99+
100+
String contentType = MediaType.APPLICATION_OCTET_STREAM_VALUE;
101+
try {
102+
String detectedContentType = Files.probeContentType(filePath);
103+
if (detectedContentType != null) {
104+
contentType = detectedContentType;
105+
}
106+
} catch (IOException e) {
107+
logger.debug("Could not determine content type for {}", filePath, e);
108+
}
109+
110+
return ResponseEntity.ok()
111+
.contentType(MediaType.parseMediaType(contentType))
112+
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + filePath.getFileName() + "\"")
113+
.body(resource);
114+
}
115+
116+
@GetMapping("/formats")
117+
public ResponseEntity<?> formats(
118+
@Valid @ModelAttribute GeoServiceLayer layer,
119+
@ModelAttribute GeoService service,
120+
@ModelAttribute Application application,
121+
@ModelAttribute AppTreeLayerNode appTreeLayerNode) {
122+
return ResponseEntity.ok(allowedExtractOutputFormats);
123+
}
124+
125+
@Transactional
126+
@PostMapping("/{clientId}")
127+
@Timed(value = "tailormap_api_extract", description = "Time taken to process a layer extract request")
128+
public ResponseEntity<?> extract(
129+
@Valid @ModelAttribute GeoServiceLayer layer,
130+
@ModelAttribute GeoService service,
131+
@ModelAttribute Application application,
132+
@ModelAttribute AppTreeLayerNode appTreeLayerNode,
133+
@PathVariable String clientId,
134+
@RequestParam ExtractOutputFormat outputFormat,
135+
@RequestParam(required = false) Set<String> attributes,
136+
@RequestParam(required = false) String filter,
137+
@RequestParam(required = false) String sortBy,
138+
@RequestParam(required = false, defaultValue = "asc") String sortOrder) {
139+
140+
try {
141+
createLayerExtractService.validateClientId(clientId);
142+
} catch (IllegalArgumentException e) {
143+
logger.warn("Invalid clientId for extract request: {}", clientId);
144+
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, e.getMessage());
145+
}
146+
147+
if (!allowedExtractOutputFormats.contains(outputFormat)) {
148+
logger.debug("Invalid output format requested: {}", outputFormat);
149+
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid output format");
150+
}
151+
152+
TMFeatureType sourceFT = service.findFeatureTypeForLayer(layer, featureSourceRepository);
153+
if (sourceFT == null) {
154+
logger.debug("Layer export requested for layer without feature type");
155+
throw new ResponseStatusException(HttpStatus.NOT_FOUND);
156+
}
157+
if (attributes == null) {
158+
attributes = new HashSet<>();
159+
}
160+
161+
AppLayerSettings appLayerSettings = application.getAppLayerSettings(appTreeLayerNode);
162+
// Get attributes in configured or original order
163+
Set<String> nonHiddenAttributes =
164+
getConfiguredAttributes(sourceFT, appLayerSettings).keySet();
165+
166+
if (!attributes.isEmpty()) {
167+
// Only export non-hidden property names
168+
if (!nonHiddenAttributes.containsAll(attributes)) {
169+
throw new ResponseStatusException(
170+
HttpStatus.BAD_REQUEST,
171+
"One or more requested attributes are not available on the feature type");
172+
}
173+
} else if (!sourceFT.getSettings().getHideAttributes().isEmpty()) {
174+
// Only specify specific propNames if there are hidden attributes. Having no propNames
175+
// request parameter to request all propNames is less error-prone than specifying the ones
176+
// we have saved in the feature type
177+
attributes = new HashSet<>(nonHiddenAttributes);
178+
}
179+
180+
// Empty attributes means we won't specify propNames in the GetFeature request. However, if we do select only
181+
// some property names, we need the geometry attribute which is not in the 'attributes' request param so spatial
182+
// export formats don't have the geometry missing.
183+
if (!attributes.isEmpty() && sourceFT.getDefaultGeometryAttribute() != null) {
184+
attributes.add(sourceFT.getDefaultGeometryAttribute());
185+
}
186+
187+
SortOrder sortingOrder = SortOrder.ASCENDING;
188+
if (null != sortOrder && (sortOrder.equalsIgnoreCase("desc") || sortOrder.equalsIgnoreCase("asc"))) {
189+
sortingOrder = SortOrder.valueOf(sortOrder.toUpperCase(Locale.ROOT));
190+
}
191+
192+
final String outputFileName =
193+
this.createLayerExtractService.createExtractFilename(clientId, sourceFT, outputFormat);
194+
this.createLayerExtractService.emitProgress(clientId, outputFileName, 0, false, "Extract task received");
195+
196+
//noinspection JvmTaintAnalysis Not a Path Traversal Sink because the clientId is validated
197+
this.createLayerExtractService.createLayerExtract(
198+
clientId, sourceFT, attributes, filter, sortBy, sortingOrder, outputFormat, outputFileName);
199+
200+
//noinspection JvmTaintAnalysis Not an XSS sink because the response is a json message
201+
return ResponseEntity.accepted()
202+
.body(Map.of("message", "Extract request accepted", "downloadId", outputFileName));
203+
}
204+
205+
public enum ExtractOutputFormat {
206+
CSV("csv", "csv"),
207+
GEOJSON("geojson", "json"),
208+
XLSX("xlsx", "xlsx"),
209+
SHAPE("shape", "zip");
210+
211+
private final String value;
212+
private final String extension;
213+
214+
ExtractOutputFormat(String value, String extension) {
215+
this.value = value;
216+
this.extension = extension;
217+
}
218+
219+
public static ExtractOutputFormat fromValue(String value) {
220+
for (ExtractOutputFormat format : ExtractOutputFormat.values()) {
221+
if (format.value.equalsIgnoreCase(value)) {
222+
return format;
223+
}
224+
}
225+
throw new IllegalArgumentException("Invalid output format: " + value);
226+
}
227+
228+
public String getValue() {
229+
return this.value;
230+
}
231+
232+
public String getExtension() {
233+
return this.extension;
234+
}
235+
236+
@Override
237+
public String toString() {
238+
return String.valueOf(this.value);
239+
}
240+
}
241+
}

0 commit comments

Comments
 (0)