Accepts the standard {@code xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx} format.
+ *
+ * @param value the string to parse
+ * @return the parsed UUIDv7
+ * @throws IllegalArgumentException if the string is not a valid UUID or not version 7
+ */
+ public static UUID fromString(String value) throws IllegalArgumentException {
+ if (value == null || value.isBlank()) {
+ throw new IllegalArgumentException("UUID string cannot be null or blank");
+ }
+ // Remove hyphens and parse the 32 hex characters directly
+ String hex = value.replace("-", "");
+ if (hex.length() != 32) {
+ throw new IllegalArgumentException("Invalid UUID string: " + value);
+ }
+ try {
+ long high = Long.parseUnsignedLong(hex, 0, 16, 16);
+ long low = Long.parseUnsignedLong(hex, 16, 32, 16);
+ UUID uuid = new UUID(high, low);
+ if (uuid.version() != 7) {
+ throw new IllegalArgumentException("UUID is not version 7: " + value);
+ }
+ return uuid;
+ } catch (NumberFormatException e) {
+ throw new IllegalArgumentException("Invalid UUID string: " + value, e);
+ }
+ }
+}
diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties
index 2de8a8bea2..ec8813cb6d 100644
--- a/src/main/resources/application.properties
+++ b/src/main/resources/application.properties
@@ -29,8 +29,16 @@ tailormap-api.features.wfs_count_exact=false
tailormap-api.feature.info.maxitems=30
# Should match the list in tailormap-viewer class AttributeListExportService
+# deprecated
tailormap-api.export.allowed-outputformats=csv,text/csv,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,excel2007,application/vnd.shp,application/x-zipped-shp,SHAPE-ZIP,application/geopackage+sqlite3,application/x-gpkg,geopackage,geopkg,gpkg,application/geo+json,application/geojson,application/json,json,DXF-ZIP
+# see org.tailormap.api.controller.LayerExtractController.ExtractOutputFormattFormat for valid values
+tailormap-api.extract.allowed-outputformats=csv,geojson,xlsx,shape
+# any files older than this (in minutes) in the extract output directory will be deleted by a scheduled job, to prevent filling up the disk
+# tailormap-api.extract.cleanup-minutes=120
+# the directory where the extract output files are stored, should be writable by the application
+# tailormap-api.extract.location=/tmp
+
# proxy passthrough regex patterns for layer names, when empty no additional layers are allowed to be proxied
# eg. use vw_t_gi_%s_[a-fA-F0-9]{32} to match `vw_t_gi_layername_70cae9814c6144808f1c9bb921099794` as a sub-layer of layername
# %s is replaced with the layer name from the configuration (this uses String.format() syntax, so be aware of the escaping rules for % and \)
diff --git a/src/test/java/org/tailormap/api/controller/LayerExtractControllerIntegrationTest.java b/src/test/java/org/tailormap/api/controller/LayerExtractControllerIntegrationTest.java
new file mode 100644
index 0000000000..bf94c24ef2
--- /dev/null
+++ b/src/test/java/org/tailormap/api/controller/LayerExtractControllerIntegrationTest.java
@@ -0,0 +1,294 @@
+/*
+ * Copyright (C) 2026 B3Partners B.V.
+ *
+ * SPDX-License-Identifier: MIT
+ */
+package org.tailormap.api.controller;
+
+import static java.util.concurrent.TimeUnit.MINUTES;
+import static java.util.concurrent.TimeUnit.SECONDS;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.containsString;
+import static org.hamcrest.Matchers.greaterThanOrEqualTo;
+import static org.hamcrest.Matchers.not;
+import static org.hamcrest.Matchers.startsWith;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.request;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+import static org.tailormap.api.TestRequestProcessor.setServletPath;
+import static org.tailormap.api.controller.TestUrls.layerBegroeidTerreindeelPostgis;
+import static org.tailormap.api.controller.TestUrls.layerProxiedWithAuthInPublicApp;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import org.awaitility.Awaitility;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.MethodOrderer;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.TestMethodOrder;
+import org.junit.jupiter.api.parallel.Execution;
+import org.junit.jupiter.api.parallel.ExecutionMode;
+import org.junitpioneer.jupiter.Stopwatch;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
+import org.springframework.http.MediaType;
+import org.springframework.security.test.context.support.WithMockUser;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.MvcResult;
+import org.tailormap.api.StaticTestData;
+import org.tailormap.api.annotation.PostgresIntegrationTest;
+import org.tailormap.api.viewer.model.ServerSentEventResponse;
+import tools.jackson.databind.ObjectMapper;
+
+@PostgresIntegrationTest
+@AutoConfigureMockMvc
+@Execution(ExecutionMode.CONCURRENT)
+@Stopwatch
+@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
+class LayerExtractControllerIntegrationTest {
+ private static final String extractPath = "/extract/";
+ private static final String downloadPath = "/extract/download/";
+ // Use a unique clientId per test instance to avoid cross-test interference
+ // when running concurrently.
+ private final String sseClientId = "testcase-" + System.nanoTime();
+
+ @Autowired
+ private MockMvc mockMvc;
+
+ @Value("${tailormap-api.base-path}")
+ private String apiBasePath;
+
+ /** SSE connection result; its response buffer accumulates server-sent events. */
+ private MvcResult sseResult;
+
+ @BeforeEach
+ void start_sse_stream() throws Exception {
+ final String sseUrl = apiBasePath + "/events/" + sseClientId;
+ sseResult = mockMvc.perform(get(sseUrl)
+ .accept(MediaType.TEXT_EVENT_STREAM)
+ .with(setServletPath(sseUrl))
+ .acceptCharset(StandardCharsets.UTF_8))
+ .andExpect(request().asyncStarted())
+ .andReturn();
+ }
+
+ @Test
+ void should_export_large_filter_to_csv() throws Exception {
+ final String extractUrl = apiBasePath + layerBegroeidTerreindeelPostgis + extractPath + sseClientId;
+ mockMvc.perform(post(extractUrl)
+ .accept(MediaType.APPLICATION_JSON)
+ .with(setServletPath(extractUrl))
+ .with(csrf())
+ .param("attributes", "")
+ .param("outputFormat", "csv")
+ .param("filter", StaticTestData.get("large_cql_filter"))
+ .acceptCharset(StandardCharsets.UTF_8)
+ .characterEncoding(StandardCharsets.UTF_8)
+ .contentType(MediaType.APPLICATION_FORM_URLENCODED))
+ .andExpect(status().isAccepted());
+
+ // The SseEventBus may dispatch events slightly after the POST returns.
+ // Awaitility polls the buffered SSE response until the expected content appears.
+ Awaitility.await()
+ .atMost(10, SECONDS)
+ .untilAsserted(() -> assertThat(
+ sseResult.getResponse().getContentAsString(), containsString("Extract task received")));
+
+ Awaitility.await().pollInterval(5, SECONDS).atMost(30, SECONDS).untilAsserted(() -> {
+ final String stream = sseResult.getResponse().getContentAsString();
+ assertThat(count_completed_messages(stream), greaterThanOrEqualTo(1));
+ });
+
+ final String lastCompletedEventJson =
+ getLastCompletedEventJson(sseResult.getResponse().getContentAsString());
+ assertThat(lastCompletedEventJson.length(), greaterThanOrEqualTo(100));
+
+ final String extractedDownloadId = getDownloadId(lastCompletedEventJson);
+ assertThat(extractedDownloadId, containsString(".csv"));
+
+ final String downloadUrl = apiBasePath + layerBegroeidTerreindeelPostgis + downloadPath + extractedDownloadId;
+ MvcResult download = mockMvc.perform(get(downloadUrl).with(setServletPath(downloadUrl)))
+ .andExpect(status().isOk())
+ .andExpect(result -> {
+ String contentType = result.getResponse().getContentType();
+ assertThat(contentType, containsString("text/csv"));
+
+ String contentDisposition = result.getResponse().getHeader("Content-Disposition");
+ assertThat(contentDisposition, containsString("attachment; filename="));
+ assertThat(contentDisposition, containsString(extractedDownloadId));
+ })
+ .andReturn();
+
+ final String csvContent = download.getResponse().getContentAsString();
+ assertEquals(
+ 19,
+ csvContent.lines().count(),
+ "Expected 19 lines in the CSV output, including header and 18 data rows");
+ }
+
+ @Test
+ void should_export_large_output_to_csv() throws Exception {
+ final String extractUrl = apiBasePath + layerBegroeidTerreindeelPostgis + extractPath + sseClientId;
+ mockMvc.perform(post(extractUrl)
+ .accept(MediaType.APPLICATION_JSON)
+ .with(setServletPath(extractUrl))
+ .with(csrf())
+ .param("attributes", "identificatie, class")
+ .param("outputFormat", "csv")
+ .acceptCharset(StandardCharsets.UTF_8)
+ .characterEncoding(StandardCharsets.UTF_8)
+ .contentType(MediaType.APPLICATION_FORM_URLENCODED))
+ .andExpect(status().isAccepted());
+
+ // The SseEventBus may dispatch events slightly after the POST returns.
+ // Awaitility polls the buffered SSE response until the expected content appears.
+ Awaitility.await()
+ .atMost(10, SECONDS)
+ .untilAsserted(() -> assertThat(
+ sseResult.getResponse().getContentAsString(), containsString("Extract task received")));
+
+ Awaitility.await().pollInterval(5, SECONDS).atMost(5, MINUTES).untilAsserted(() -> {
+ final String stream = sseResult.getResponse().getContentAsString();
+ assertThat(count_completed_messages(stream), greaterThanOrEqualTo(1));
+ });
+
+ final String lastCompletedEventJson =
+ getLastCompletedEventJson(sseResult.getResponse().getContentAsString());
+ assertThat(lastCompletedEventJson.length(), greaterThanOrEqualTo(100));
+
+ final String extractedDownloadId = getDownloadId(lastCompletedEventJson);
+ assertThat(extractedDownloadId, containsString(".csv"));
+
+ final String downloadUrl = apiBasePath + layerBegroeidTerreindeelPostgis + downloadPath + extractedDownloadId;
+ MvcResult download = mockMvc.perform(get(downloadUrl).with(setServletPath(downloadUrl)))
+ .andExpect(status().isOk())
+ .andExpect(result -> {
+ String contentType = result.getResponse().getContentType();
+ assertThat(contentType, containsString("text/csv"));
+
+ String contentDisposition = result.getResponse().getHeader("Content-Disposition");
+ assertThat(contentDisposition, containsString("attachment; filename="));
+ assertThat(contentDisposition, containsString(extractedDownloadId));
+ })
+ .andReturn();
+
+ final String csvContent = download.getResponse().getContentAsString();
+ assertEquals(
+ 3663,
+ csvContent.lines().count(),
+ "Expected 3663 lines in the CSV output, including header and 3662 data rows");
+ csvContent.lines().findFirst().ifPresent(header -> {
+ assertThat(header, containsString("identificatie"));
+ assertThat(header, containsString("class"));
+ // geometry is always included and the name is fixed
+ assertThat(header, containsString("the_geom_wkt"));
+ // these - among others - should not be exported
+ assertThat(header, not(containsString("bronhouder")));
+ assertThat(header, not(containsString("lv_publicatiedatum")));
+ });
+ }
+
+ @WithMockUser(
+ username = "tm-admin",
+ authorities = {"admin"})
+ @Test
+ void should_export_wfs_to_csv_with_authentication() throws Exception {
+ final String extractUrl = apiBasePath + layerProxiedWithAuthInPublicApp + extractPath + sseClientId;
+ mockMvc.perform(post(extractUrl)
+ .accept(MediaType.APPLICATION_JSON)
+ .with(setServletPath(extractUrl))
+ .with(csrf())
+ .param("attributes", "geom,naam,code")
+ .param("outputFormat", "csv")
+ .acceptCharset(StandardCharsets.UTF_8)
+ .characterEncoding(StandardCharsets.UTF_8)
+ .contentType(MediaType.APPLICATION_FORM_URLENCODED))
+ .andExpect(status().isAccepted());
+
+ // The SseEventBus may dispatch events slightly after the POST returns.
+ // Awaitility polls the buffered SSE response until the expected content appears.
+ Awaitility.await()
+ .atMost(10, SECONDS)
+ .untilAsserted(() -> assertThat(
+ sseResult.getResponse().getContentAsString(), containsString("Extract task received")));
+
+ Awaitility.await().pollInterval(5, SECONDS).atMost(5, MINUTES).untilAsserted(() -> {
+ final String stream = sseResult.getResponse().getContentAsString();
+ assertThat(count_completed_messages(stream), greaterThanOrEqualTo(1));
+ });
+
+ final String lastCompletedEventJson =
+ getLastCompletedEventJson(sseResult.getResponse().getContentAsString());
+ assertThat(lastCompletedEventJson.length(), greaterThanOrEqualTo(100));
+
+ final String extractedDownloadId = getDownloadId(lastCompletedEventJson);
+ assertThat(extractedDownloadId, containsString(".csv"));
+
+ final String downloadUrl = apiBasePath + layerBegroeidTerreindeelPostgis + downloadPath + extractedDownloadId;
+ MvcResult download = mockMvc.perform(get(downloadUrl).with(setServletPath(downloadUrl)))
+ .andExpect(status().isOk())
+ .andExpect(result -> {
+ String contentType = result.getResponse().getContentType();
+ assertThat(contentType, containsString("text/csv"));
+
+ String contentDisposition = result.getResponse().getHeader("Content-Disposition");
+ assertThat(contentDisposition, containsString("attachment; filename="));
+ assertThat(contentDisposition, containsString(extractedDownloadId));
+ })
+ .andReturn();
+
+ final String csvContent = download.getResponse().getContentAsString();
+ assertEquals(
+ 13,
+ csvContent.lines().count(),
+ "Expected 13 lines in the CSV output, including header and 12 data rows");
+ csvContent.lines().findFirst().ifPresent(header -> {
+ // geometry is always included and the name is fixed/hardcoded
+ assertThat(header, containsString("the_geom_wkt"));
+ assertThat(header, containsString("naam"));
+ assertThat(header, containsString("code"));
+ assertThat(header, startsWith("\"the_geom_wkt\",\"naam\",\"code\""));
+ assertThat(header, not(containsString("ligtInLandNaam")));
+ });
+ }
+
+ /**
+ * Parse the last non-empty line from the SSE stream that looks something like:
+ * {@code data:{"details":{"message":"Extract task
+ * completed","progress":100,"file":"begroeidterreindeel15061479295163305053.csv"},"eventType":"extract-completed","id":"019d6838-7f48-7053-9256-dd4b57c14264"}
+ * } as JSON and extract the file from the details.
+ */
+ private String getLastCompletedEventJson(String sseMessages) throws IOException {
+ return java.util.Arrays.stream(sseMessages.split("\\R"))
+ .map(String::trim)
+ .filter(line -> !line.isEmpty())
+ .filter(line -> line.startsWith("data:"))
+ .filter(line -> line.contains("\"eventType\":\"extract-completed\""))
+ .reduce((first, second) -> second)
+ .orElseThrow()
+ .substring("data:".length());
+ }
+
+ private String getDownloadId(String eventJson) {
+ return new ObjectMapper()
+ .readTree(eventJson)
+ .path("details")
+ .path("downloadId")
+ .asString();
+ }
+
+ private int count_completed_messages(String s) {
+ int count = 0;
+ int index = 0;
+ final String marker = "\"eventType\":\"" + ServerSentEventResponse.EventTypeEnum.EXTRACT_COMPLETED + "\"";
+ while ((index = s.indexOf(marker, index)) != -1) {
+ count++;
+ index += marker.length();
+ }
+ return count;
+ }
+}
diff --git a/src/test/java/org/tailormap/api/controller/LayerExtractControllerRestrictedFormatsIntegrationTest.java b/src/test/java/org/tailormap/api/controller/LayerExtractControllerRestrictedFormatsIntegrationTest.java
new file mode 100644
index 0000000000..711dd5ea0c
--- /dev/null
+++ b/src/test/java/org/tailormap/api/controller/LayerExtractControllerRestrictedFormatsIntegrationTest.java
@@ -0,0 +1,118 @@
+/*
+ * Copyright (C) 2026 B3Partners B.V.
+ *
+ * SPDX-License-Identifier: MIT
+ */
+package org.tailormap.api.controller;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.containsString;
+import static org.hamcrest.Matchers.is;
+import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+import static org.tailormap.api.TestRequestProcessor.setServletPath;
+import static org.tailormap.api.controller.TestUrls.layerBegroeidTerreindeelPostgis;
+import static org.tailormap.api.controller.TestUrls.layerProxiedWithAuthInPublicApp;
+
+import java.nio.charset.StandardCharsets;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
+import org.springframework.http.MediaType;
+import org.springframework.test.context.TestPropertySource;
+import org.springframework.test.web.servlet.MockMvc;
+import org.tailormap.api.annotation.PostgresIntegrationTest;
+
+/** These testcase run with a subset of the available formats. */
+@PostgresIntegrationTest
+@AutoConfigureMockMvc
+@TestPropertySource(properties = {"tailormap-api.extract.allowed-outputformats=csv,shape"})
+class LayerExtractControllerRestrictedFormatsIntegrationTest {
+ private static final String formatsPath = "/extract/formats";
+ private static final String extractPath = "/extract/";
+ private static final String downloadPath = "/extract/download/";
+
+ @Autowired
+ private MockMvc mockMvc;
+
+ @Value("${tailormap-api.base-path}")
+ private String apiBasePath;
+
+ @Test
+ void list_supported_formats() throws Exception {
+ final String extractUrl = apiBasePath + layerBegroeidTerreindeelPostgis + formatsPath;
+ mockMvc.perform(get(extractUrl).accept(MediaType.APPLICATION_JSON).with(setServletPath(extractUrl)))
+ .andExpect(status().isOk())
+ .andExpect(result -> assertThat(result.getResponse().getContentAsString(), is("[\"csv\",\"shape\"]")));
+ }
+
+ @Test
+ void invalid_output_format_should_return_bad_request_on_extract() throws Exception {
+ final String validClientId = "format-test-" + System.nanoTime();
+ final String extractUrl = apiBasePath + layerBegroeidTerreindeelPostgis + extractPath + validClientId;
+ mockMvc.perform(post(extractUrl)
+ .accept(MediaType.APPLICATION_JSON)
+ .with(setServletPath(extractUrl))
+ .with(csrf())
+ .param("attributes", "")
+ // disallowed through properties
+ .param("outputFormat", "geojson")
+ .acceptCharset(StandardCharsets.UTF_8)
+ .characterEncoding(StandardCharsets.UTF_8)
+ .contentType(MediaType.APPLICATION_FORM_URLENCODED))
+ .andExpect(status().isBadRequest())
+ .andExpect(result ->
+ assertThat(result.getResponse().getContentAsString(), containsString("Invalid output format")));
+ }
+
+ @Test
+ void invalid_client_id_should_return_bad_request_on_extract() throws Exception {
+ final String invalidClientId = "invalid-te$t-" + System.nanoTime();
+ final String extractUrl = apiBasePath + layerBegroeidTerreindeelPostgis + extractPath + invalidClientId;
+ mockMvc.perform(post(extractUrl)
+ .accept(MediaType.APPLICATION_JSON)
+ .with(setServletPath(extractUrl))
+ .with(csrf())
+ .param("attributes", "")
+ .param("outputFormat", "csv")
+ .acceptCharset(StandardCharsets.UTF_8)
+ .characterEncoding(StandardCharsets.UTF_8)
+ .contentType(MediaType.APPLICATION_FORM_URLENCODED))
+ .andExpect(status().isBadRequest())
+ .andExpect(result ->
+ assertThat(result.getResponse().getContentAsString(), containsString("Invalid clientId")));
+ }
+
+ @Test
+ void invalid_download_id_should_return_bad_request_on_download() throws Exception {
+ final String extractUrl = apiBasePath + layerBegroeidTerreindeelPostgis + downloadPath + "invalidDownloadId";
+ mockMvc.perform(get(extractUrl)
+ .accept(MediaType.APPLICATION_OCTET_STREAM)
+ .with(setServletPath(extractUrl)))
+ .andExpect(status().isNotFound())
+ .andExpect(result -> assertThat(
+ result.getResponse().getContentAsString(), containsString("Download file not found")));
+ }
+
+ @Test
+ void wms_secured_proxy_not_in_public_app_should_be_forbidden() throws Exception {
+ final String validClientId = "format-test-" + System.nanoTime();
+ final String extractUrl = apiBasePath + layerProxiedWithAuthInPublicApp + extractPath + validClientId;
+
+ mockMvc.perform(post(extractUrl)
+ .accept(MediaType.APPLICATION_JSON)
+ .with(setServletPath(extractUrl))
+ .with(csrf())
+ .param("attributes", "")
+ .param("outputFormat", "csv")
+ .acceptCharset(StandardCharsets.UTF_8)
+ .characterEncoding(StandardCharsets.UTF_8)
+ .contentType(MediaType.APPLICATION_FORM_URLENCODED))
+ .andDo(print())
+ .andExpect(status().isForbidden());
+ }
+}
diff --git a/src/test/java/org/tailormap/api/controller/ServerSentEventsControllerIntegrationTest.java b/src/test/java/org/tailormap/api/controller/ServerSentEventsControllerIntegrationTest.java
index a3fa19c06f..574bc19640 100644
--- a/src/test/java/org/tailormap/api/controller/ServerSentEventsControllerIntegrationTest.java
+++ b/src/test/java/org/tailormap/api/controller/ServerSentEventsControllerIntegrationTest.java
@@ -66,7 +66,7 @@ void should_send_keep_alive_messages_for_two_minutes() {
Awaitility.await("waiting for keep-alive messages")
.pollDelay(45, SECONDS)
.pollInterval(15, SECONDS)
- .atLeast(2, MINUTES)
+ .atLeast(1, MINUTES)
.atMost(130, SECONDS)
.logging(logPrinter -> logger.debug("Checking for keep-alive messages in SSE stream... {}", logPrinter))
.untilAsserted(() -> {
diff --git a/src/test/java/org/tailormap/api/util/UUIDv7Test.java b/src/test/java/org/tailormap/api/util/UUIDv7Test.java
new file mode 100644
index 0000000000..c066b630e9
--- /dev/null
+++ b/src/test/java/org/tailormap/api/util/UUIDv7Test.java
@@ -0,0 +1,59 @@
+/*
+ * Copyright (C) 2026 B3Partners B.V.
+ *
+ * SPDX-License-Identifier: MIT
+ */
+package org.tailormap.api.util;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.closeTo;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.UUID;
+import org.junit.jupiter.api.Test;
+
+class UUIDv7Test {
+ @Test
+ void testExtractUuid() throws InterruptedException {
+ ArrayList
The output file is validated to ensure it is created under the configured extract location.
+ *
+ * @param extractOutputFormat the requested extract output format
+ * @param outputFileName the target output filename
+ * @param clientId the SSE client id, used for error reporting
+ * @param typeName the source feature type name, used to derive format-specific metadata (for example Excel sheet
+ * name)
+ * @return a newly created {@link FileDataStore} configured for the requested format
+ * @throws IOException when the output file path is invalid or the datastore cannot be created
+ */
private FileDataStore getExtractDataStore(
LayerExtractController.ExtractOutputFormat extractOutputFormat,
String outputFileName,
@@ -293,15 +348,7 @@ private FileDataStore getExtractDataStore(
String typeName)
throws IOException {
- final File outputFile = Files.createFile(Path.of(exportFilesLocation, outputFileName))
- .toFile()
- .getCanonicalFile();
- if (!outputFile
- .getPath()
- .startsWith(Path.of(exportFilesLocation).toFile().getCanonicalPath())) {
- throw new IOException("Invalid file path");
- }
-
+ final File outputFile = getValidatedOutputFile(outputFileName);
if (!logger.isDebugEnabled()) {
// delete in production after JVM exit because the event bus will be reset when the JVM exits, and then we
// are unlikely to have a reference to the file anymore.
@@ -311,18 +358,18 @@ private FileDataStore getExtractDataStore(
switch (extractOutputFormat) {
case CSV -> {
- Map