1
2
3
4
5
6 package org.tailormap.api.controller;
7
8 import static org.tailormap.api.persistence.helper.TMFeatureTypeHelper.getConfiguredAttributes;
9 import static org.tailormap.api.util.HttpProxyUtil.passthroughResponseHeaders;
10
11 import io.micrometer.core.annotation.Counted;
12 import io.micrometer.core.annotation.Timed;
13 import jakarta.servlet.http.HttpServletRequest;
14 import java.io.IOException;
15 import java.io.InputStream;
16 import java.io.Serializable;
17 import java.lang.invoke.MethodHandles;
18 import java.net.URI;
19 import java.net.http.HttpResponse;
20 import java.util.HashMap;
21 import java.util.HashSet;
22 import java.util.List;
23 import java.util.Map;
24 import java.util.Set;
25 import org.geotools.api.feature.type.AttributeDescriptor;
26 import org.geotools.data.wfs.WFSDataStore;
27 import org.geotools.data.wfs.WFSDataStoreFactory;
28 import org.slf4j.Logger;
29 import org.slf4j.LoggerFactory;
30 import org.springframework.beans.factory.annotation.Value;
31 import org.springframework.core.io.InputStreamResource;
32 import org.springframework.http.HttpStatus;
33 import org.springframework.http.ResponseEntity;
34 import org.springframework.transaction.annotation.Transactional;
35 import org.springframework.util.CollectionUtils;
36 import org.springframework.util.LinkedMultiValueMap;
37 import org.springframework.util.MultiValueMap;
38 import org.springframework.web.bind.annotation.GetMapping;
39 import org.springframework.web.bind.annotation.ModelAttribute;
40 import org.springframework.web.bind.annotation.RequestMapping;
41 import org.springframework.web.bind.annotation.RequestMethod;
42 import org.springframework.web.bind.annotation.RequestParam;
43 import org.springframework.web.server.ResponseStatusException;
44 import org.tailormap.api.annotation.AppRestController;
45 import org.tailormap.api.geotools.wfs.SimpleWFSHelper;
46 import org.tailormap.api.geotools.wfs.SimpleWFSLayerDescription;
47 import org.tailormap.api.geotools.wfs.WFSProxy;
48 import org.tailormap.api.persistence.Application;
49 import org.tailormap.api.persistence.GeoService;
50 import org.tailormap.api.persistence.TMFeatureSource;
51 import org.tailormap.api.persistence.TMFeatureType;
52 import org.tailormap.api.persistence.json.AppLayerSettings;
53 import org.tailormap.api.persistence.json.AppTreeLayerNode;
54 import org.tailormap.api.persistence.json.GeoServiceLayer;
55 import org.tailormap.api.persistence.json.GeoServiceProtocol;
56 import org.tailormap.api.persistence.json.ServiceAuthentication;
57 import org.tailormap.api.repository.FeatureSourceRepository;
58 import org.tailormap.api.viewer.model.LayerExportCapabilities;
59
60 @AppRestController
61 @RequestMapping(path = "${tailormap-api.base-path}/{viewerKind}/{viewerName}/layer/{appLayerId}/export/")
62 public class LayerExportController {
63 private static final Logger logger =
64 LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
65
66 @Value("#{'${tailormap-api.export.allowed-outputformats}'.split(',')}")
67 private List<String> allowedOutputFormats;
68
69 private final FeatureSourceRepository featureSourceRepository;
70
71 public LayerExportController(FeatureSourceRepository featureSourceRepository) {
72 this.featureSourceRepository = featureSourceRepository;
73 }
74
75 @Transactional
76 @GetMapping(path = "capabilities")
77 @Timed(value = "export_get_capabilities", description = "Get layer export capabilities")
78 public ResponseEntity<Serializable> capabilities(
79 @ModelAttribute GeoService service, @ModelAttribute GeoServiceLayer layer) {
80
81 final LayerExportCapabilities capabilities = new LayerExportCapabilities().exportable(false);
82
83 TMFeatureType tmft = service.findFeatureTypeForLayer(layer, featureSourceRepository);
84
85 if (tmft != null) {
86 WFSTypeNameDescriptor wfsTypeNameDescriptor = findWFSFeatureType(service, layer, tmft);
87
88 if (wfsTypeNameDescriptor != null) {
89 try {
90 List<String> outputFormats = SimpleWFSHelper.getOutputFormats(
91 wfsTypeNameDescriptor.wfsUrl(),
92 wfsTypeNameDescriptor.typeName(),
93 wfsTypeNameDescriptor.username(),
94 wfsTypeNameDescriptor.password());
95 capabilities.setOutputFormats(outputFormats);
96 } catch (Exception e) {
97 String msg =
98 String.format("Error getting capabilities for WFS \"%s\"", wfsTypeNameDescriptor.wfsUrl());
99 if (logger.isTraceEnabled()) {
100 logger.trace(msg, e);
101 } else {
102 logger.warn("{}: {}: {}", msg, e.getClass(), e.getMessage());
103 }
104 capabilities.setOutputFormats(null);
105 }
106 }
107 capabilities.setExportable(capabilities.getOutputFormats() != null
108 && !capabilities.getOutputFormats().isEmpty());
109 }
110
111 return ResponseEntity.status(HttpStatus.OK).body(capabilities);
112 }
113
114 @Transactional
115 @RequestMapping(
116 path = "download",
117 method = {RequestMethod.GET, RequestMethod.POST})
118 @Counted(value = "export_download", description = "Count of layer downloads")
119 public ResponseEntity<?> download(
120 @ModelAttribute GeoService service,
121 @ModelAttribute GeoServiceLayer layer,
122 @ModelAttribute Application application,
123 @ModelAttribute AppTreeLayerNode appTreeLayerNode,
124 @RequestParam String outputFormat,
125 @RequestParam(required = false) Set<String> attributes,
126 @RequestParam(required = false) String filter,
127 @RequestParam(required = false) String sortBy,
128 @RequestParam(required = false) String sortOrder,
129 @RequestParam(required = false) String crs,
130 HttpServletRequest request) {
131
132
133 if (!allowedOutputFormats.contains(outputFormat)) {
134 logger.warn("Invalid output format requested: {}", outputFormat);
135 return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("Invalid output format");
136 }
137
138 TMFeatureType tmft = service.findFeatureTypeForLayer(layer, featureSourceRepository);
139 AppLayerSettings appLayerSettings = application.getAppLayerSettings(appTreeLayerNode);
140
141 if (tmft == null) {
142 logger.debug("Layer export requested for layer without feature type");
143 throw new ResponseStatusException(HttpStatus.NOT_FOUND);
144 }
145
146
147
148 WFSTypeNameDescriptor wfsTypeNameDescriptor = findWFSFeatureType(service, layer, tmft);
149
150 if (wfsTypeNameDescriptor == null) {
151 throw new ResponseStatusException(
152 HttpStatus.SERVICE_UNAVAILABLE, "No suitable WFS available for layer export");
153 }
154
155 if (attributes == null) {
156 attributes = new HashSet<>();
157 }
158
159
160 Set<String> nonHiddenAttributes =
161 getConfiguredAttributes(tmft, appLayerSettings).keySet();
162
163 if (!attributes.isEmpty()) {
164
165 if (!nonHiddenAttributes.containsAll(attributes)) {
166 throw new ResponseStatusException(
167 HttpStatus.BAD_REQUEST,
168 "One or more requested attributes are not available on the feature type");
169 }
170 } else if (!tmft.getSettings().getHideAttributes().isEmpty()) {
171
172
173
174 attributes = new HashSet<>(nonHiddenAttributes);
175 }
176
177
178
179
180 if (!attributes.isEmpty() && tmft.getDefaultGeometryAttribute() != null) {
181 attributes.add(tmft.getDefaultGeometryAttribute());
182 }
183
184
185
186
187
188 try {
189 List<String> wfsAttributeNames = getWFSAttributeNames(wfsTypeNameDescriptor);
190 attributes.retainAll(wfsAttributeNames);
191 } catch (IOException e) {
192 logger.error("Error getting WFS feature type", e);
193 return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("Error getting WFS feature type");
194 }
195
196 return downloadFromWFS(
197 wfsTypeNameDescriptor, outputFormat, attributes, filter, sortBy, sortOrder, crs, request);
198 }
199
200 private ResponseEntity<?> downloadFromWFS(
201 WFSTypeNameDescriptor wfsTypeName,
202 String outputFormat,
203 Set<String> attributes,
204 String filter,
205 String sortBy,
206 String sortOrder,
207 String crs,
208 HttpServletRequest request) {
209
210 MultiValueMap<String, String> getFeatureParameters = new LinkedMultiValueMap<>();
211
212 getFeatureParameters.add("typeNames", wfsTypeName.typeName());
213 getFeatureParameters.add("outputFormat", outputFormat);
214 if (filter != null) {
215
216
217 getFeatureParameters.add("cql_filter", filter);
218 }
219 if (crs != null) {
220 getFeatureParameters.add("srsName", crs);
221 }
222 if (!CollectionUtils.isEmpty(attributes)) {
223 getFeatureParameters.add("propertyName", String.join(",", attributes));
224 }
225 if (sortBy != null) {
226 getFeatureParameters.add("sortBy", sortBy + ("asc".equals(sortOrder) ? " A" : " D"));
227 }
228 URI wfsGetFeature = SimpleWFSHelper.getWFSRequestURL(wfsTypeName.wfsUrl(), "GetFeature", getFeatureParameters);
229
230 logger.info("Layer download, proxying WFS GetFeature request {}", wfsGetFeature);
231 try {
232
233 HttpResponse<InputStream> response =
234 WFSProxy.proxyWfsRequest(wfsGetFeature, wfsTypeName.username(), wfsTypeName.password(), request);
235
236 logger.info(
237 "Layer download response code: {}, content type: {}, disposition: {}",
238 response.statusCode(),
239 response.headers()
240 .firstValue("Content-Type")
241 .map(Object::toString)
242 .orElse("<none>"),
243 response.headers()
244 .firstValue("Content-Disposition")
245 .map(Object::toString)
246 .orElse("<none>"));
247
248 InputStreamResource body = new InputStreamResource(response.body());
249
250 org.springframework.http.HttpHeaders headers =
251 passthroughResponseHeaders(response.headers(), Set.of("Content-Type", "Content-Disposition"));
252
253
254 return ResponseEntity.status(response.statusCode()).headers(headers).body(body);
255 } catch (Exception e) {
256 return ResponseEntity.status(HttpStatus.BAD_GATEWAY).body("Bad Gateway");
257 }
258 }
259
260 private record WFSTypeNameDescriptor(String wfsUrl, String typeName, String username, String password) {}
261
262 private WFSTypeNameDescriptor findWFSFeatureType(GeoService service, GeoServiceLayer layer, TMFeatureType tmft) {
263
264 String wfsUrl = null;
265 String typeName = null;
266 String username = null;
267 String password = null;
268 ServiceAuthentication auth = null;
269
270 if (tmft != null) {
271 TMFeatureSource featureSource = tmft.getFeatureSource();
272
273 if (featureSource.getProtocol() == TMFeatureSource.Protocol.WFS) {
274 wfsUrl = featureSource.getUrl();
275 typeName = tmft.getName();
276 auth = featureSource.getAuthentication();
277 }
278 }
279
280 if ((wfsUrl == null || typeName == null) && service.getProtocol() == GeoServiceProtocol.WMS) {
281
282 auth = service.getAuthentication();
283
284 SimpleWFSLayerDescription wfsLayerDescription = getWFSLayerDescriptionForWMS(service, layer.getName());
285 if (wfsLayerDescription != null
286 && wfsLayerDescription.wfsUrl() != null
287 && wfsLayerDescription.getFirstTypeName() != null) {
288 wfsUrl = wfsLayerDescription.wfsUrl();
289
290
291 typeName = wfsLayerDescription.getFirstTypeName();
292 auth = service.getAuthentication();
293 }
294 }
295
296 if (auth != null && auth.getMethod() == ServiceAuthentication.MethodEnum.PASSWORD) {
297 username = auth.getUsername();
298 password = auth.getPassword();
299 }
300
301 if (wfsUrl != null && typeName != null) {
302 return new WFSTypeNameDescriptor(wfsUrl, typeName, username, password);
303 } else {
304 return null;
305 }
306 }
307
308 private SimpleWFSLayerDescription getWFSLayerDescriptionForWMS(GeoService wmsService, String layerName) {
309 String username = null;
310 String password = null;
311 if (wmsService.getAuthentication() != null
312 && wmsService.getAuthentication().getMethod() == ServiceAuthentication.MethodEnum.PASSWORD) {
313 username = wmsService.getAuthentication().getUsername();
314 password = wmsService.getAuthentication().getPassword();
315 }
316 SimpleWFSLayerDescription wfsLayerDescription =
317 SimpleWFSHelper.describeWMSLayer(wmsService.getUrl(), username, password, layerName);
318 if (wfsLayerDescription != null && !wfsLayerDescription.typeNames().isEmpty()) {
319 logger.info(
320 "WMS described layer \"{}\" with typeNames \"{}\" of WFS \"{}\" for WMS \"{}\"",
321 layerName,
322 wfsLayerDescription.typeNames(),
323 wfsLayerDescription.wfsUrl(),
324 wmsService.getUrl());
325
326 return wfsLayerDescription;
327 }
328 return null;
329 }
330
331
332
333
334
335
336
337
338 private static List<String> getWFSAttributeNames(WFSTypeNameDescriptor wfsTypeNameDescriptor) throws IOException {
339 Map<String, Object> connectionParameters = new HashMap<>();
340 connectionParameters.put(
341 WFSDataStoreFactory.URL.key,
342 SimpleWFSHelper.getWFSRequestURL(wfsTypeNameDescriptor.wfsUrl(), "GetCapabilities")
343 .toURL());
344 connectionParameters.put(WFSDataStoreFactory.PROTOCOL.key, Boolean.FALSE);
345 connectionParameters.put(WFSDataStoreFactory.WFS_STRATEGY.key, "geoserver");
346 connectionParameters.put(WFSDataStoreFactory.LENIENT.key, Boolean.TRUE);
347 connectionParameters.put(WFSDataStoreFactory.TIMEOUT.key, SimpleWFSHelper.TIMEOUT);
348 if (wfsTypeNameDescriptor.username() != null) {
349 connectionParameters.put(WFSDataStoreFactory.USERNAME.key, wfsTypeNameDescriptor.username());
350 connectionParameters.put(WFSDataStoreFactory.PASSWORD.key, wfsTypeNameDescriptor.password());
351 }
352
353 WFSDataStore wfs = new WFSDataStoreFactory().createDataStore(connectionParameters);
354 List<String> attributeNames =
355 wfs.getFeatureSource(wfsTypeNameDescriptor.typeName()).getSchema().getAttributeDescriptors().stream()
356 .map(AttributeDescriptor::getLocalName)
357 .toList();
358
359 wfs.dispose();
360 return attributeNames;
361 }
362 }