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