1
2
3
4
5
6 package org.tailormap.api.persistence.helper;
7
8 import static org.tailormap.api.persistence.TMFeatureSource.Protocol.WFS;
9 import static org.tailormap.api.persistence.json.GeoServiceProtocol.QUANTIZEDMESH;
10 import static org.tailormap.api.persistence.json.GeoServiceProtocol.TILES3D;
11 import static org.tailormap.api.persistence.json.GeoServiceProtocol.WMS;
12 import static org.tailormap.api.persistence.json.GeoServiceProtocol.XYZ;
13
14 import java.io.IOException;
15 import java.lang.invoke.MethodHandles;
16 import java.net.URI;
17 import java.net.URISyntaxException;
18 import java.net.URL;
19 import java.nio.charset.StandardCharsets;
20 import java.time.Instant;
21 import java.util.ArrayList;
22 import java.util.Collections;
23 import java.util.List;
24 import java.util.Map;
25 import java.util.Objects;
26 import java.util.Optional;
27 import java.util.Set;
28 import java.util.function.Predicate;
29 import java.util.stream.Collectors;
30 import org.apache.commons.lang3.StringUtils;
31 import org.geotools.api.data.ServiceInfo;
32 import org.geotools.data.ows.AbstractOpenWebService;
33 import org.geotools.data.ows.Capabilities;
34 import org.geotools.data.ows.OperationType;
35 import org.geotools.http.HTTPClientFinder;
36 import org.geotools.ows.wms.Layer;
37 import org.geotools.ows.wms.WMSCapabilities;
38 import org.geotools.ows.wms.WebMapServer;
39 import org.geotools.ows.wmts.WebMapTileServer;
40 import org.geotools.ows.wmts.model.WMTSLayer;
41 import org.slf4j.Logger;
42 import org.slf4j.LoggerFactory;
43 import org.springframework.beans.factory.annotation.Autowired;
44 import org.springframework.http.MediaType;
45 import org.springframework.stereotype.Service;
46 import org.springframework.util.CollectionUtils;
47 import org.springframework.web.util.UriComponentsBuilder;
48 import org.tailormap.api.configuration.TailormapConfig;
49 import org.tailormap.api.geotools.ResponseTeeingHTTPClient;
50 import org.tailormap.api.geotools.WMSServiceExceptionUtil;
51 import org.tailormap.api.geotools.featuresources.WFSFeatureSourceHelper;
52 import org.tailormap.api.geotools.wfs.SimpleWFSHelper;
53 import org.tailormap.api.geotools.wfs.SimpleWFSLayerDescription;
54 import org.tailormap.api.persistence.GeoService;
55 import org.tailormap.api.persistence.TMFeatureSource;
56 import org.tailormap.api.persistence.json.GeoServiceLayer;
57 import org.tailormap.api.persistence.json.ServiceAuthentication;
58 import org.tailormap.api.persistence.json.TMServiceCapabilitiesRequest;
59 import org.tailormap.api.persistence.json.TMServiceCapabilitiesRequestGetFeatureInfo;
60 import org.tailormap.api.persistence.json.TMServiceCapabilitiesRequestGetMap;
61 import org.tailormap.api.persistence.json.TMServiceCaps;
62 import org.tailormap.api.persistence.json.TMServiceCapsCapabilities;
63 import org.tailormap.api.persistence.json.TMServiceInfo;
64 import org.tailormap.api.persistence.json.WMSStyle;
65 import org.tailormap.api.repository.FeatureSourceRepository;
66
67 @Service
68 public class GeoServiceHelper {
69
70 private static final Logger logger =
71 LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
72 private final TailormapConfig tailormapConfig;
73 private final FeatureSourceRepository featureSourceRepository;
74
75 @Autowired
76 public GeoServiceHelper(TailormapConfig tailormapConfig, FeatureSourceRepository featureSourceRepository) {
77 this.tailormapConfig = tailormapConfig;
78 this.featureSourceRepository = featureSourceRepository;
79 }
80
81 public static org.tailormap.api.viewer.model.Service.ServerTypeEnum guessServerTypeFromUrl(String url) {
82
83 if (StringUtils.isBlank(url)) {
84 return org.tailormap.api.viewer.model.Service.ServerTypeEnum.GENERIC;
85 }
86 if (url.contains("/arcgis/")) {
87 return org.tailormap.api.viewer.model.Service.ServerTypeEnum.GENERIC;
88 }
89 if (url.contains("/geoserver/")) {
90 return org.tailormap.api.viewer.model.Service.ServerTypeEnum.GEOSERVER;
91 }
92 if (url.contains("/mapserv")) {
93 return org.tailormap.api.viewer.model.Service.ServerTypeEnum.MAPSERVER;
94 }
95 return org.tailormap.api.viewer.model.Service.ServerTypeEnum.GENERIC;
96 }
97
98 public static String getWmsRequest(String uri) {
99 return getWmsRequest(uri == null ? null : URI.create(uri));
100 }
101
102
103
104
105
106
107
108 public static String getWmsRequest(URI uri) {
109 if (uri == null || uri.getQuery() == null) {
110 return null;
111 }
112 return UriComponentsBuilder.fromUri(uri).build().getQueryParams().entrySet().stream()
113 .filter(entry -> "request".equalsIgnoreCase(entry.getKey()))
114 .map(entry -> entry.getValue().get(0))
115 .findFirst()
116 .orElse(null);
117 }
118
119 public void loadServiceCapabilities(GeoService geoService) throws Exception {
120
121 if (geoService.getProtocol() == XYZ) {
122 setXyzCapabilities(geoService);
123 return;
124 }
125
126 if (geoService.getProtocol() == TILES3D) {
127 set3DTilesCapabilities(geoService);
128 return;
129 }
130
131 if (geoService.getProtocol() == QUANTIZEDMESH) {
132 setQuantizedMeshCapabilities(geoService);
133 return;
134 }
135
136 ResponseTeeingHTTPClient client = new ResponseTeeingHTTPClient(
137 HTTPClientFinder.createClient(), null, Set.of("Access-Control-Allow-Origin"));
138
139 ServiceAuthentication auth = geoService.getAuthentication();
140 if (auth != null && auth.getMethod() == ServiceAuthentication.MethodEnum.PASSWORD) {
141 client.setUser(auth.getUsername());
142 client.setPassword(auth.getPassword());
143 }
144
145 client.setReadTimeout(this.tailormapConfig.getTimeout());
146 client.setConnectTimeout(this.tailormapConfig.getTimeout());
147 client.setTryGzip(true);
148
149 logger.info(
150 "Get capabilities for {} {} from URL {}",
151 geoService.getProtocol(),
152 geoService.getId() == null ? "(new)" : "id " + geoService.getId(),
153 geoService.getUrl());
154
155
156
157 switch (geoService.getProtocol()) {
158 case WMS -> loadWMSCapabilities(geoService, client);
159 case WMTS -> loadWMTSCapabilities(geoService, client);
160 default ->
161 throw new UnsupportedOperationException(
162 "Unsupported geo service protocol: " + geoService.getProtocol());
163 }
164
165 if (geoService.getTitle() == null) {
166 geoService.setTitle(Optional.ofNullable(geoService.getServiceCapabilities())
167 .map(TMServiceCaps::getServiceInfo)
168 .map(TMServiceInfo::getTitle)
169 .orElse(null));
170 }
171
172 if (logger.isDebugEnabled()) {
173 logger.debug("Loaded service layers: {}", geoService.getLayers());
174 } else {
175 logger.info(
176 "Loaded service layers: {}",
177 geoService.getLayers().stream()
178 .filter(Predicate.not(GeoServiceLayer::getVirtual))
179 .map(GeoServiceLayer::getName)
180 .collect(Collectors.toList()));
181 }
182 }
183
184 private static void setXyzCapabilities(GeoService geoService) {
185 geoService.setLayers(List.of(new GeoServiceLayer()
186 .id("0")
187 .root(true)
188 .name("xyz")
189 .title(geoService.getTitle())
190 .crs(Set.of(geoService.getSettings().getXyzCrs()))
191 .virtual(false)
192 .queryable(false)));
193 }
194
195 private static void set3DTilesCapabilities(GeoService geoService) {
196 geoService.setLayers(List.of(new GeoServiceLayer()
197 .id("0")
198 .root(true)
199 .name("tiles3d")
200 .title(geoService.getTitle())
201 .virtual(false)
202 .queryable(false)));
203 }
204
205 private static void setQuantizedMeshCapabilities(GeoService geoService) {
206 geoService.setLayers(List.of(new GeoServiceLayer()
207 .id("0")
208 .root(true)
209 .name("quantizedmesh")
210 .title(geoService.getTitle())
211 .virtual(false)
212 .queryable(false)));
213 }
214
215 private void setServiceInfo(
216 GeoService geoService,
217 ResponseTeeingHTTPClient client,
218 AbstractOpenWebService<? extends Capabilities, Layer> ows) {
219 geoService.setCapabilities(client.getLatestResponseCopy());
220 geoService.setCapabilitiesContentType(MediaType.APPLICATION_XML_VALUE);
221
222 geoService.setCapabilitiesFetched(Instant.now());
223
224 ServiceInfo info = ows.getInfo();
225
226 TMServiceCaps caps = new TMServiceCaps();
227 geoService.setServiceCapabilities(caps);
228
229 caps.setCorsAllowOrigin(client.getLatestResponse().getResponseHeader("Access-Control-Allow-Origin"));
230
231 if (info != null) {
232 if (StringUtils.isBlank(geoService.getTitle())) {
233 geoService.setTitle(info.getTitle());
234 }
235
236 caps.serviceInfo(new TMServiceInfo()
237 .keywords(info.getKeywords())
238 .description(info.getDescription())
239 .title(info.getTitle())
240 .publisher(info.getPublisher())
241 .schema(info.getSchema())
242 .source(info.getSource()));
243
244 geoService.setAdvertisedUrl(info.getSource().toString());
245 } else if (ows.getCapabilities() != null && ows.getCapabilities().getService() != null) {
246 org.geotools.data.ows.Service service = ows.getCapabilities().getService();
247
248 if (StringUtils.isBlank(geoService.getTitle())) {
249 geoService.setTitle(service.getTitle());
250 }
251 caps.setServiceInfo(new TMServiceInfo().keywords(Set.copyOf(List.of(service.getKeywordList()))));
252 }
253 }
254
255 private GeoServiceLayer toGeoServiceLayer(Layer l, List<? extends Layer> layers) {
256 return new GeoServiceLayer()
257 .id(String.valueOf(layers.indexOf(l)))
258 .name(l.getName())
259 .root(l.getParent() == null)
260 .title(l.getTitle())
261 .maxScale(Double.isNaN(l.getScaleDenominatorMax()) ? null : l.getScaleDenominatorMax())
262 .minScale(Double.isNaN(l.getScaleDenominatorMin()) ? null : l.getScaleDenominatorMin())
263 .virtual(l.getName() == null)
264 .crs(l.getSrs())
265 .latLonBoundingBox(GeoToolsHelper.boundsFromCRSEnvelope(l.getLatLonBoundingBox()))
266 .styles(l.getStyles().stream()
267 .map(gtStyle -> {
268 WMSStyle style = new WMSStyle()
269 .name(gtStyle.getName())
270 .title(Optional.ofNullable(gtStyle.getTitle())
271 .map(Objects::toString)
272 .orElse(null))
273 .abstractText(Optional.ofNullable(gtStyle.getAbstract())
274 .map(Objects::toString)
275 .orElse(null));
276 try {
277 List<?> legendURLs = gtStyle.getLegendURLs();
278
279 if (legendURLs != null && !legendURLs.isEmpty() && legendURLs.get(0) != null) {
280 style.legendURL(new URI((String) legendURLs.get(0)));
281 }
282 } catch (URISyntaxException ignored) {
283
284
285 }
286 return style;
287 })
288 .collect(Collectors.toList()))
289 .queryable(l.isQueryable())
290 .abstractText(l.get_abstract())
291 .children(l.getLayerChildren().stream()
292 .map(layers::indexOf)
293 .map(String::valueOf)
294 .collect(Collectors.toList()));
295 }
296
297 private void addLayerRecursive(
298 GeoService geoService, List<? extends Layer> layers, Layer layer, Set<String> parentCrs) {
299 GeoServiceLayer geoServiceLayer = toGeoServiceLayer(layer, layers);
300
301
302
303 geoServiceLayer.getCrs().removeAll(parentCrs);
304 geoService.getLayers().add(geoServiceLayer);
305 for (Layer l : layer.getLayerChildren()) {
306 addLayerRecursive(geoService, layers, l, layer.getSrs());
307 }
308 }
309
310 void loadWMSCapabilities(GeoService geoService, ResponseTeeingHTTPClient client) throws Exception {
311 WebMapServer wms;
312 try {
313 wms = new WebMapServer(new URL(geoService.getUrl()), client);
314 } catch (ClassCastException | IllegalStateException e) {
315
316
317
318
319
320
321
322
323
324 String contentType = client.getLatestResponse().getContentType();
325 if (contentType != null && contentType.contains("text/xml")) {
326 String wmsException =
327 WMSServiceExceptionUtil.tryGetServiceExceptionMessage(client.getLatestResponseCopy());
328 throw new Exception("Error loading WMS capabilities: "
329 + (wmsException != null
330 ? wmsException
331 : new String(client.getLatestResponseCopy(), StandardCharsets.UTF_8)));
332 } else {
333 throw e;
334 }
335 } catch (IOException e) {
336
337
338
339 if (e.getMessage().contains("Server returned HTTP response code: 401 for URL:")) {
340 throw new Exception(
341 "Error loading WMS, got 401 unauthorized response (credentials may be required or invalid)");
342 } else {
343 throw e;
344 }
345 }
346
347 OperationType getMap = wms.getCapabilities().getRequest().getGetMap();
348 OperationType getFeatureInfo = wms.getCapabilities().getRequest().getGetFeatureInfo();
349
350 if (getMap == null) {
351 throw new Exception("Service does not support GetMap");
352 }
353
354 setServiceInfo(geoService, client, wms);
355
356 WMSCapabilities wmsCapabilities = wms.getCapabilities();
357
358
359
360 geoService
361 .getServiceCapabilities()
362 .capabilities(new TMServiceCapsCapabilities()
363 .version(wmsCapabilities.getVersion())
364 .updateSequence(wmsCapabilities.getUpdateSequence())
365 .abstractText(wmsCapabilities.getService().get_abstract())
366 .request(new TMServiceCapabilitiesRequest()
367 .getMap(new TMServiceCapabilitiesRequestGetMap()
368 .formats(Set.copyOf(getMap.getFormats())))
369 .getFeatureInfo(
370 getFeatureInfo == null
371 ? null
372 : new TMServiceCapabilitiesRequestGetFeatureInfo()
373 .formats(Set.copyOf(getFeatureInfo.getFormats())))
374 .describeLayer(
375 wms.getCapabilities().getRequest().getDescribeLayer() != null)));
376
377 if (logger.isDebugEnabled()) {
378 logger.debug("Loaded capabilities, service capabilities: {}", geoService.getServiceCapabilities());
379 } else {
380 logger.info(
381 "Loaded capabilities from \"{}\", title: \"{}\"",
382 geoService.getUrl(),
383 geoService.getServiceCapabilities() != null
384 && geoService.getServiceCapabilities().getServiceInfo() != null
385 ? geoService
386 .getServiceCapabilities()
387 .getServiceInfo()
388 .getTitle()
389 : "(none)");
390 }
391 geoService.setLayers(new ArrayList<>());
392 addLayerRecursive(
393 geoService,
394 wms.getCapabilities().getLayerList(),
395 wms.getCapabilities().getLayer(),
396 Collections.emptySet());
397 }
398
399 void loadWMTSCapabilities(GeoService geoService, ResponseTeeingHTTPClient client) throws Exception {
400 WebMapTileServer wmts = new WebMapTileServer(new URL(geoService.getUrl()), client);
401 setServiceInfo(geoService, client, wmts);
402
403
404
405 List<WMTSLayer> layers = wmts.getCapabilities().getLayerList();
406 geoService.setLayers(
407 layers.stream().map(l -> toGeoServiceLayer(l, layers)).collect(Collectors.toList()));
408 }
409
410 public Map<String, SimpleWFSLayerDescription> findRelatedWFS(GeoService geoService) {
411
412
413 if (CollectionUtils.isEmpty(geoService.getLayers())) {
414 return Collections.emptyMap();
415 }
416
417
418
419
420
421
422
423 List<String> layers = geoService.getLayers().stream()
424 .filter(l -> !l.getVirtual())
425 .map(GeoServiceLayer::getName)
426 .filter(n -> {
427
428 boolean noWhitespace = !n.contains("(.*?)\\s(.*?)");
429 if (!noWhitespace) {
430 logger.warn(
431 "Not doing WFS DescribeLayer request for layer name with space: \"{}\" of WMS {}",
432 n,
433 geoService.getUrl());
434 }
435 return noWhitespace;
436 })
437 .collect(Collectors.toList());
438
439
440 Map<String, SimpleWFSLayerDescription> descriptions =
441 SimpleWFSHelper.describeWMSLayers(geoService.getUrl(), null, null, layers);
442
443 for (Map.Entry<String, SimpleWFSLayerDescription> entry : descriptions.entrySet()) {
444 String layerName = entry.getKey();
445 SimpleWFSLayerDescription description = entry.getValue();
446 if (description.typeNames().size() == 1 && layerName.equals(description.getFirstTypeName())) {
447 logger.info(
448 "layer \"{}\" linked to feature type with same name of WFS {}",
449 layerName,
450 description.wfsUrl());
451 } else {
452 logger.info(
453 "layer \"{}\" -> feature type(s) {} of WFS {}",
454 layerName,
455 description.typeNames(),
456 description.wfsUrl());
457 }
458 }
459 return descriptions;
460 }
461
462 public void findAndSaveRelatedWFS(GeoService geoService) {
463 if (geoService.getProtocol() != WMS) {
464 throw new IllegalArgumentException();
465 }
466
467
468
469 Map<String, SimpleWFSLayerDescription> wfsByLayer = this.findRelatedWFS(geoService);
470
471 wfsByLayer.values().stream()
472 .map(SimpleWFSLayerDescription::wfsUrl)
473 .distinct()
474 .forEach(url -> {
475 TMFeatureSource fs = featureSourceRepository.findByUrl(url);
476 if (fs == null) {
477 fs = new TMFeatureSource()
478 .setProtocol(WFS)
479 .setUrl(url)
480 .setTitle("WFS for " + geoService.getTitle())
481 .setLinkedService(geoService);
482 try {
483 new WFSFeatureSourceHelper().loadCapabilities(fs, tailormapConfig.getTimeout());
484 } catch (IOException e) {
485 String msg = String.format(
486 "Error loading WFS from URL %s: %s: %s", url, e.getClass(), e.getMessage());
487 if (logger.isTraceEnabled()) {
488 logger.error(msg, e);
489 } else {
490 logger.error(msg);
491 }
492 }
493 featureSourceRepository.save(fs);
494 }
495 });
496 }
497
498
499
500
501
502
503
504
505
506
507
508 public static URI getLayerLegendUrlFromStyles(GeoService service, GeoServiceLayer serviceLayer) {
509 if (serviceLayer.getRoot()) {
510
511
512 return serviceLayer.getStyles().stream()
513 .findFirst()
514 .map(WMSStyle::getLegendURL)
515 .orElse(null);
516 }
517
518 final List<WMSStyle> allOurLayersStyles = serviceLayer.getStyles();
519 if (allOurLayersStyles.size() == 1) {
520 return allOurLayersStyles.get(0).getLegendURL();
521 }
522
523 service.getLayers().stream()
524 .filter(layer -> !layer.equals(serviceLayer))
525 .forEach(layer -> allOurLayersStyles.removeAll(layer.getStyles()));
526
527 return allOurLayersStyles.stream()
528 .findFirst()
529 .map(WMSStyle::getLegendURL)
530 .orElse(null);
531 }
532 }