From 2cd20a4a622b06121158ecf7dbb2dc2589fbe539 Mon Sep 17 00:00:00 2001 From: Mark Prins <1165786+mprins@users.noreply.github.com> Date: Thu, 6 Nov 2025 14:43:16 +0100 Subject: [PATCH 01/14] HTM-1736 | HTM-1737: API and code to manage attachments - add - list - get - delete --- .../api/controller/AttachmentsController.java | 125 ++++++++++-- .../featuresources/AttachmentsHelper.java | 156 ++++++++++++++- src/main/resources/openapi/viewer-api.yaml | 118 ++++++++++++ .../AttachmentsControllerIntegrationTest.java | 182 ++++++++++++++++-- 4 files changed, 541 insertions(+), 40 deletions(-) diff --git a/src/main/java/org/tailormap/api/controller/AttachmentsController.java b/src/main/java/org/tailormap/api/controller/AttachmentsController.java index a14652a6e..e4c5df7db 100644 --- a/src/main/java/org/tailormap/api/controller/AttachmentsController.java +++ b/src/main/java/org/tailormap/api/controller/AttachmentsController.java @@ -9,7 +9,9 @@ import java.io.IOException; import java.io.Serializable; import java.lang.invoke.MethodHandles; +import java.nio.ByteBuffer; import java.sql.SQLException; +import java.util.List; import java.util.Set; import java.util.UUID; import org.geotools.api.data.Query; @@ -92,6 +94,8 @@ public ResponseEntity addAttachment( TMFeatureType tmFeatureType = editUtil.getEditableFeatureType(application, appTreeLayerNode, service, layer); + checkFeatureExists(tmFeatureType, featureId); + Set<@Valid AttachmentAttributeType> attachmentAttrSet = tmFeatureType.getSettings().getAttachmentAttributes(); if (attachmentAttrSet == null || attachmentAttrSet.isEmpty()) { @@ -115,10 +119,6 @@ public ResponseEntity addAttachment( + fileData.length)); logger.debug("Using attachment attribute {}", attachmentAttributeType); - if (!checkFeatureExists(tmFeatureType, featureId)) { - throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Feature with id " + featureId + " does not exist"); - } - AttachmentMetadata response; try { response = AttachmentsHelper.insertAttachment(tmFeatureType, attachment, featureId, fileData); @@ -129,32 +129,119 @@ public ResponseEntity addAttachment( return new ResponseEntity<>(response, HttpStatus.CREATED); } - @DeleteMapping(path = "${tailormap-api.base-path}/attachment/{attachmentId}") - public ResponseEntity deleteAttachment(@PathVariable UUID attachmentId) { + /** + * Add an attachment to a feature + * + * @param appTreeLayerNode the application tree layer node + * @param service the geo service + * @param layer the geo service layer + * @param application the application + * @param featureId the feature id + * @return the response entity + */ + @GetMapping( + path = { + "${tailormap-api.base-path}/{viewerKind}/{viewerName}/layer/{appLayerId}/feature/{featureId}/attachment" + }, + produces = MediaType.APPLICATION_JSON_VALUE) + @Transactional + public ResponseEntity> listAttachments( + @ModelAttribute AppTreeLayerNode appTreeLayerNode, + @ModelAttribute GeoService service, + @ModelAttribute GeoServiceLayer layer, + @ModelAttribute Application application, + @PathVariable String featureId) { + + TMFeatureType tmFeatureType = editUtil.getEditableFeatureType(application, appTreeLayerNode, service, layer); + + checkFeatureExists(tmFeatureType, featureId); + checkFeatureTypeSupportsAttachments(tmFeatureType); + + List response; + try { + response = AttachmentsHelper.listAttachmentsForFeature(tmFeatureType, featureId); + } catch (IOException | SQLException e) { + throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, e.getMessage()); + } + + return new ResponseEntity<>(response, HttpStatus.OK); + } + + @DeleteMapping( + path = "${tailormap-api.base-path}/{viewerKind}/{viewerName}/layer/{appLayerId}/attachment/{attachmentId}") + public ResponseEntity deleteAttachment( + @ModelAttribute AppTreeLayerNode appTreeLayerNode, + @ModelAttribute GeoService service, + @ModelAttribute GeoServiceLayer layer, + @ModelAttribute Application application, + @PathVariable UUID attachmentId) { editUtil.checkEditAuthorisation(); - logger.debug("TODO: Deleting attachment with id {}", attachmentId); - throw new UnsupportedOperationException("Not implemented yet"); + TMFeatureType tmFeatureType = editUtil.getEditableFeatureType(application, appTreeLayerNode, service, layer); + + checkFeatureTypeSupportsAttachments(tmFeatureType); + + try { + AttachmentsHelper.deleteAttachment(attachmentId, tmFeatureType); + } catch (IOException | SQLException e) { + throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, e.getMessage()); + } + + return new ResponseEntity<>(HttpStatus.NO_CONTENT); } + @Transactional @GetMapping( - path = "${tailormap-api.base-path}/attachment/{attachmentId}", + path = "${tailormap-api.base-path}/{viewerKind}/{viewerName}/layer/{appLayerId}/attachment/{attachmentId}", produces = {"application/octet-stream"}) - // TODO determine return type: ResponseEntity or ResponseEntity? - public ResponseEntity getAttachment(@PathVariable UUID attachmentId) { - logger.debug("TODO: Getting attachment with id {}", attachmentId); + public ResponseEntity getAttachment( + @ModelAttribute AppTreeLayerNode appTreeLayerNode, + @ModelAttribute GeoService service, + @ModelAttribute GeoServiceLayer layer, + @ModelAttribute Application application, + @PathVariable UUID attachmentId) { - throw new UnsupportedOperationException("Not implemented yet"); + TMFeatureType tmFeatureType = editUtil.getEditableFeatureType(application, appTreeLayerNode, service, layer); + + try { + final AttachmentsHelper.AttachmentWithBinary attachmentWithBinary = + AttachmentsHelper.getAttachment(tmFeatureType, attachmentId); + + if (attachmentWithBinary == null) { + throw new ResponseStatusException( + HttpStatus.NOT_FOUND, "Attachment %s not found".formatted(attachmentId.toString())); + } + + // the binary attachment() is a read-only ByteBuffer, so we cant use .array() + final ByteBuffer bb = attachmentWithBinary.attachment().asReadOnlyBuffer(); + bb.rewind(); + byte[] attachmentData = new byte[bb.remaining()]; + bb.get(attachmentData); + + return ResponseEntity.ok() + .header( + "Content-Disposition", + "inline; filename=\"" + + attachmentWithBinary.attachmentMetadata().getFileName() + "\"") + .contentType(MediaType.parseMediaType( + attachmentWithBinary.attachmentMetadata().getMimeType())) + .body(attachmentData); + } catch (SQLException | IOException e) { + throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, e.getMessage()); + } } - private boolean checkFeatureExists(TMFeatureType tmFeatureType, String featureId) { + private void checkFeatureExists(TMFeatureType tmFeatureType, String featureId) throws ResponseStatusException { final Filter fidFilter = ff.id(ff.featureId(featureId)); SimpleFeatureSource fs = null; try { fs = featureSourceFactoryHelper.openGeoToolsFeatureSource(tmFeatureType); Query query = new Query(); query.setFilter(fidFilter); - return fs.getCount(query) > 0; + if (fs.getCount(query) < 1) { + throw new ResponseStatusException( + HttpStatus.NOT_FOUND, "Feature with id " + featureId + " does not exist"); + } } catch (IOException e) { throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, e.getMessage()); } finally { @@ -163,4 +250,12 @@ private boolean checkFeatureExists(TMFeatureType tmFeatureType, String featureId } } } + + private void checkFeatureTypeSupportsAttachments(TMFeatureType tmFeatureType) throws ResponseStatusException { + Set<@Valid AttachmentAttributeType> attachmentAttrSet = + tmFeatureType.getSettings().getAttachmentAttributes(); + if (attachmentAttrSet == null || attachmentAttrSet.isEmpty()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Feature type does not support attachments"); + } + } } diff --git a/src/main/java/org/tailormap/api/geotools/featuresources/AttachmentsHelper.java b/src/main/java/org/tailormap/api/geotools/featuresources/AttachmentsHelper.java index 5b52f94f3..0b2705293 100644 --- a/src/main/java/org/tailormap/api/geotools/featuresources/AttachmentsHelper.java +++ b/src/main/java/org/tailormap/api/geotools/featuresources/AttachmentsHelper.java @@ -18,6 +18,7 @@ import java.text.MessageFormat; import java.time.OffsetDateTime; import java.time.ZoneId; +import java.util.ArrayList; import java.util.List; import java.util.Locale; import java.util.UUID; @@ -244,7 +245,8 @@ private static String getCreateAttachmentsForFeatureTypeStatements( typeModifier = getValidModifier(fkColumnType, fkColumnSize); } logger.debug( - "Creating attachment table for feature type with primary key {} (native type: {}, meta type: {}, size: {} (modifier: {}))", + "Creating attachment table for feature type with primary key {} (native type: {}, meta type: {}, size:" + + " {} (modifier: {}))", pkDescriptor.getLocalName(), fkColumnType, pkDescriptor.getUserData().get("org.geotools.jdbc.nativeTypeName"), @@ -388,8 +390,6 @@ public static AttachmentMetadata insertAttachment( """, featureType.getName()); - byte[] attachmentIdBytes = asBytes(attachment.getAttachmentId()); - JDBCDataStore ds = null; try { ds = (JDBCDataStore) new JDBCFeatureSourceHelper().createDataStore(featureType.getFeatureSource()); @@ -402,7 +402,7 @@ public static AttachmentMetadata insertAttachment( .getJdbcConnection() .getDbtype() .equals(JDBCConnectionProperties.DbtypeEnum.ORACLE)) { - stmt.setBytes(2, attachmentIdBytes); + stmt.setBytes(2, asBytes(attachment.getAttachmentId())); } else { stmt.setObject(2, attachment.getAttachmentId()); } @@ -426,4 +426,152 @@ public static AttachmentMetadata insertAttachment( } } } + + public static void deleteAttachment(UUID attachmentId, TMFeatureType featureType) throws IOException, SQLException { + String deleteSql = MessageFormat.format( + """ +DELETE FROM {0}_attachments WHERE attachment_id = ? +""", featureType.getName()); + JDBCDataStore ds = null; + try { + ds = (JDBCDataStore) new JDBCFeatureSourceHelper().createDataStore(featureType.getFeatureSource()); + try (Connection conn = ds.getDataSource().getConnection(); + PreparedStatement stmt = conn.prepareStatement(deleteSql)) { + if (featureType + .getFeatureSource() + .getJdbcConnection() + .getDbtype() + .equals(JDBCConnectionProperties.DbtypeEnum.ORACLE)) { + stmt.setBytes(1, asBytes(attachmentId)); + } else { + stmt.setObject(1, attachmentId); + } + + stmt.executeUpdate(); + } + } finally { + if (ds != null) { + ds.dispose(); + } + } + } + + public static List listAttachmentsForFeature(TMFeatureType featureType, String featureId) + throws IOException, SQLException { + + String querySql = MessageFormat.format( + """ +SELECT +{0}_pk, +attachment_id, +file_name, +attribute_name, +description, +attachment_size, +mime_type, +created_at, +created_by +FROM {0}_attachments WHERE {0}_pk = ? +""", + featureType.getName()); + + List attachments = new ArrayList<>(); + JDBCDataStore ds = null; + try { + ds = (JDBCDataStore) new JDBCFeatureSourceHelper().createDataStore(featureType.getFeatureSource()); + try (Connection conn = ds.getDataSource().getConnection(); + PreparedStatement stmt = conn.prepareStatement(querySql)) { + + stmt.setString(1, featureId); + + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + AttachmentMetadata a = new AttachmentMetadata(); + // attachment_id (handle UUID, RAW(16) as byte[] or string) + Object idObj = rs.getObject("attachment_id"); + if (idObj instanceof UUID u) { + a.setAttachmentId(u); + } else if (idObj instanceof byte[] b) { + ByteBuffer bb = ByteBuffer.wrap(b); + a.setAttachmentId(new UUID(bb.getLong(), bb.getLong())); + } else { + String s = rs.getString("attachment_id"); + if (s != null && !s.isEmpty()) { + a.setAttachmentId(UUID.fromString(s)); + } + } + a.setFileName(rs.getString("file_name")); + a.setAttributeName(rs.getString("attribute_name")); + a.setDescription(rs.getString("description")); + long size = rs.getLong("attachment_size"); + if (!rs.wasNull()) { + a.setAttachmentSize(size); + } + a.setMimeType(rs.getString("mime_type")); + java.sql.Timestamp ts = rs.getTimestamp("created_at"); + if (ts != null) { + a.setCreatedAt(OffsetDateTime.ofInstant(ts.toInstant(), ZoneId.of("UTC"))); + } + a.setCreatedBy(rs.getString("created_by")); + attachments.add(a); + } + } + } + } finally { + if (ds != null) { + ds.dispose(); + } + } + return attachments; + } + + public static AttachmentWithBinary getAttachment(TMFeatureType featureType, UUID attachmentId) + throws IOException, SQLException { + + String querySql = MessageFormat.format( + "SELECT attachment, attachment_size, mime_type, file_name FROM {0}_attachments WHERE attachment_id = ?", + featureType.getName()); + JDBCDataStore ds = null; + try { + byte[] attachment; + ds = (JDBCDataStore) new JDBCFeatureSourceHelper().createDataStore(featureType.getFeatureSource()); + try (Connection conn = ds.getDataSource().getConnection(); + PreparedStatement stmt = conn.prepareStatement(querySql)) { + + if (featureType + .getFeatureSource() + .getJdbcConnection() + .getDbtype() + .equals(JDBCConnectionProperties.DbtypeEnum.ORACLE)) { + stmt.setBytes(1, asBytes(attachmentId)); + } else { + stmt.setObject(1, attachmentId); + } + + try (ResultSet rs = stmt.executeQuery()) { + if (rs.next()) { + attachment = rs.getBytes("attachment"); + AttachmentMetadata a = new AttachmentMetadata(); + long size = rs.getLong("attachment_size"); + if (!rs.wasNull()) { + a.setAttachmentSize(size); + } + a.setMimeType(rs.getString("mime_type")); + a.setFileName(rs.getString("file_name")); + return new AttachmentWithBinary( + a, ByteBuffer.wrap(attachment).asReadOnlyBuffer()); + } else { + return null; + } + } + } + } finally { + if (ds != null) { + ds.dispose(); + } + } + } + + public record AttachmentWithBinary( + @NotNull AttachmentMetadata attachmentMetadata, @NotNull ByteBuffer attachment) {} } diff --git a/src/main/resources/openapi/viewer-api.yaml b/src/main/resources/openapi/viewer-api.yaml index dc6687395..ee7c63e64 100644 --- a/src/main/resources/openapi/viewer-api.yaml +++ b/src/main/resources/openapi/viewer-api.yaml @@ -1745,6 +1745,37 @@ paths: required: true schema: type: string + get: + operationId: 'getAttachments' + description: 'Get the attachment(s) for a feature.' + responses: + '200': + description: 'successfully retrieved attachments.' + content: + application/json: + schema: + type: array + minLength: 0 + items: + $ref: './viewer-schemas.yaml#/components/schemas/AttachmentMetadata' + '401': + description: 'Unauthorized' + content: + application/json: + schema: + $ref: './status-responses.yaml#/components/schemas/RedirectResponse' + '404': + description: 'Feature not found' + content: + application/json: + schema: + $ref: './status-responses.yaml#/components/schemas/ErrorResponse' + '500': + description: 'Internal server error' + content: + application/json: + schema: + $ref: './status-responses.yaml#/components/schemas/ErrorResponse' put: operationId: 'addAttachment' description: 'Add an attachment to a feature.' @@ -1779,6 +1810,12 @@ paths: application/json: schema: $ref: './status-responses.yaml#/components/schemas/RedirectResponse' + '404': + description: 'Feature not found' + content: + application/json: + schema: + $ref: './status-responses.yaml#/components/schemas/ErrorResponse' '500': description: 'Internal server error' content: @@ -1786,3 +1823,84 @@ paths: schema: $ref: './status-responses.yaml#/components/schemas/ErrorResponse' + /{viewerKind}/{viewerName}/layer/{appLayerId}/attachment/{attachmentId}: + summary: 'Attachment operations on a single Attachment.' + description: 'Attachment operations on a single feature referenced by the featureId.' + parameters: + - in: path + name: viewerKind + required: true + schema: + type: string + enum: + - app + - service + - in: path + name: viewerName + required: true + schema: + type: string + - in: path + name: appLayerId + required: true + schema: + type: string + - in: path + name: attachmentId + required: true + schema: + type: string + format: uuid + delete: + operationId: 'deleteAttachment' + description: 'Delete an attachment from a feature.' + responses: + '204': + description: 'successfully deleted attachment' + '404': + description: 'Attachment not found' + content: + application/json: + schema: + $ref: './status-responses.yaml#/components/schemas/ErrorResponse' + '401': + description: 'Unauthorized' + content: + application/json: + schema: + $ref: './status-responses.yaml#/components/schemas/RedirectResponse' + '500': + description: 'Internal server error' + content: + application/json: + schema: + $ref: './status-responses.yaml#/components/schemas/ErrorResponse' + get: + operationId: 'getAttachment' + description: 'Get a single binary attachment from a feature.' + responses: + '200': + description: 'successfully retrieved attachment.' + content: + application/octet-stream: + schema: + type: string + format: binary + '404': + description: 'Attachment not found' + content: + application/json: + schema: + $ref: './status-responses.yaml#/components/schemas/ErrorResponse' + '401': + description: 'Unauthorized' + content: + application/json: + schema: + $ref: './status-responses.yaml#/components/schemas/RedirectResponse' + '500': + description: 'Internal server error' + content: + application/json: + schema: + $ref: './status-responses.yaml#/components/schemas/ErrorResponse' diff --git a/src/test/java/org/tailormap/api/controller/AttachmentsControllerIntegrationTest.java b/src/test/java/org/tailormap/api/controller/AttachmentsControllerIntegrationTest.java index 1334ebbbc..7e068e3a1 100644 --- a/src/test/java/org/tailormap/api/controller/AttachmentsControllerIntegrationTest.java +++ b/src/test/java/org/tailormap/api/controller/AttachmentsControllerIntegrationTest.java @@ -5,20 +5,27 @@ */ package org.tailormap.api.controller; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.tailormap.api.TestRequestProcessor.setServletPath; import static org.tailormap.api.persistence.Group.ADMIN; +import com.jayway.jsonpath.JsonPath; import java.nio.charset.StandardCharsets; +import java.util.stream.Stream; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Order; import org.junit.jupiter.api.TestInstance; import org.junit.jupiter.api.TestMethodOrder; import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.ValueSource; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; import org.junitpioneer.jupiter.Stopwatch; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; @@ -48,36 +55,43 @@ class AttachmentsControllerIntegrationTest { private MockMvc mockMvc; + private static final MockMultipartFile attachmentMetadata = new MockMultipartFile( + "attachmentMetadata", + "metadata.json", + "application/json", + """ +{ +"attributeName":"attachmentName", +"mimeType":"image/svg+xml", +"fileName":"lichtpunt.svg", +"description":"A test SVG attachment" +} +""" + .getBytes(StandardCharsets.UTF_8)); + + private static Stream testUrls() { + return Stream.of( + Arguments.of( + "/app/default/layer/lyr:snapshot-geoserver:postgis:begroeidterreindeel/feature/21f95499702e3a5d05230d2ae596ea1c/attachment"), + Arguments.of( + "/app/default/layer/lyr:snapshot-geoserver:oracle:WATERDEEL/feature/93294fda97a19c37080849c5c1fddbf3/attachment"), + Arguments.of( + "/app/default/layer/lyr:snapshot-geoserver:sqlserver:wegdeel/feature/2d323d3d98a2101c01ef1c6274085254/attachment")); + } + @BeforeAll void initialize() { mockMvc = MockMvcBuilders.webAppContextSetup(context).build(); } + @Order(1) @ParameterizedTest - @ValueSource( - strings = { - "/app/default/layer/lyr:snapshot-geoserver:postgis:begroeidterreindeel/feature/21f95499702e3a5d05230d2ae596ea1c/attachment", - "/app/default/layer/lyr:snapshot-geoserver:oracle:WATERDEEL/feature/93294fda97a19c37080849c5c1fddbf3/attachment", - "/app/default/layer/lyr:snapshot-geoserver:sqlserver:wegdeel/feature/2d323d3d98a2101c01ef1c6274085254/attachment" - }) + @MethodSource("testUrls") @WithMockUser( username = "tm-admin", authorities = {ADMIN}) void addAttachment(String url) throws Exception { url = apiBasePath + url; - MockMultipartFile attachmentMetadata = new MockMultipartFile( - "attachmentMetadata", - "metadata.json", - "application/json", - """ -{ -"attributeName":"attachmentName", -"mimeType":"image/svg+xml", -"fileName":"lichtpunt.svg", -"description":"A test SVG attachment" -} -""" - .getBytes(StandardCharsets.UTF_8)); byte[] svgBytes = new ClassPathResource("test/lichtpunt.svg").getContentAsByteArray(); @@ -92,7 +106,6 @@ void addAttachment(String url) throws Exception { }) .with(setServletPath(url)) .accept(MediaType.APPLICATION_JSON)) - .andDo(print()) .andExpect(status().isCreated()) .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) .andExpect(jsonPath("$.description").value("A test SVG attachment")) @@ -103,4 +116,131 @@ void addAttachment(String url) throws Exception { .andExpect(jsonPath("$.createdBy").isNotEmpty()) .andExpect(jsonPath("$.attachmentSize").value(svgBytes.length)); } + + @Order(1) + @ParameterizedTest + @MethodSource("testUrls") + void addAttachmentUnauthorised(String url) throws Exception { + url = apiBasePath + url; + + byte[] svgBytes = new ClassPathResource("test/lichtpunt.svg").getContentAsByteArray(); + + MockMultipartFile svgFile = new MockMultipartFile("attachment", "lichtpunt.svg", "image/svg+xml", svgBytes); + + mockMvc.perform(MockMvcRequestBuilders.multipart(url) + .file(attachmentMetadata) + .file(svgFile) + .with(request -> { + request.setMethod("PUT"); + return request; + }) + .with(setServletPath(url)) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isUnauthorized()); + } + + @Order(2) + @ParameterizedTest + @MethodSource("testUrls") + void listAttachments(String url) throws Exception { + url = apiBasePath + url; + + mockMvc.perform(get(url).with(setServletPath(url)).accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.[0].description").value("A test SVG attachment")) + .andExpect(jsonPath("$.[0].fileName").value("lichtpunt.svg")) + .andExpect(jsonPath("$.[0].mimeType").value("image/svg+xml")) + .andExpect(jsonPath("$.[0].attachmentId").isNotEmpty()) + .andExpect(jsonPath("$.[0].createdAt").isNotEmpty()) + .andExpect(jsonPath("$.[0].createdBy").isNotEmpty()) + .andExpect(jsonPath("$.[0].attachmentSize").isNotEmpty()); + } + + @Order(2) + @ParameterizedTest + @MethodSource("testUrls") + void getAttachment(String url) throws Exception { + url = apiBasePath + url; + + // First get the list of attachments to retrieve the attachmentId + String responseContent = mockMvc.perform( + get(url).with(setServletPath(url)).accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andReturn() + .getResponse() + .getContentAsString(); + + // Extract attachmentId from the response (assuming it's the first attachment) + String attachmentId = JsonPath.read(responseContent, "$.[0].attachmentId"); + // Now get the actual attachment + String attachmentUrl = url.substring(0, url.indexOf("/feature")) + "/attachment/" + attachmentId; + + mockMvc.perform(get(attachmentUrl) + .with(setServletPath(attachmentUrl)) + .accept(MediaType.APPLICATION_OCTET_STREAM)) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(content().contentType("image/svg+xml")) + .andExpect(header().string("Content-Type", "image/svg+xml")) + .andExpect(header().string("Content-Disposition", "inline; filename=\"lichtpunt.svg\"")) + .andExpect(header().string("Content-Type", "image/svg+xml")) + .andExpect(content() + .bytes(new ClassPathResource("test/lichtpunt.svg") + .getInputStream() + .readAllBytes())); + } + + @Order(Integer.MAX_VALUE) + @ParameterizedTest + @MethodSource("testUrls") + @WithMockUser( + username = "tm-admin", + authorities = {ADMIN}) + void deleteAttachment(String url) throws Exception { + url = apiBasePath + url; + + // First get the list of attachments to retrieve the attachmentId + String responseContent = mockMvc.perform( + get(url).with(setServletPath(url)).accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andReturn() + .getResponse() + .getContentAsString(); + + // Extract attachmentId from the response (assuming it's the first attachment) + String attachmentId = JsonPath.read(responseContent, "$.[0].attachmentId"); + // Now get the actual attachment + String attachmentUrl = url.substring(0, url.indexOf("/feature")) + "/attachment/" + attachmentId; + + mockMvc.perform(delete(attachmentUrl) + .with(setServletPath(attachmentUrl)) + .accept(MediaType.APPLICATION_OCTET_STREAM)) + .andExpect(status().isNoContent()); + } + + @Order(2) + @ParameterizedTest + @MethodSource("testUrls") + void deleteAttachmentUnauthorised(String url) throws Exception { + url = apiBasePath + url; + + // First get the list of attachments to retrieve the attachmentId + String responseContent = mockMvc.perform( + get(url).with(setServletPath(url)).accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andReturn() + .getResponse() + .getContentAsString(); + + // Extract attachmentId from the response (assuming it's the first attachment) + String attachmentId = JsonPath.read(responseContent, "$.[0].attachmentId"); + // Now get the actual attachment + String attachmentUrl = url.substring(0, url.indexOf("/feature")) + "/attachment/" + attachmentId; + + mockMvc.perform(delete(attachmentUrl) + .with(setServletPath(attachmentUrl)) + .accept(MediaType.APPLICATION_OCTET_STREAM)) + .andExpect(status().isUnauthorized()); + } } From 6db29550f25beb79cff36ce5e81e9c8ecec65308 Mon Sep 17 00:00:00 2001 From: Mark Prins <1165786+mprins@users.noreply.github.com> Date: Thu, 6 Nov 2025 15:08:27 +0100 Subject: [PATCH 02/14] Add Transactional tp delete because we need the lazy-loaded attachment attributes --- .../java/org/tailormap/api/controller/AttachmentsController.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/org/tailormap/api/controller/AttachmentsController.java b/src/main/java/org/tailormap/api/controller/AttachmentsController.java index e4c5df7db..598672141 100644 --- a/src/main/java/org/tailormap/api/controller/AttachmentsController.java +++ b/src/main/java/org/tailormap/api/controller/AttachmentsController.java @@ -169,6 +169,7 @@ public ResponseEntity> listAttachments( @DeleteMapping( path = "${tailormap-api.base-path}/{viewerKind}/{viewerName}/layer/{appLayerId}/attachment/{attachmentId}") + @Transactional public ResponseEntity deleteAttachment( @ModelAttribute AppTreeLayerNode appTreeLayerNode, @ModelAttribute GeoService service, From 463b0aae07288b4a1814797c0163ee0eb7bddfb9 Mon Sep 17 00:00:00 2001 From: Matthijs Laan Date: Thu, 6 Nov 2025 15:51:55 +0100 Subject: [PATCH 03/14] Change to PUT, remove mime type check --- .../tailormap/api/controller/AttachmentsController.java | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/main/java/org/tailormap/api/controller/AttachmentsController.java b/src/main/java/org/tailormap/api/controller/AttachmentsController.java index a14652a6e..0d4657db4 100644 --- a/src/main/java/org/tailormap/api/controller/AttachmentsController.java +++ b/src/main/java/org/tailormap/api/controller/AttachmentsController.java @@ -29,7 +29,7 @@ import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestPart; import org.springframework.web.server.ResponseStatusException; import org.tailormap.api.annotation.AppRestController; @@ -72,7 +72,7 @@ public AttachmentsController(EditUtil editUtil, FeatureSourceFactoryHelper featu * @param fileData the attachment file data * @return the response entity */ - @PutMapping( + @PostMapping( path = { "${tailormap-api.base-path}/{viewerKind}/{viewerName}/layer/{appLayerId}/feature/{featureId}/attachment" }, @@ -100,9 +100,8 @@ public ResponseEntity addAttachment( AttachmentAttributeType attachmentAttributeType = attachmentAttrSet.stream() .filter(attr -> (attr.getAttributeName().equals(attachment.getAttributeName()) - && java.util.Arrays.stream(attr.getMimeType().split(",")) - .map(String::trim) - .anyMatch(mime -> mime.equals(attachment.getMimeType())) + // TODO check attr.getMimeType() as "accept" attribute for HTML file input (null, mime types but + // also extensions) && (attr.getMaxAttachmentSize() == null || attr.getMaxAttachmentSize() >= fileData.length))) .findFirst() .orElseThrow(() -> new ResponseStatusException( From 85b7fbc26b1b697009e84088ca5c974af5e35285 Mon Sep 17 00:00:00 2001 From: Matthijs Laan Date: Thu, 6 Nov 2025 15:53:34 +0100 Subject: [PATCH 04/14] Temp workaround: type of foreign key is not always string (use Long for testing for now), use part of featureId after the feature type name --- .../api/geotools/featuresources/AttachmentsHelper.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/tailormap/api/geotools/featuresources/AttachmentsHelper.java b/src/main/java/org/tailormap/api/geotools/featuresources/AttachmentsHelper.java index 5b52f94f3..a7a43bbe0 100644 --- a/src/main/java/org/tailormap/api/geotools/featuresources/AttachmentsHelper.java +++ b/src/main/java/org/tailormap/api/geotools/featuresources/AttachmentsHelper.java @@ -396,7 +396,7 @@ public static AttachmentMetadata insertAttachment( try (Connection conn = ds.getDataSource().getConnection(); PreparedStatement stmt = conn.prepareStatement(insertSql)) { - stmt.setString(1, featureId); + stmt.setLong(1, Long.parseLong(featureId.substring(featureId.indexOf('.') + 1))); if (featureType .getFeatureSource() .getJdbcConnection() From e82fe2c9308aefca3fb438e35d7ee11e78a1a3e1 Mon Sep 17 00:00:00 2001 From: Mark Prins <1165786+mprins@users.noreply.github.com> Date: Thu, 6 Nov 2025 15:53:58 +0100 Subject: [PATCH 05/14] first check the type, then the feature, this could save one database query --- .../org/tailormap/api/controller/AttachmentsController.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/tailormap/api/controller/AttachmentsController.java b/src/main/java/org/tailormap/api/controller/AttachmentsController.java index 598672141..f20226095 100644 --- a/src/main/java/org/tailormap/api/controller/AttachmentsController.java +++ b/src/main/java/org/tailormap/api/controller/AttachmentsController.java @@ -154,8 +154,8 @@ public ResponseEntity> listAttachments( TMFeatureType tmFeatureType = editUtil.getEditableFeatureType(application, appTreeLayerNode, service, layer); - checkFeatureExists(tmFeatureType, featureId); checkFeatureTypeSupportsAttachments(tmFeatureType); + checkFeatureExists(tmFeatureType, featureId); List response; try { From 0dea8c0cf8420869d4fbc8cb0e6bcba882ddab27 Mon Sep 17 00:00:00 2001 From: Mark Prins <1165786+mprins@users.noreply.github.com> Date: Thu, 6 Nov 2025 16:04:06 +0100 Subject: [PATCH 06/14] HTM-1764: AttachmentsHelper should check the type of primary key of the feature and use that when inserting --- .../api/geotools/featuresources/AttachmentsHelper.java | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/tailormap/api/geotools/featuresources/AttachmentsHelper.java b/src/main/java/org/tailormap/api/geotools/featuresources/AttachmentsHelper.java index 0b2705293..c216905ce 100644 --- a/src/main/java/org/tailormap/api/geotools/featuresources/AttachmentsHelper.java +++ b/src/main/java/org/tailormap/api/geotools/featuresources/AttachmentsHelper.java @@ -393,10 +393,16 @@ public static AttachmentMetadata insertAttachment( JDBCDataStore ds = null; try { ds = (JDBCDataStore) new JDBCFeatureSourceHelper().createDataStore(featureType.getFeatureSource()); + + Class typeOfPK = ds.getSchema(featureType.getName()) + .getDescriptor(featureType.getPrimaryKeyAttribute()) + .getType() + .getBinding(); + try (Connection conn = ds.getDataSource().getConnection(); PreparedStatement stmt = conn.prepareStatement(insertSql)) { - stmt.setString(1, featureId); + stmt.setObject(1, featureId, ds.getMapping(typeOfPK)); if (featureType .getFeatureSource() .getJdbcConnection() From b900b1607e6992572d6bb6a2ed01516e185f6345 Mon Sep 17 00:00:00 2001 From: Mark Prins <1165786+mprins@users.noreply.github.com> Date: Thu, 6 Nov 2025 16:05:57 +0100 Subject: [PATCH 07/14] Update src/main/resources/openapi/viewer-api.yaml Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/main/resources/openapi/viewer-api.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/openapi/viewer-api.yaml b/src/main/resources/openapi/viewer-api.yaml index ee7c63e64..5bf9b9794 100644 --- a/src/main/resources/openapi/viewer-api.yaml +++ b/src/main/resources/openapi/viewer-api.yaml @@ -1825,7 +1825,7 @@ paths: /{viewerKind}/{viewerName}/layer/{appLayerId}/attachment/{attachmentId}: summary: 'Attachment operations on a single Attachment.' - description: 'Attachment operations on a single feature referenced by the featureId.' + description: 'Attachment operations on a single attachment referenced by the attachmentId.' parameters: - in: path name: viewerKind From 1ae7db1c807b3dcc1a8d5b28b663f4e67cafcd6d Mon Sep 17 00:00:00 2001 From: Mark Prins <1165786+mprins@users.noreply.github.com> Date: Thu, 6 Nov 2025 16:07:48 +0100 Subject: [PATCH 08/14] Update src/main/java/org/tailormap/api/controller/AttachmentsController.java Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../org/tailormap/api/controller/AttachmentsController.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/tailormap/api/controller/AttachmentsController.java b/src/main/java/org/tailormap/api/controller/AttachmentsController.java index f20226095..abcf99709 100644 --- a/src/main/java/org/tailormap/api/controller/AttachmentsController.java +++ b/src/main/java/org/tailormap/api/controller/AttachmentsController.java @@ -130,14 +130,14 @@ public ResponseEntity addAttachment( } /** - * Add an attachment to a feature + * List attachments for a feature. * * @param appTreeLayerNode the application tree layer node * @param service the geo service * @param layer the geo service layer * @param application the application * @param featureId the feature id - * @return the response entity + * @return the response entity containing a list of attachment metadata */ @GetMapping( path = { From 6a63c91b807965d7afde9d058de0d77525f7119f Mon Sep 17 00:00:00 2001 From: Mark Prins <1165786+mprins@users.noreply.github.com> Date: Thu, 6 Nov 2025 16:08:02 +0100 Subject: [PATCH 09/14] Update src/main/resources/openapi/viewer-api.yaml Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/main/resources/openapi/viewer-api.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/openapi/viewer-api.yaml b/src/main/resources/openapi/viewer-api.yaml index 5bf9b9794..49cb6f1d1 100644 --- a/src/main/resources/openapi/viewer-api.yaml +++ b/src/main/resources/openapi/viewer-api.yaml @@ -1755,7 +1755,7 @@ paths: application/json: schema: type: array - minLength: 0 + minItems: 0 items: $ref: './viewer-schemas.yaml#/components/schemas/AttachmentMetadata' '401': From 89d52ecc240fe9c49aa8545efc6f3cd58eb5a61c Mon Sep 17 00:00:00 2001 From: Mark Prins <1165786+mprins@users.noreply.github.com> Date: Thu, 6 Nov 2025 16:08:14 +0100 Subject: [PATCH 10/14] Update src/test/java/org/tailormap/api/controller/AttachmentsControllerIntegrationTest.java Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../api/controller/AttachmentsControllerIntegrationTest.java | 1 - 1 file changed, 1 deletion(-) diff --git a/src/test/java/org/tailormap/api/controller/AttachmentsControllerIntegrationTest.java b/src/test/java/org/tailormap/api/controller/AttachmentsControllerIntegrationTest.java index 7e068e3a1..687596715 100644 --- a/src/test/java/org/tailormap/api/controller/AttachmentsControllerIntegrationTest.java +++ b/src/test/java/org/tailormap/api/controller/AttachmentsControllerIntegrationTest.java @@ -179,7 +179,6 @@ void getAttachment(String url) throws Exception { mockMvc.perform(get(attachmentUrl) .with(setServletPath(attachmentUrl)) .accept(MediaType.APPLICATION_OCTET_STREAM)) - .andDo(print()) .andExpect(status().isOk()) .andExpect(content().contentType("image/svg+xml")) .andExpect(header().string("Content-Type", "image/svg+xml")) From 1909b85a9c24588e23cade868e6442a3587e2c70 Mon Sep 17 00:00:00 2001 From: Mark Prins <1165786+mprins@users.noreply.github.com> Date: Thu, 6 Nov 2025 16:08:37 +0100 Subject: [PATCH 11/14] Update src/test/java/org/tailormap/api/controller/AttachmentsControllerIntegrationTest.java Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../api/controller/AttachmentsControllerIntegrationTest.java | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/test/java/org/tailormap/api/controller/AttachmentsControllerIntegrationTest.java b/src/test/java/org/tailormap/api/controller/AttachmentsControllerIntegrationTest.java index 687596715..e8dc6d815 100644 --- a/src/test/java/org/tailormap/api/controller/AttachmentsControllerIntegrationTest.java +++ b/src/test/java/org/tailormap/api/controller/AttachmentsControllerIntegrationTest.java @@ -7,7 +7,6 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; @@ -183,7 +182,6 @@ void getAttachment(String url) throws Exception { .andExpect(content().contentType("image/svg+xml")) .andExpect(header().string("Content-Type", "image/svg+xml")) .andExpect(header().string("Content-Disposition", "inline; filename=\"lichtpunt.svg\"")) - .andExpect(header().string("Content-Type", "image/svg+xml")) .andExpect(content() .bytes(new ClassPathResource("test/lichtpunt.svg") .getInputStream() From 94303f85c565089071af2e23414c63519c8e5a67 Mon Sep 17 00:00:00 2001 From: Mark Prins <1165786+mprins@users.noreply.github.com> Date: Fri, 7 Nov 2025 09:28:10 +0100 Subject: [PATCH 12/14] Use plurals for the attachments endpoint that related to a feature and add testcases --- .../api/controller/AttachmentsController.java | 10 +-- src/main/resources/openapi/viewer-api.yaml | 2 +- .../AttachmentsControllerIntegrationTest.java | 68 ++++++++++++++++++- 3 files changed, 71 insertions(+), 9 deletions(-) diff --git a/src/main/java/org/tailormap/api/controller/AttachmentsController.java b/src/main/java/org/tailormap/api/controller/AttachmentsController.java index abcf99709..e5cf9fc17 100644 --- a/src/main/java/org/tailormap/api/controller/AttachmentsController.java +++ b/src/main/java/org/tailormap/api/controller/AttachmentsController.java @@ -76,7 +76,7 @@ public AttachmentsController(EditUtil editUtil, FeatureSourceFactoryHelper featu */ @PutMapping( path = { - "${tailormap-api.base-path}/{viewerKind}/{viewerName}/layer/{appLayerId}/feature/{featureId}/attachment" + "${tailormap-api.base-path}/{viewerKind}/{viewerName}/layer/{appLayerId}/feature/{featureId}/attachments" }, consumes = MediaType.MULTIPART_FORM_DATA_VALUE, produces = MediaType.APPLICATION_JSON_VALUE) @@ -99,7 +99,7 @@ public ResponseEntity addAttachment( Set<@Valid AttachmentAttributeType> attachmentAttrSet = tmFeatureType.getSettings().getAttachmentAttributes(); if (attachmentAttrSet == null || attachmentAttrSet.isEmpty()) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Feature type does not support attachments"); + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Layer does not support attachments"); } AttachmentAttributeType attachmentAttributeType = attachmentAttrSet.stream() @@ -111,7 +111,7 @@ public ResponseEntity addAttachment( .findFirst() .orElseThrow(() -> new ResponseStatusException( HttpStatus.BAD_REQUEST, - "Feature type does not support attachments for attribute " + "Layer does not support attachments for attribute " + attachment.getAttributeName() + " with mime type " + attachment.getMimeType() @@ -141,7 +141,7 @@ public ResponseEntity addAttachment( */ @GetMapping( path = { - "${tailormap-api.base-path}/{viewerKind}/{viewerName}/layer/{appLayerId}/feature/{featureId}/attachment" + "${tailormap-api.base-path}/{viewerKind}/{viewerName}/layer/{appLayerId}/feature/{featureId}/attachments" }, produces = MediaType.APPLICATION_JSON_VALUE) @Transactional @@ -256,7 +256,7 @@ private void checkFeatureTypeSupportsAttachments(TMFeatureType tmFeatureType) th Set<@Valid AttachmentAttributeType> attachmentAttrSet = tmFeatureType.getSettings().getAttachmentAttributes(); if (attachmentAttrSet == null || attachmentAttrSet.isEmpty()) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Feature type does not support attachments"); + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Layer does not support attachments"); } } } diff --git a/src/main/resources/openapi/viewer-api.yaml b/src/main/resources/openapi/viewer-api.yaml index 49cb6f1d1..e17a7919c 100644 --- a/src/main/resources/openapi/viewer-api.yaml +++ b/src/main/resources/openapi/viewer-api.yaml @@ -1718,7 +1718,7 @@ paths: schema: $ref: './status-responses.yaml#/components/schemas/ErrorResponse' - /{viewerKind}/{viewerName}/layer/{appLayerId}/feature/{featureId}/attachment: + /{viewerKind}/{viewerName}/layer/{appLayerId}/feature/{featureId}/attachments: summary: 'Attachment operations on a single feature.' description: 'Attachment operations on a single feature referenced by the featureId.' parameters: diff --git a/src/test/java/org/tailormap/api/controller/AttachmentsControllerIntegrationTest.java b/src/test/java/org/tailormap/api/controller/AttachmentsControllerIntegrationTest.java index e8dc6d815..0d95a419a 100644 --- a/src/test/java/org/tailormap/api/controller/AttachmentsControllerIntegrationTest.java +++ b/src/test/java/org/tailormap/api/controller/AttachmentsControllerIntegrationTest.java @@ -7,6 +7,7 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; @@ -20,6 +21,7 @@ import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.MethodOrderer; import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; import org.junit.jupiter.api.TestInstance; import org.junit.jupiter.api.TestMethodOrder; import org.junit.jupiter.params.ParameterizedTest; @@ -71,13 +73,19 @@ class AttachmentsControllerIntegrationTest { private static Stream testUrls() { return Stream.of( Arguments.of( - "/app/default/layer/lyr:snapshot-geoserver:postgis:begroeidterreindeel/feature/21f95499702e3a5d05230d2ae596ea1c/attachment"), + "/app/default/layer/lyr:snapshot-geoserver:postgis:begroeidterreindeel/feature/21f95499702e3a5d05230d2ae596ea1c/attachments"), Arguments.of( - "/app/default/layer/lyr:snapshot-geoserver:oracle:WATERDEEL/feature/93294fda97a19c37080849c5c1fddbf3/attachment"), + "/app/default/layer/lyr:snapshot-geoserver:oracle:WATERDEEL/feature/93294fda97a19c37080849c5c1fddbf3/attachments"), Arguments.of( - "/app/default/layer/lyr:snapshot-geoserver:sqlserver:wegdeel/feature/2d323d3d98a2101c01ef1c6274085254/attachment")); + "/app/default/layer/lyr:snapshot-geoserver:sqlserver:wegdeel/feature/2d323d3d98a2101c01ef1c6274085254/attachments")); } + private static final String layerNotEditableUrl = + "/app/default/layer/lyr:snapshot-geoserver:postgis:bak/feature/dbbe3dd9c3e45f1261faf5f74c67e19e/attachments"; + + private static final String attachmentsNotSupportedUrl = + "/app/default/layer/lyr:snapshot-geoserver:postgis:osm_polygon/feature/299933373/attachments"; + @BeforeAll void initialize() { mockMvc = MockMvcBuilders.webAppContextSetup(context).build(); @@ -138,6 +146,60 @@ void addAttachmentUnauthorised(String url) throws Exception { .andExpect(status().isUnauthorized()); } + @Order(1) + @Test + @WithMockUser( + username = "tm-admin", + authorities = {ADMIN}) + void addAttachmentsNotSupported() throws Exception { + String url = apiBasePath + attachmentsNotSupportedUrl; + + byte[] svgBytes = new ClassPathResource("test/lichtpunt.svg").getContentAsByteArray(); + + MockMultipartFile svgFile = new MockMultipartFile("attachment", "lichtpunt.svg", "image/svg+xml", svgBytes); + + mockMvc.perform(MockMvcRequestBuilders.multipart(url) + .file(attachmentMetadata) + .file(svgFile) + .with(request -> { + request.setMethod("PUT"); + return request; + }) + .with(setServletPath(url)) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()) + .andDo(print()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.message").value("Layer does not support attachments")); + } + + @Order(1) + @Test + @WithMockUser( + username = "tm-admin", + authorities = {ADMIN}) + void addAttachmentsToNonEditableLayer() throws Exception { + String url = apiBasePath + layerNotEditableUrl; + + byte[] svgBytes = new ClassPathResource("test/lichtpunt.svg").getContentAsByteArray(); + + MockMultipartFile svgFile = new MockMultipartFile("attachment", "lichtpunt.svg", "image/svg+xml", svgBytes); + + mockMvc.perform(MockMvcRequestBuilders.multipart(url) + .file(attachmentMetadata) + .file(svgFile) + .with(request -> { + request.setMethod("PUT"); + return request; + }) + .with(setServletPath(url)) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()) + .andDo(print()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.message").value("Layer is not editable")); + } + @Order(2) @ParameterizedTest @MethodSource("testUrls") From 072e04a681f0a601cdcc551f6e324952fc2e64d8 Mon Sep 17 00:00:00 2001 From: Matthijs Laan Date: Fri, 7 Nov 2025 09:53:29 +0100 Subject: [PATCH 13/14] Fix attachment primary key param --- .../featuresources/AttachmentsHelper.java | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/tailormap/api/geotools/featuresources/AttachmentsHelper.java b/src/main/java/org/tailormap/api/geotools/featuresources/AttachmentsHelper.java index c216905ce..b064b81b7 100644 --- a/src/main/java/org/tailormap/api/geotools/featuresources/AttachmentsHelper.java +++ b/src/main/java/org/tailormap/api/geotools/featuresources/AttachmentsHelper.java @@ -27,6 +27,8 @@ import org.geotools.jdbc.JDBCDataStore; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.core.convert.ConversionService; +import org.springframework.format.support.DefaultFormattingConversionService; import org.springframework.security.core.context.SecurityContextHolder; import org.tailormap.api.persistence.TMFeatureType; import org.tailormap.api.persistence.json.JDBCConnectionProperties; @@ -57,6 +59,8 @@ public final class AttachmentsHelper { "NUMBER", "RAW"); + private static final ConversionService conversionService = new DefaultFormattingConversionService(); + private AttachmentsHelper() { // private constructor for utility class } @@ -402,7 +406,20 @@ public static AttachmentMetadata insertAttachment( try (Connection conn = ds.getDataSource().getConnection(); PreparedStatement stmt = conn.prepareStatement(insertSql)) { - stmt.setObject(1, featureId, ds.getMapping(typeOfPK)); + String pkStringValue = featureId.substring(featureId.indexOf(".") + 1); + Object pkValue = pkStringValue; + if (!typeOfPK.isAssignableFrom(String.class)) { + try { + pkValue = conversionService.convert(pkStringValue, typeOfPK); + } catch (RuntimeException ex) { + throw new SQLException( + "Failed to convert \"%s\" to primary key type %s: %s" + .formatted(pkStringValue, typeOfPK.getName(), ex.getMessage()), + ex); + } + } + stmt.setObject(1, pkValue, ds.getMapping(typeOfPK)); + if (featureType .getFeatureSource() .getJdbcConnection() From 2a71c0428396ecdd8bb727b10ecb7bfb6ca31f7c Mon Sep 17 00:00:00 2001 From: Matthijs Laan Date: Fri, 7 Nov 2025 10:15:31 +0100 Subject: [PATCH 14/14] Fix attachment primary key param using primary key object from loading feature --- .../api/controller/AttachmentsController.java | 29 +++++++++++------ .../featuresources/AttachmentsHelper.java | 31 +++---------------- 2 files changed, 24 insertions(+), 36 deletions(-) diff --git a/src/main/java/org/tailormap/api/controller/AttachmentsController.java b/src/main/java/org/tailormap/api/controller/AttachmentsController.java index fa18dc4d1..a3e3a616b 100644 --- a/src/main/java/org/tailormap/api/controller/AttachmentsController.java +++ b/src/main/java/org/tailormap/api/controller/AttachmentsController.java @@ -12,12 +12,14 @@ import java.nio.ByteBuffer; import java.sql.SQLException; import java.util.List; +import java.util.NoSuchElementException; import java.util.Set; import java.util.UUID; import org.geotools.api.data.Query; import org.geotools.api.data.SimpleFeatureSource; import org.geotools.api.filter.Filter; import org.geotools.api.filter.FilterFactory; +import org.geotools.data.simple.SimpleFeatureIterator; import org.geotools.factory.CommonFactoryFinder; import org.geotools.util.factory.GeoTools; import org.slf4j.Logger; @@ -94,7 +96,7 @@ public ResponseEntity addAttachment( TMFeatureType tmFeatureType = editUtil.getEditableFeatureType(application, appTreeLayerNode, service, layer); - checkFeatureExists(tmFeatureType, featureId); + Object primaryKey = getFeaturePrimaryKeyByFid(tmFeatureType, featureId); Set<@Valid AttachmentAttributeType> attachmentAttrSet = tmFeatureType.getSettings().getAttachmentAttributes(); @@ -120,7 +122,7 @@ public ResponseEntity addAttachment( AttachmentMetadata response; try { - response = AttachmentsHelper.insertAttachment(tmFeatureType, attachment, featureId, fileData); + response = AttachmentsHelper.insertAttachment(tmFeatureType, attachment, primaryKey, fileData); } catch (IOException | SQLException e) { throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, e.getMessage()); } @@ -154,11 +156,11 @@ public ResponseEntity> listAttachments( TMFeatureType tmFeatureType = editUtil.getEditableFeatureType(application, appTreeLayerNode, service, layer); checkFeatureTypeSupportsAttachments(tmFeatureType); - checkFeatureExists(tmFeatureType, featureId); + Object primaryKey = getFeaturePrimaryKeyByFid(tmFeatureType, featureId); List response; try { - response = AttachmentsHelper.listAttachmentsForFeature(tmFeatureType, featureId); + response = AttachmentsHelper.listAttachmentsForFeature(tmFeatureType, primaryKey); } catch (IOException | SQLException e) { throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, e.getMessage()); } @@ -231,20 +233,27 @@ public ResponseEntity getAttachment( } } - private void checkFeatureExists(TMFeatureType tmFeatureType, String featureId) throws ResponseStatusException { + private Object getFeaturePrimaryKeyByFid(TMFeatureType tmFeatureType, String featureId) + throws ResponseStatusException { final Filter fidFilter = ff.id(ff.featureId(featureId)); SimpleFeatureSource fs = null; + SimpleFeatureIterator sfi = null; try { fs = featureSourceFactoryHelper.openGeoToolsFeatureSource(tmFeatureType); Query query = new Query(); query.setFilter(fidFilter); - if (fs.getCount(query) < 1) { - throw new ResponseStatusException( - HttpStatus.NOT_FOUND, "Feature with id " + featureId + " does not exist"); - } + query.setPropertyNames(tmFeatureType.getPrimaryKeyAttribute()); + sfi = fs.getFeatures(query).features(); + return sfi.next().getAttribute(tmFeatureType.getPrimaryKeyAttribute()); + } catch (NoSuchElementException e) { + throw new ResponseStatusException( + HttpStatus.NOT_FOUND, "Feature with id %s does not exist".formatted(featureId)); } catch (IOException e) { - throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, e.getMessage()); + throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, e.getMessage(), e); } finally { + if (sfi != null) { + sfi.close(); + } if (fs != null) { fs.getDataStore().dispose(); } diff --git a/src/main/java/org/tailormap/api/geotools/featuresources/AttachmentsHelper.java b/src/main/java/org/tailormap/api/geotools/featuresources/AttachmentsHelper.java index b064b81b7..cbe80e22d 100644 --- a/src/main/java/org/tailormap/api/geotools/featuresources/AttachmentsHelper.java +++ b/src/main/java/org/tailormap/api/geotools/featuresources/AttachmentsHelper.java @@ -27,8 +27,6 @@ import org.geotools.jdbc.JDBCDataStore; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.springframework.core.convert.ConversionService; -import org.springframework.format.support.DefaultFormattingConversionService; import org.springframework.security.core.context.SecurityContextHolder; import org.tailormap.api.persistence.TMFeatureType; import org.tailormap.api.persistence.json.JDBCConnectionProperties; @@ -59,8 +57,6 @@ public final class AttachmentsHelper { "NUMBER", "RAW"); - private static final ConversionService conversionService = new DefaultFormattingConversionService(); - private AttachmentsHelper() { // private constructor for utility class } @@ -358,7 +354,7 @@ private static byte[] asBytes(UUID uuid) { } public static AttachmentMetadata insertAttachment( - TMFeatureType featureType, AttachmentMetadata attachment, String featureId, byte[] fileData) + TMFeatureType featureType, AttachmentMetadata attachment, Object primaryKey, byte[] fileData) throws IOException, SQLException { // create uuid here so we don't have to deal with DB-specific returning/generated key syntax @@ -372,7 +368,7 @@ public static AttachmentMetadata insertAttachment( "Adding attachment {} for feature {}:{}, type {}: {} (bytes: {})", attachment.getAttachmentId(), featureType.getName(), - featureId, + primaryKey, attachment.getMimeType(), attachment, fileData.length); @@ -398,27 +394,10 @@ public static AttachmentMetadata insertAttachment( try { ds = (JDBCDataStore) new JDBCFeatureSourceHelper().createDataStore(featureType.getFeatureSource()); - Class typeOfPK = ds.getSchema(featureType.getName()) - .getDescriptor(featureType.getPrimaryKeyAttribute()) - .getType() - .getBinding(); - try (Connection conn = ds.getDataSource().getConnection(); PreparedStatement stmt = conn.prepareStatement(insertSql)) { - String pkStringValue = featureId.substring(featureId.indexOf(".") + 1); - Object pkValue = pkStringValue; - if (!typeOfPK.isAssignableFrom(String.class)) { - try { - pkValue = conversionService.convert(pkStringValue, typeOfPK); - } catch (RuntimeException ex) { - throw new SQLException( - "Failed to convert \"%s\" to primary key type %s: %s" - .formatted(pkStringValue, typeOfPK.getName(), ex.getMessage()), - ex); - } - } - stmt.setObject(1, pkValue, ds.getMapping(typeOfPK)); + stmt.setObject(1, primaryKey); if (featureType .getFeatureSource() @@ -479,7 +458,7 @@ public static void deleteAttachment(UUID attachmentId, TMFeatureType featureType } } - public static List listAttachmentsForFeature(TMFeatureType featureType, String featureId) + public static List listAttachmentsForFeature(TMFeatureType featureType, Object primaryKey) throws IOException, SQLException { String querySql = MessageFormat.format( @@ -505,7 +484,7 @@ public static List listAttachmentsForFeature(TMFeatureType f try (Connection conn = ds.getDataSource().getConnection(); PreparedStatement stmt = conn.prepareStatement(querySql)) { - stmt.setString(1, featureId); + stmt.setObject(1, primaryKey); try (ResultSet rs = stmt.executeQuery()) { while (rs.next()) {