View Javadoc
1   /*
2    * Copyright (C) 2023 B3Partners B.V.
3    *
4    * SPDX-License-Identifier: MIT
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")) { // /cgi-bin/mapserv, /cgi-bin/mapserv.cgi, /cgi-bin/mapserv.fcgi
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    * Extracts the value of the WMS "REQUEST" parameter (case-insensitive) from the given URI.
104    *
105    * @param uri the URI to extract the request parameter from
106    * @return the value of the request parameter, or null if not found
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     // TODO: micrometer met tags voor URL/id van service
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     // geoService.setCapabilitiesContentType(client.getResponse().getContentType());
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                 // GeoTools will replace invalid URLs with null in legendURLs
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                 // Won't occur because GeoTools would have already returned null on
284                 // invalid URL
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     // Crses are inherited from the parent and this is applied by GeoTools so Layer.getSrs() has all
301     // supported crses, but to save space we reverse that by only saving new crses for child layers
302     // -- just like the original WMS capabilities.
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       // The gt-wms module tries to cast the XML unmarshalling result expecting capabilities, but a
316       // WMS 1.0.0/1.1.0 ServiceException may have been unmarshalled which leads to a
317       // ClassCastException.
318 
319       // A WMS 1.3.0 ServiceExceptionReport leads to an IllegalStateException because of a call to
320       // Throwable.initCause() on a SAXException that already has a cause.
321 
322       // In these cases, try to extract a message from the HTTP response
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       // This tries to match a HttpURLConnection (which the default GeoTools SimpleHTTPClient uses)
337       // exception message. In a container environment the JVM is always in English so never
338       // localized.
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     // TODO Jackson annotations op GeoTools classes of iets anders slims?
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     // TODO set capabilities if we need something from it
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     // TODO: report back progress
412 
413     if (CollectionUtils.isEmpty(geoService.getLayers())) {
414       return Collections.emptyMap();
415     }
416 
417     // Do one DescribeLayer request for all layers in a WMS. This is faster than one request per
418     // layer, but when one layer has an error this prevents describing valid layers. But that's a
419     // problem with the WMS / GeoServer.
420     // For now at least ignore layers with space in the name because GeoServer chokes out invalid
421     // XML for those.
422 
423     List<String> layers = geoService.getLayers().stream()
424         .filter(l -> !l.getVirtual())
425         .map(GeoServiceLayer::getName)
426         .filter(n -> {
427           // filter out white-space (non-greedy regex)
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     // TODO: add authentication
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     // TODO: report back progress
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    * Try to extract the legend url from the styles of the layer. This works by getting all styles for the layer and
500    * then removing any styles attached to other (parent) layers from the list of all styles. What remains is/are the
501    * legend url(s) for the layer. <i>NOTE: when a layer has more than one -not an inherited style- style the first
502    * style is used.</i>
503    *
504    * @param service the service that has the layer
505    * @param serviceLayer the layer to get the legend url for
506    * @return a URI to the legend image or null if not found
507    */
508   public static URI getLayerLegendUrlFromStyles(GeoService service, GeoServiceLayer serviceLayer) {
509     if (serviceLayer.getRoot()) {
510       // if this is a root layer, there are no parent layers, return the first style we find for
511       // this layer, if any
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     // remove the styles from all the other layer(s) from the list of all our layers styles
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 }