Skip to content

Commit f8e7670

Browse files
authored
HTM-1735: API for uploading feature attachments (#1465)
* HTM-1735: API for uploading feature attachments * Optimize checkFeatureExists to use getCount(Query) instead of getFeatures().isEmpty() (#1466) * fix test value, see f3cfed1#commitcomment-169783540
1 parent f3cfed1 commit f8e7670

File tree

8 files changed

+513
-5
lines changed

8 files changed

+513
-5
lines changed

src/main/java/org/tailormap/api/configuration/dev/PopulateTestData.java

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
import java.io.IOException;
2323
import java.lang.invoke.MethodHandles;
2424
import java.nio.charset.StandardCharsets;
25+
import java.sql.SQLException;
2526
import java.time.OffsetDateTime;
2627
import java.time.ZoneId;
2728
import java.util.ArrayList;
@@ -48,6 +49,7 @@
4849
import org.springframework.transaction.annotation.Transactional;
4950
import org.springframework.util.PropertyPlaceholderHelper;
5051
import org.tailormap.api.admin.model.TaskSchedule;
52+
import org.tailormap.api.geotools.featuresources.AttachmentsHelper;
5153
import org.tailormap.api.geotools.featuresources.FeatureSourceFactoryHelper;
5254
import org.tailormap.api.geotools.featuresources.JDBCFeatureSourceHelper;
5355
import org.tailormap.api.geotools.featuresources.WFSFeatureSourceHelper;
@@ -70,6 +72,7 @@
7072
import org.tailormap.api.persistence.json.AppTreeLevelNode;
7173
import org.tailormap.api.persistence.json.AppTreeNode;
7274
import org.tailormap.api.persistence.json.AppUiSettings;
75+
import org.tailormap.api.persistence.json.AttachmentAttributeType;
7376
import org.tailormap.api.persistence.json.AttributeSettings;
7477
import org.tailormap.api.persistence.json.AttributeValueSettings;
7578
import org.tailormap.api.persistence.json.AuthorizationRule;
@@ -919,6 +922,16 @@ private void createCatalogTestData() throws Exception {
919922
ft.getSettings().addAttributeOrderItem("identificatie");
920923
ft.getSettings().addAttributeOrderItem("bronhouder");
921924
ft.getSettings().addAttributeOrderItem("class");
925+
ft.getSettings()
926+
.addAttachmentAttributesItem(new AttachmentAttributeType()
927+
.attributeName("attachmentName")
928+
.maxAttachmentSize(4_000_000L)
929+
.mimeType("image/jpeg, image/svg+xml"));
930+
try {
931+
AttachmentsHelper.createAttachmentTableForFeatureType(ft);
932+
} catch (IOException | SQLException e) {
933+
throw new RuntimeException("Failed to create attachments table", e);
934+
}
922935
});
923936

924937
featureSources.get("postgis").getFeatureTypes().stream()
@@ -939,6 +952,37 @@ private void createCatalogTestData() throws Exception {
939952
ft.getSettings().addHideAttributesItem("plus_type");
940953
});
941954

955+
featureSources.get("oracle").getFeatureTypes().stream()
956+
.filter(ft -> ft.getName().equals("WATERDEEL"))
957+
.findFirst()
958+
.ifPresent(ft -> {
959+
ft.getSettings()
960+
.addAttachmentAttributesItem(new AttachmentAttributeType()
961+
.attributeName("attachmentName")
962+
.maxAttachmentSize(4_000_000L)
963+
.mimeType("image/jpeg, image/svg+xml"));
964+
try {
965+
AttachmentsHelper.createAttachmentTableForFeatureType(ft);
966+
} catch (IOException | SQLException e) {
967+
throw new RuntimeException("Failed to create attachments table", e);
968+
}
969+
});
970+
featureSources.get("sqlserver").getFeatureTypes().stream()
971+
.filter(ft -> ft.getName().equals("wegdeel"))
972+
.findFirst()
973+
.ifPresent(ft -> {
974+
ft.getSettings()
975+
.addAttachmentAttributesItem(new AttachmentAttributeType()
976+
.attributeName("attachmentName")
977+
.maxAttachmentSize(4_000_000L)
978+
.mimeType("image/jpeg, image/svg+xml"));
979+
try {
980+
AttachmentsHelper.createAttachmentTableForFeatureType(ft);
981+
} catch (IOException | SQLException e) {
982+
throw new RuntimeException("Failed to create attachments table", e);
983+
}
984+
});
985+
942986
featureSources.get("postgis").getFeatureTypes().stream()
943987
.filter(ft -> ft.getName().equals("kadastraal_perceel"))
944988
.findFirst()
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
/*
2+
* Copyright (C) 2025 B3Partners B.V.
3+
*
4+
* SPDX-License-Identifier: MIT
5+
*/
6+
package org.tailormap.api.controller;
7+
8+
import jakarta.validation.Valid;
9+
import java.io.IOException;
10+
import java.io.Serializable;
11+
import java.lang.invoke.MethodHandles;
12+
import java.sql.SQLException;
13+
import java.util.Set;
14+
import java.util.UUID;
15+
import org.geotools.api.data.Query;
16+
import org.geotools.api.data.SimpleFeatureSource;
17+
import org.geotools.api.filter.Filter;
18+
import org.geotools.api.filter.FilterFactory;
19+
import org.geotools.factory.CommonFactoryFinder;
20+
import org.geotools.util.factory.GeoTools;
21+
import org.slf4j.Logger;
22+
import org.slf4j.LoggerFactory;
23+
import org.springframework.http.HttpStatus;
24+
import org.springframework.http.MediaType;
25+
import org.springframework.http.ResponseEntity;
26+
import org.springframework.transaction.annotation.Transactional;
27+
import org.springframework.validation.annotation.Validated;
28+
import org.springframework.web.bind.annotation.DeleteMapping;
29+
import org.springframework.web.bind.annotation.GetMapping;
30+
import org.springframework.web.bind.annotation.ModelAttribute;
31+
import org.springframework.web.bind.annotation.PathVariable;
32+
import org.springframework.web.bind.annotation.PutMapping;
33+
import org.springframework.web.bind.annotation.RequestPart;
34+
import org.springframework.web.server.ResponseStatusException;
35+
import org.tailormap.api.annotation.AppRestController;
36+
import org.tailormap.api.geotools.featuresources.AttachmentsHelper;
37+
import org.tailormap.api.geotools.featuresources.FeatureSourceFactoryHelper;
38+
import org.tailormap.api.persistence.Application;
39+
import org.tailormap.api.persistence.GeoService;
40+
import org.tailormap.api.persistence.TMFeatureType;
41+
import org.tailormap.api.persistence.json.AppTreeLayerNode;
42+
import org.tailormap.api.persistence.json.AttachmentAttributeType;
43+
import org.tailormap.api.persistence.json.GeoServiceLayer;
44+
import org.tailormap.api.util.EditUtil;
45+
import org.tailormap.api.viewer.model.AttachmentMetadata;
46+
47+
@AppRestController
48+
@Validated
49+
public class AttachmentsController {
50+
51+
private static final Logger logger =
52+
LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
53+
54+
private final EditUtil editUtil;
55+
private final FeatureSourceFactoryHelper featureSourceFactoryHelper;
56+
private final FilterFactory ff = CommonFactoryFinder.getFilterFactory(GeoTools.getDefaultHints());
57+
58+
public AttachmentsController(EditUtil editUtil, FeatureSourceFactoryHelper featureSourceFactoryHelper) {
59+
this.editUtil = editUtil;
60+
this.featureSourceFactoryHelper = featureSourceFactoryHelper;
61+
}
62+
63+
/**
64+
* Add an attachment to a feature
65+
*
66+
* @param appTreeLayerNode the application tree layer node
67+
* @param service the geo service
68+
* @param layer the geo service layer
69+
* @param application the application
70+
* @param featureId the feature id
71+
* @param attachment the attachment metadata
72+
* @param fileData the attachment file data
73+
* @return the response entity
74+
*/
75+
@PutMapping(
76+
path = {
77+
"${tailormap-api.base-path}/{viewerKind}/{viewerName}/layer/{appLayerId}/feature/{featureId}/attachment"
78+
},
79+
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
80+
produces = MediaType.APPLICATION_JSON_VALUE)
81+
@Transactional
82+
public ResponseEntity<Serializable> addAttachment(
83+
@ModelAttribute AppTreeLayerNode appTreeLayerNode,
84+
@ModelAttribute GeoService service,
85+
@ModelAttribute GeoServiceLayer layer,
86+
@ModelAttribute Application application,
87+
@PathVariable String featureId,
88+
@RequestPart("attachmentMetadata") AttachmentMetadata attachment,
89+
@RequestPart("attachment") byte[] fileData) {
90+
91+
editUtil.checkEditAuthorisation();
92+
93+
TMFeatureType tmFeatureType = editUtil.getEditableFeatureType(application, appTreeLayerNode, service, layer);
94+
95+
Set<@Valid AttachmentAttributeType> attachmentAttrSet =
96+
tmFeatureType.getSettings().getAttachmentAttributes();
97+
if (attachmentAttrSet == null || attachmentAttrSet.isEmpty()) {
98+
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Feature type does not support attachments");
99+
}
100+
101+
AttachmentAttributeType attachmentAttributeType = attachmentAttrSet.stream()
102+
.filter(attr -> (attr.getAttributeName().equals(attachment.getAttributeName())
103+
&& java.util.Arrays.stream(attr.getMimeType().split(","))
104+
.map(String::trim)
105+
.anyMatch(mime -> mime.equals(attachment.getMimeType()))
106+
&& (attr.getMaxAttachmentSize() == null || attr.getMaxAttachmentSize() >= fileData.length)))
107+
.findFirst()
108+
.orElseThrow(() -> new ResponseStatusException(
109+
HttpStatus.BAD_REQUEST,
110+
"Feature type does not support attachments for attribute "
111+
+ attachment.getAttributeName()
112+
+ " with mime type "
113+
+ attachment.getMimeType()
114+
+ " and size "
115+
+ fileData.length));
116+
logger.debug("Using attachment attribute {}", attachmentAttributeType);
117+
118+
if (!checkFeatureExists(tmFeatureType, featureId)) {
119+
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Feature with id " + featureId + " does not exist");
120+
}
121+
122+
AttachmentMetadata response;
123+
try {
124+
response = AttachmentsHelper.insertAttachment(tmFeatureType, attachment, featureId, fileData);
125+
} catch (IOException | SQLException e) {
126+
throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, e.getMessage());
127+
}
128+
129+
return new ResponseEntity<>(response, HttpStatus.CREATED);
130+
}
131+
132+
@DeleteMapping(path = "${tailormap-api.base-path}/attachment/{attachmentId}")
133+
public ResponseEntity<Serializable> deleteAttachment(@PathVariable UUID attachmentId) {
134+
editUtil.checkEditAuthorisation();
135+
136+
logger.debug("TODO: Deleting attachment with id {}", attachmentId);
137+
throw new UnsupportedOperationException("Not implemented yet");
138+
}
139+
140+
@GetMapping(
141+
path = "${tailormap-api.base-path}/attachment/{attachmentId}",
142+
produces = {"application/octet-stream"})
143+
// TODO determine return type: ResponseEntity<byte[]> or ResponseEntity<InputStreamResource>?
144+
public ResponseEntity<byte[]> getAttachment(@PathVariable UUID attachmentId) {
145+
logger.debug("TODO: Getting attachment with id {}", attachmentId);
146+
147+
throw new UnsupportedOperationException("Not implemented yet");
148+
}
149+
150+
private boolean checkFeatureExists(TMFeatureType tmFeatureType, String featureId) {
151+
final Filter fidFilter = ff.id(ff.featureId(featureId));
152+
SimpleFeatureSource fs = null;
153+
try {
154+
fs = featureSourceFactoryHelper.openGeoToolsFeatureSource(tmFeatureType);
155+
Query query = new Query();
156+
query.setFilter(fidFilter);
157+
return fs.getCount(query) > 0;
158+
} catch (IOException e) {
159+
throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, e.getMessage());
160+
} finally {
161+
if (fs != null) {
162+
fs.getDataStore().dispose();
163+
}
164+
}
165+
}
166+
}

src/main/java/org/tailormap/api/geotools/featuresources/AttachmentsHelper.java

Lines changed: 92 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,21 +8,28 @@
88
import jakarta.validation.constraints.NotNull;
99
import java.io.IOException;
1010
import java.lang.invoke.MethodHandles;
11+
import java.nio.ByteBuffer;
1112
import java.sql.Connection;
1213
import java.sql.DatabaseMetaData;
14+
import java.sql.PreparedStatement;
1315
import java.sql.ResultSet;
1416
import java.sql.SQLException;
1517
import java.sql.Statement;
1618
import java.text.MessageFormat;
19+
import java.time.OffsetDateTime;
20+
import java.time.ZoneId;
1721
import java.util.List;
1822
import java.util.Locale;
23+
import java.util.UUID;
1924
import org.apache.commons.dbcp.DelegatingConnection;
2025
import org.geotools.api.feature.type.AttributeDescriptor;
2126
import org.geotools.jdbc.JDBCDataStore;
2227
import org.slf4j.Logger;
2328
import org.slf4j.LoggerFactory;
29+
import org.springframework.security.core.context.SecurityContextHolder;
2430
import org.tailormap.api.persistence.TMFeatureType;
2531
import org.tailormap.api.persistence.json.JDBCConnectionProperties;
32+
import org.tailormap.api.viewer.model.AttachmentMetadata;
2633

2734
/** Helper class for managing the {@code <FT>_attachments} sidecar tables in JDBC DataStores. */
2835
public final class AttachmentsHelper {
@@ -62,7 +69,7 @@ private static String getPostGISCreateAttachmentsTableStatement(
6269
"""
6370
CREATE TABLE IF NOT EXISTS {4}{0}_attachments (
6471
{0}_pk {2}{3} NOT NULL REFERENCES {4}{0}({1}) ON DELETE CASCADE,
65-
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
72+
attachment_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
6673
file_name VARCHAR(255),
6774
attribute_name VARCHAR(255) NOT NULL,
6875
description TEXT,
@@ -335,4 +342,88 @@ IF NOT EXISTS(SELECT * FROM sys.indexes WHERE name = ''{0}_attachments_fk'' AND
335342
"Unsupported database type for attachments: " + connProperties.getDbtype());
336343
}
337344
}
345+
346+
/** Convert UUID to byte array for storage in Oracle RAW(16). */
347+
private static byte[] asBytes(UUID uuid) {
348+
ByteBuffer bb = ByteBuffer.wrap(new byte[16]);
349+
bb.putLong(uuid.getMostSignificantBits());
350+
bb.putLong(uuid.getLeastSignificantBits());
351+
return bb.array();
352+
}
353+
354+
public static AttachmentMetadata insertAttachment(
355+
TMFeatureType featureType, AttachmentMetadata attachment, String featureId, byte[] fileData)
356+
throws IOException, SQLException {
357+
358+
// create uuid here so we don't have to deal with DB-specific returning/generated key syntax
359+
attachment.setAttachmentId(UUID.randomUUID());
360+
attachment.setAttachmentSize((long) fileData.length);
361+
attachment.setCreatedBy(
362+
SecurityContextHolder.getContext().getAuthentication().getName());
363+
attachment.createdAt(OffsetDateTime.now(ZoneId.of("UTC")));
364+
365+
logger.debug(
366+
"Adding attachment {} for feature {}:{}, type {}: {} (bytes: {})",
367+
attachment.getAttachmentId(),
368+
featureType.getName(),
369+
featureId,
370+
attachment.getMimeType(),
371+
attachment,
372+
fileData.length);
373+
374+
String insertSql = MessageFormat.format(
375+
"""
376+
INSERT INTO {0}_attachments (
377+
{0}_pk,
378+
attachment_id,
379+
file_name,
380+
attribute_name,
381+
description,
382+
attachment,
383+
attachment_size,
384+
mime_type,
385+
created_at,
386+
created_by
387+
) VALUES (?,?, ?, ?, ?, ?, ?, ?, ?, ?)
388+
""",
389+
featureType.getName());
390+
391+
byte[] attachmentIdBytes = asBytes(attachment.getAttachmentId());
392+
393+
JDBCDataStore ds = null;
394+
try {
395+
ds = (JDBCDataStore) new JDBCFeatureSourceHelper().createDataStore(featureType.getFeatureSource());
396+
try (Connection conn = ds.getDataSource().getConnection();
397+
PreparedStatement stmt = conn.prepareStatement(insertSql)) {
398+
399+
stmt.setString(1, featureId);
400+
if (featureType
401+
.getFeatureSource()
402+
.getJdbcConnection()
403+
.getDbtype()
404+
.equals(JDBCConnectionProperties.DbtypeEnum.ORACLE)) {
405+
stmt.setBytes(2, attachmentIdBytes);
406+
} else {
407+
stmt.setObject(2, attachment.getAttachmentId());
408+
}
409+
stmt.setString(3, attachment.getFileName());
410+
stmt.setString(4, attachment.getAttributeName());
411+
stmt.setString(5, attachment.getDescription());
412+
stmt.setBytes(6, fileData);
413+
stmt.setLong(7, fileData.length);
414+
stmt.setString(8, attachment.getMimeType());
415+
stmt.setTimestamp(
416+
9, java.sql.Timestamp.from(attachment.getCreatedAt().toInstant()));
417+
stmt.setString(10, attachment.getCreatedBy());
418+
419+
stmt.executeUpdate();
420+
421+
return attachment;
422+
}
423+
} finally {
424+
if (ds != null) {
425+
ds.dispose();
426+
}
427+
}
428+
}
338429
}

0 commit comments

Comments
 (0)