Skip to content

Commit e3a5a10

Browse files
committed
HTM-1961: Update /extract API, introduce and implement SSE /events API
1 parent a900609 commit e3a5a10

6 files changed

Lines changed: 282 additions & 16 deletions

File tree

build/qa/PMD-ruleset_for_TM.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ SPDX-License-Identifier: MIT
113113
<rule ref="category/java/errorprone.xml/MoreThanOneLogger"/>
114114
<rule ref="category/java/bestpractices.xml/UnitTestShouldIncludeAssert">
115115
<properties>
116-
<property name="extraAssertMethodNames" value="andExpect"/>
116+
<property name="extraAssertMethodNames" value="andExpect, untilAsserted"/>
117117
</properties>
118118
</rule>
119119
<rule ref="category/java/bestpractices.xml/SimplifiableTestAssertion"/>
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
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 ch.rasc.sse.eventbus.SseEvent.DEFAULT_EVENT;
9+
10+
import ch.rasc.sse.eventbus.SseEvent;
11+
import ch.rasc.sse.eventbus.SseEventBus;
12+
import java.lang.invoke.MethodHandles;
13+
import org.slf4j.Logger;
14+
import org.slf4j.LoggerFactory;
15+
import org.springframework.http.HttpStatus;
16+
import org.springframework.scheduling.annotation.Scheduled;
17+
import org.springframework.web.bind.annotation.GetMapping;
18+
import org.springframework.web.bind.annotation.PathVariable;
19+
import org.springframework.web.bind.annotation.RestController;
20+
import org.springframework.web.server.ResponseStatusException;
21+
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
22+
import org.tailormap.api.viewer.model.ServerSentEventResponse;
23+
import tools.jackson.core.JacksonException;
24+
import tools.jackson.databind.SerializationFeature;
25+
import tools.jackson.databind.json.JsonMapper;
26+
27+
@RestController
28+
public class ServerSentEventsController {
29+
private static final Logger logger =
30+
LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
31+
32+
private final SseEventBus eventBus;
33+
34+
private final JsonMapper jsonMapper;
35+
36+
public ServerSentEventsController(SseEventBus eventBus, JsonMapper jsonMapper) {
37+
this.eventBus = eventBus;
38+
// force unindented/single line output for SSE messages, because we may have set
39+
// spring.jackson.serialization.indent_output=true for debugging/development/test
40+
if (jsonMapper.isEnabled(SerializationFeature.INDENT_OUTPUT)) {
41+
this.jsonMapper = jsonMapper
42+
.rebuild()
43+
.configure(SerializationFeature.INDENT_OUTPUT, false)
44+
.build();
45+
} else {
46+
this.jsonMapper = jsonMapper;
47+
}
48+
}
49+
50+
@GetMapping(path = "${tailormap-api.base-path}/events/{clientId}")
51+
public SseEmitter sse(@PathVariable String clientId) {
52+
// tests input against the set allowed by Nano ID
53+
if (!clientId.matches("[A-Za-z0-9_-]+")) {
54+
logger.warn("Invalid clientId for SSE connection: {}", clientId);
55+
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid clientId");
56+
}
57+
logger.debug("Adding new SSE client with id: {}", clientId);
58+
return this.eventBus.createSseEmitter(clientId, 3600_000L, DEFAULT_EVENT);
59+
}
60+
61+
@Scheduled(fixedRate = 60_000)
62+
public void keepAlive() throws JacksonException {
63+
this.eventBus.handleEvent(SseEvent.ofData(jsonMapper.writeValueAsString(
64+
new ServerSentEventResponse().eventType(ServerSentEventResponse.EventTypeEnum.KEEP_ALIVE))));
65+
}
66+
}

src/main/resources/openapi/status-responses.yaml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,8 +75,9 @@ components:
7575
eventType:
7676
description: 'Event type'
7777
type: string
78+
enum: [ 'keep-alive', 'extract-progress', 'extract-completed', 'extract-failed' ]
7879
details:
79-
description: 'Event data. Can be any JSON object or nothing, but should include at least a `status` property to indicate the status of the event.'
80+
description: 'Event data. Can be any JSON object or nothing, but should include at least a `message` property to indicate the status of the event.'
8081
type: object
8182
nullable: true
8283
id:
@@ -89,5 +90,5 @@ components:
8990
details:
9091
status: 'started'
9192
message: 'Extracting data'
92-
progress: 10
93+
progress: 17
9394
id: '123e4567-e89b-12d3-a456-426614174000'

src/main/resources/openapi/viewer-api.yaml

Lines changed: 86 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1809,8 +1809,71 @@ paths:
18091809
schema:
18101810
$ref: './status-responses.yaml#/components/schemas/ErrorResponse'
18111811

1812+
/events/{clientId}:
1813+
description: 'Subscribe to server-sent events for a viewer. The `clientId` is a client-generated identifier.'
1814+
get:
1815+
operationId: 'subscribeToEvents'
1816+
security:
1817+
- formAuth: [ ]
1818+
parameters:
1819+
- name: clientId
1820+
in: path
1821+
required: true
1822+
description: 'A client-generated identifier;
1823+
this must be the same as the clientId used in eg. the `/extract` request to correlate the events with the extract request.
1824+
The format should use the "Nano ID" format, for example `V1StGXR8_Z5jdHi6B-myT`.'
1825+
schema:
1826+
type: string
1827+
pattern: '[A-Za-z0-9_-]+'
1828+
responses:
1829+
'200':
1830+
description: 'OK'
1831+
content:
1832+
text/event-stream:
1833+
schema:
1834+
$ref: './status-responses.yaml#/components/schemas/ServerSentEventResponse'
1835+
example:
1836+
eventType: 'keep-alive'
1837+
id: '123e4567-e89b-12d3-a456-426614174000'
18121838

1813-
/{viewerKind}/{viewerName}/layer/{appLayerId}/extract:
1839+
/{viewerKind}/{viewerName}/layer/{appLayerId}/extract/formats:
1840+
description: 'Get the configured output formats for layer attribute extraction for this viewer/layer.
1841+
Currently this is set per instance using the `tailormap-api.extract.allowed-outputformats` property, but in the future this may be configurable per layer/viewer.'
1842+
get:
1843+
operationId: 'getAllowedExtractFormats'
1844+
security:
1845+
- formAuth: [ ]
1846+
parameters:
1847+
- name: viewerKind
1848+
in: path
1849+
required: true
1850+
schema:
1851+
type: string
1852+
enum:
1853+
- app
1854+
- service
1855+
- name: viewerName
1856+
in: path
1857+
required: true
1858+
schema:
1859+
type: string
1860+
- name: appLayerId
1861+
in: path
1862+
required: true
1863+
schema:
1864+
type: string
1865+
responses:
1866+
'200':
1867+
description: 'OK'
1868+
content:
1869+
application/json:
1870+
schema:
1871+
type: array
1872+
items:
1873+
type: string
1874+
example: '["csv","shape.zip"]'
1875+
1876+
/{viewerKind}/{viewerName}/layer/{appLayerId}/extract/{clientId}:
18141877
description: 'Export the attributes as shown in the attribute list for a layer.'
18151878
post:
18161879
operationId: 'extractLayerAttributes'
@@ -1835,6 +1898,14 @@ paths:
18351898
required: true
18361899
schema:
18371900
type: string
1901+
- name: clientId
1902+
in: path
1903+
required: true
1904+
description: 'The client-generated id to identify the extract request; this must be the same as the clientId used to initiate the SSE stream in `/events/`.
1905+
This is used to correlate the extract response and the download request. The format should use the "Nano ID" format, for example `V1StGXR8_Z5jdHi6B-myT`.'
1906+
schema:
1907+
type: string
1908+
pattern: '[A-Za-z0-9_-]+'
18381909
requestBody:
18391910
required: true
18401911
content:
@@ -1846,9 +1917,8 @@ paths:
18461917
properties:
18471918
outputFormat:
18481919
description: 'Output format.
1849-
The allowed formats are configured per instance using `tailormap-api.export.allowed-outputformats`.
1850-
For geographical formats the (default) geometry is included even if not requested.
1851-
For textual or spreadsheet formats geometries are not included.'
1920+
The allowed formats are configured per instance using `tailormap-api.extract.allowed-outputformats`.
1921+
The (default) geometry is included even if not requested.'
18521922
type: string
18531923
nullable: false
18541924
example: 'application/geo+json'
@@ -1871,26 +1941,29 @@ paths:
18711941
enum:
18721942
- asc
18731943
- desc
1874-
crs:
1875-
description: 'Projection for geometry output.'
1876-
type: string
18771944
responses:
1878-
'200':
1879-
description: 'Export started/in progress/finished. The client should listen to the stream and wait for an
1945+
'202':
1946+
description: 'Export started/queued. The client should listen to the `/events/` stream and wait for an
18801947
`extract-completed` event with `status: completed` to know when the export is finished and the file is ready
1881-
to be downloaded. If the connection is closed before that, the export will be cancelled.'
1948+
to be downloaded. If the connection is closed before that, the export may be cancelled.'
18821949
content:
1883-
text/event-stream:
1950+
application/json:
18841951
schema:
1885-
$ref: './status-responses.yaml#/components/schemas/ServerSentEventResponse'
1952+
title: 'extractRequestResponse'
1953+
type: object
1954+
properties:
1955+
message:
1956+
type: string
1957+
required:
1958+
- message
18861959
example:
18871960
eventType: 'extract-started'
18881961
id: '123e4567-e89b-12d3-a456-426614174000'
18891962
details:
18901963
status: started
18911964
message: 'Extracting data'
18921965
progress: 50
1893-
downloadId: '123e4567-e89b-12d3-a456-426614174001'
1966+
downloadId: '123e4567-e89b-12d3-a456-426614174001.csv'
18941967
'400':
18951968
description: 'Bad Request. May be returned for some combination of parameters that can not be processed or are incomplete.'
18961969
content:
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
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 java.util.concurrent.TimeUnit.MINUTES;
9+
import static java.util.concurrent.TimeUnit.SECONDS;
10+
import static org.hamcrest.MatcherAssert.assertThat;
11+
import static org.hamcrest.Matchers.greaterThanOrEqualTo;
12+
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
13+
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.request;
14+
import static org.tailormap.api.TestRequestProcessor.setServletPath;
15+
16+
import java.lang.invoke.MethodHandles;
17+
import java.nio.charset.StandardCharsets;
18+
import org.awaitility.Awaitility;
19+
import org.junit.jupiter.api.BeforeEach;
20+
import org.junit.jupiter.api.Test;
21+
import org.junit.jupiter.api.parallel.Execution;
22+
import org.junit.jupiter.api.parallel.ExecutionMode;
23+
import org.slf4j.Logger;
24+
import org.slf4j.LoggerFactory;
25+
import org.springframework.beans.factory.annotation.Autowired;
26+
import org.springframework.beans.factory.annotation.Value;
27+
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
28+
import org.springframework.http.MediaType;
29+
import org.springframework.test.web.servlet.MockMvc;
30+
import org.springframework.test.web.servlet.MvcResult;
31+
import org.tailormap.api.annotation.PostgresIntegrationTest;
32+
import org.tailormap.api.viewer.model.ServerSentEventResponse;
33+
34+
@PostgresIntegrationTest
35+
@AutoConfigureMockMvc
36+
@Execution(ExecutionMode.CONCURRENT)
37+
class ServerSentEventsControllerIntegrationTest {
38+
private static final Logger logger =
39+
LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
40+
// Unique id avoids interference with parallel/other tests.
41+
private final String sseClientId = "keepalive-test-" + System.nanoTime();
42+
43+
@Autowired
44+
private MockMvc mockMvc;
45+
46+
@Value("${tailormap-api.base-path}")
47+
private String apiBasePath;
48+
49+
private MvcResult sseResult;
50+
51+
@BeforeEach
52+
void start_sse_stream() throws Exception {
53+
final String sseUrl = apiBasePath + "/events/" + sseClientId;
54+
sseResult = mockMvc.perform(get(sseUrl)
55+
.accept(MediaType.TEXT_EVENT_STREAM)
56+
.with(setServletPath(sseUrl))
57+
.acceptCharset(StandardCharsets.UTF_8))
58+
.andExpect(request().asyncStarted())
59+
.andReturn();
60+
}
61+
62+
/** Check that at least 2 keep-alive messages arrive in 130 seconds. */
63+
@Test
64+
void should_send_keep_alive_messages_for_two_minutes() {
65+
// Keep this test running for at least 2 minutes, then assert at least 2 keep-alives arrived.
66+
Awaitility.await("waiting for keep-alive messages")
67+
.pollDelay(45, SECONDS)
68+
.pollInterval(15, SECONDS)
69+
.atLeast(2, MINUTES)
70+
.atMost(130, SECONDS)
71+
.logging(logPrinter -> logger.debug("Checking for keep-alive messages in SSE stream... {}", logPrinter))
72+
.untilAsserted(() -> {
73+
final String stream = sseResult.getResponse().getContentAsString();
74+
assertThat(count_keep_alive_messages(stream), greaterThanOrEqualTo(2));
75+
});
76+
}
77+
78+
private int count_keep_alive_messages(String stream) {
79+
int count = 0;
80+
int index = 0;
81+
final String marker = "\"eventType\":\"" + ServerSentEventResponse.EventTypeEnum.KEEP_ALIVE + "\"";
82+
while ((index = stream.indexOf(marker, index)) != -1) {
83+
count++;
84+
index += marker.length();
85+
}
86+
return count;
87+
}
88+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
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.test.web.servlet.request.MockMvcRequestBuilders.get;
9+
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
10+
import static org.tailormap.api.TestRequestProcessor.setServletPath;
11+
12+
import org.junit.jupiter.api.Test;
13+
import org.springframework.beans.factory.annotation.Autowired;
14+
import org.springframework.beans.factory.annotation.Value;
15+
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
16+
import org.springframework.http.MediaType;
17+
import org.springframework.test.web.servlet.MockMvc;
18+
import org.tailormap.api.annotation.PostgresIntegrationTest;
19+
20+
@PostgresIntegrationTest
21+
@AutoConfigureMockMvc
22+
class ServerSentEventsControllerInvalidInputIntegrationTest {
23+
24+
@Autowired
25+
private MockMvc mockMvc;
26+
27+
@Value("${tailormap-api.base-path}")
28+
private String apiBasePath;
29+
30+
@Test
31+
void invalid_client_id_should_return_bad_request() throws Exception {
32+
final String invalidClientId = "invalid-te$t-" + System.nanoTime();
33+
final String sseUrl = apiBasePath + "/events/" + invalidClientId;
34+
35+
mockMvc.perform(get(sseUrl).accept(MediaType.TEXT_EVENT_STREAM).with(setServletPath(sseUrl)))
36+
.andExpect(status().isBadRequest());
37+
}
38+
}

0 commit comments

Comments
 (0)