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.tailormap.api.configuration.TailormapConfig;
48  import org.tailormap.api.geotools.ResponseTeeingHTTPClient;
49  import org.tailormap.api.geotools.WMSServiceExceptionUtil;
50  import org.tailormap.api.geotools.featuresources.WFSFeatureSourceHelper;
51  import org.tailormap.api.geotools.wfs.SimpleWFSHelper;
52  import org.tailormap.api.geotools.wfs.SimpleWFSLayerDescription;
53  import org.tailormap.api.persistence.GeoService;
54  import org.tailormap.api.persistence.TMFeatureSource;
55  import org.tailormap.api.persistence.json.GeoServiceLayer;
56  import org.tailormap.api.persistence.json.ServiceAuthentication;
57  import org.tailormap.api.persistence.json.TMServiceCapabilitiesRequest;
58  import org.tailormap.api.persistence.json.TMServiceCapabilitiesRequestGetFeatureInfo;
59  import org.tailormap.api.persistence.json.TMServiceCapabilitiesRequestGetMap;
60  import org.tailormap.api.persistence.json.TMServiceCaps;
61  import org.tailormap.api.persistence.json.TMServiceCapsCapabilities;
62  import org.tailormap.api.persistence.json.TMServiceInfo;
63  import org.tailormap.api.persistence.json.WMSStyle;
64  import org.tailormap.api.repository.FeatureSourceRepository;
65  
66  @Service
67  public class GeoServiceHelper {
68  
69    private static final Logger logger =
70        LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
71    private final TailormapConfig tailormapConfig;
72    private final FeatureSourceRepository featureSourceRepository;
73  
74    @Autowired
75    public GeoServiceHelper(TailormapConfig tailormapConfig, FeatureSourceRepository featureSourceRepository) {
76      this.tailormapConfig = tailormapConfig;
77      this.featureSourceRepository = featureSourceRepository;
78    }
79  
80    public org.tailormap.api.viewer.model.Service.ServerTypeEnum guessServerTypeFromUrl(String url) {
81  
82      if (StringUtils.isBlank(url)) {
83        return org.tailormap.api.viewer.model.Service.ServerTypeEnum.GENERIC;
84      }
85      if (url.contains("/arcgis/")) {
86        return org.tailormap.api.viewer.model.Service.ServerTypeEnum.GENERIC;
87      }
88      if (url.contains("/geoserver/")) {
89        return org.tailormap.api.viewer.model.Service.ServerTypeEnum.GEOSERVER;
90      }
91      if (url.contains("/mapserv")) { // /cgi-bin/mapserv, /cgi-bin/mapserv.cgi, /cgi-bin/mapserv.fcgi
92        return org.tailormap.api.viewer.model.Service.ServerTypeEnum.MAPSERVER;
93      }
94      return org.tailormap.api.viewer.model.Service.ServerTypeEnum.GENERIC;
95    }
96  
97    public void loadServiceCapabilities(GeoService geoService) throws Exception {
98  
99      if (geoService.getProtocol() == XYZ) {
100       setXyzCapabilities(geoService);
101       return;
102     }
103 
104     if (geoService.getProtocol() == TILES3D) {
105       set3DTilesCapabilities(geoService);
106       return;
107     }
108 
109     if (geoService.getProtocol() == QUANTIZEDMESH) {
110       setQuantizedMeshCapabilities(geoService);
111       return;
112     }
113 
114     ResponseTeeingHTTPClient client = new ResponseTeeingHTTPClient(
115         HTTPClientFinder.createClient(), null, Set.of("Access-Control-Allow-Origin"));
116 
117     ServiceAuthentication auth = geoService.getAuthentication();
118     if (auth != null && auth.getMethod() == ServiceAuthentication.MethodEnum.PASSWORD) {
119       client.setUser(auth.getUsername());
120       client.setPassword(auth.getPassword());
121     }
122 
123     client.setReadTimeout(this.tailormapConfig.getTimeout());
124     client.setConnectTimeout(this.tailormapConfig.getTimeout());
125     client.setTryGzip(true);
126 
127     logger.info(
128         "Get capabilities for {} {} from URL {}",
129         geoService.getProtocol(),
130         geoService.getId() == null ? "(new)" : "id " + geoService.getId(),
131         geoService.getUrl());
132 
133     // TODO: micrometer met tags voor URL/id van service
134 
135     switch (geoService.getProtocol()) {
136       case WMS:
137         loadWMSCapabilities(geoService, client);
138         break;
139       case WMTS:
140         loadWMTSCapabilities(geoService, client);
141         break;
142       default:
143         throw new UnsupportedOperationException(
144             "Unsupported geo service protocol: " + geoService.getProtocol());
145     }
146 
147     if (geoService.getTitle() == null) {
148       geoService.setTitle(Optional.ofNullable(geoService.getServiceCapabilities())
149           .map(TMServiceCaps::getServiceInfo)
150           .map(TMServiceInfo::getTitle)
151           .orElse(null));
152     }
153 
154     if (logger.isDebugEnabled()) {
155       logger.debug("Loaded service layers: {}", geoService.getLayers());
156     } else {
157       logger.info(
158           "Loaded service layers: {}",
159           geoService.getLayers().stream()
160               .filter(Predicate.not(GeoServiceLayer::getVirtual))
161               .map(GeoServiceLayer::getName)
162               .collect(Collectors.toList()));
163     }
164   }
165 
166   private static void setXyzCapabilities(GeoService geoService) {
167     geoService.setLayers(List.of(new GeoServiceLayer()
168         .id("0")
169         .root(true)
170         .name("xyz")
171         .title(geoService.getTitle())
172         .crs(Set.of(geoService.getSettings().getXyzCrs()))
173         .virtual(false)
174         .queryable(false)));
175   }
176 
177   private static void set3DTilesCapabilities(GeoService geoService) {
178     geoService.setLayers(List.of(new GeoServiceLayer()
179         .id("0")
180         .root(true)
181         .name("tiles3d")
182         .title(geoService.getTitle())
183         .virtual(false)
184         .queryable(false)));
185   }
186 
187   private static void setQuantizedMeshCapabilities(GeoService geoService) {
188     geoService.setLayers(List.of(new GeoServiceLayer()
189         .id("0")
190         .root(true)
191         .name("quantizedmesh")
192         .title(geoService.getTitle())
193         .virtual(false)
194         .queryable(false)));
195   }
196 
197   private void setServiceInfo(
198       GeoService geoService,
199       ResponseTeeingHTTPClient client,
200       AbstractOpenWebService<? extends Capabilities, Layer> ows) {
201     geoService.setCapabilities(client.getLatestResponseCopy());
202     geoService.setCapabilitiesContentType(MediaType.APPLICATION_XML_VALUE);
203     // geoService.setCapabilitiesContentType(client.getResponse().getContentType());
204     geoService.setCapabilitiesFetched(Instant.now());
205 
206     ServiceInfo info = ows.getInfo();
207 
208     TMServiceCaps caps = new TMServiceCaps();
209     geoService.setServiceCapabilities(caps);
210 
211     caps.setCorsAllowOrigin(client.getLatestResponse().getResponseHeader("Access-Control-Allow-Origin"));
212 
213     if (info != null) {
214       if (StringUtils.isBlank(geoService.getTitle())) {
215         geoService.setTitle(info.getTitle());
216       }
217 
218       caps.serviceInfo(new TMServiceInfo()
219           .keywords(info.getKeywords())
220           .description(info.getDescription())
221           .title(info.getTitle())
222           .publisher(info.getPublisher())
223           .schema(info.getSchema())
224           .source(info.getSource()));
225 
226       geoService.setAdvertisedUrl(info.getSource().toString());
227     } else if (ows.getCapabilities() != null && ows.getCapabilities().getService() != null) {
228       org.geotools.data.ows.Service service = ows.getCapabilities().getService();
229 
230       if (StringUtils.isBlank(geoService.getTitle())) {
231         geoService.setTitle(service.getTitle());
232       }
233       caps.setServiceInfo(new TMServiceInfo().keywords(Set.copyOf(List.of(service.getKeywordList()))));
234     }
235   }
236 
237   private GeoServiceLayer toGeoServiceLayer(Layer l, List<? extends Layer> layers) {
238     return new GeoServiceLayer()
239         .id(String.valueOf(layers.indexOf(l)))
240         .name(l.getName())
241         .root(l.getParent() == null)
242         .title(l.getTitle())
243         .maxScale(Double.isNaN(l.getScaleDenominatorMax()) ? null : l.getScaleDenominatorMax())
244         .minScale(Double.isNaN(l.getScaleDenominatorMin()) ? null : l.getScaleDenominatorMin())
245         .virtual(l.getName() == null)
246         .crs(l.getSrs())
247         .latLonBoundingBox(GeoToolsHelper.boundsFromCRSEnvelope(l.getLatLonBoundingBox()))
248         .styles(l.getStyles().stream()
249             .map(gtStyle -> {
250               WMSStyle style = new WMSStyle()
251                   .name(gtStyle.getName())
252                   .title(Optional.ofNullable(gtStyle.getTitle())
253                       .map(Objects::toString)
254                       .orElse(null))
255                   .abstractText(Optional.ofNullable(gtStyle.getAbstract())
256                       .map(Objects::toString)
257                       .orElse(null));
258               try {
259                 List<?> legendURLs = gtStyle.getLegendURLs();
260                 // GeoTools will replace invalid URLs with null in legendURLs
261                 if (legendURLs != null && !legendURLs.isEmpty() && legendURLs.get(0) != null) {
262                   style.legendURL(new URI((String) legendURLs.get(0)));
263                 }
264               } catch (URISyntaxException ignored) {
265                 // Won't occur because GeoTools would have already returned null on
266                 // invalid URL
267               }
268               return style;
269             })
270             .collect(Collectors.toList()))
271         .queryable(l.isQueryable())
272         .abstractText(l.get_abstract())
273         .children(l.getLayerChildren().stream()
274             .map(layers::indexOf)
275             .map(String::valueOf)
276             .collect(Collectors.toList()));
277   }
278 
279   private void addLayerRecursive(
280       GeoService geoService, List<? extends Layer> layers, Layer layer, Set<String> parentCrs) {
281     GeoServiceLayer geoServiceLayer = toGeoServiceLayer(layer, layers);
282     // Crses are inherited from the parent and this is applied by GeoTools so Layer.getSrs() has all
283     // supported crses, but to save space we reverse that by only saving new crses for child layers
284     // -- just like the original WMS capabilities.
285     geoServiceLayer.getCrs().removeAll(parentCrs);
286     geoService.getLayers().add(geoServiceLayer);
287     for (Layer l : layer.getLayerChildren()) {
288       addLayerRecursive(geoService, layers, l, layer.getSrs());
289     }
290   }
291 
292   void loadWMSCapabilities(GeoService geoService, ResponseTeeingHTTPClient client) throws Exception {
293     WebMapServer wms;
294     try {
295       wms = new WebMapServer(new URL(geoService.getUrl()), client);
296     } catch (ClassCastException | IllegalStateException e) {
297       // The gt-wms module tries to cast the XML unmarshalling result expecting capabilities, but a
298       // WMS 1.0.0/1.1.0 ServiceException may have been unmarshalled which leads to a
299       // ClassCastException.
300 
301       // A WMS 1.3.0 ServiceExceptionReport leads to an IllegalStateException because of a call to
302       // Throwable.initCause() on a SAXException that already has a cause.
303 
304       // In these cases, try to extract a message from the HTTP response
305 
306       String contentType = client.getLatestResponse().getContentType();
307       if (contentType != null && contentType.contains("text/xml")) {
308         String wmsException =
309             WMSServiceExceptionUtil.tryGetServiceExceptionMessage(client.getLatestResponseCopy());
310         throw new Exception("Error loading WMS capabilities: "
311             + (wmsException != null
312                 ? wmsException
313                 : new String(client.getLatestResponseCopy(), StandardCharsets.UTF_8)));
314       } else {
315         throw e;
316       }
317     } catch (IOException e) {
318       // This tries to match a HttpURLConnection (which the default GeoTools SimpleHTTPClient uses)
319       // exception message. In a container environment the JVM is always in English so never
320       // localized.
321       if (e.getMessage().contains("Server returned HTTP response code: 401 for URL:")) {
322         throw new Exception(
323             "Error loading WMS, got 401 unauthorized response (credentials may be required or invalid)");
324       } else {
325         throw e;
326       }
327     }
328 
329     OperationType getMap = wms.getCapabilities().getRequest().getGetMap();
330     OperationType getFeatureInfo = wms.getCapabilities().getRequest().getGetFeatureInfo();
331 
332     if (getMap == null) {
333       throw new Exception("Service does not support GetMap");
334     }
335 
336     setServiceInfo(geoService, client, wms);
337 
338     WMSCapabilities wmsCapabilities = wms.getCapabilities();
339 
340     // TODO Jackson annotations op GeoTools classes of iets anders slims?
341 
342     geoService
343         .getServiceCapabilities()
344         .capabilities(new TMServiceCapsCapabilities()
345             .version(wmsCapabilities.getVersion())
346             .updateSequence(wmsCapabilities.getUpdateSequence())
347             .abstractText(wmsCapabilities.getService().get_abstract())
348             .request(new TMServiceCapabilitiesRequest()
349                 .getMap(new TMServiceCapabilitiesRequestGetMap()
350                     .formats(Set.copyOf(getMap.getFormats())))
351                 .getFeatureInfo(
352                     getFeatureInfo == null
353                         ? null
354                         : new TMServiceCapabilitiesRequestGetFeatureInfo()
355                             .formats(Set.copyOf(getFeatureInfo.getFormats())))
356                 .describeLayer(
357                     wms.getCapabilities().getRequest().getDescribeLayer() != null)));
358 
359     if (logger.isDebugEnabled()) {
360       logger.debug("Loaded capabilities, service capabilities: {}", geoService.getServiceCapabilities());
361     } else {
362       logger.info(
363           "Loaded capabilities from \"{}\", title: \"{}\"",
364           geoService.getUrl(),
365           geoService.getServiceCapabilities() != null
366                   && geoService.getServiceCapabilities().getServiceInfo() != null
367               ? geoService
368                   .getServiceCapabilities()
369                   .getServiceInfo()
370                   .getTitle()
371               : "(none)");
372     }
373     geoService.setLayers(new ArrayList<>());
374     addLayerRecursive(
375         geoService,
376         wms.getCapabilities().getLayerList(),
377         wms.getCapabilities().getLayer(),
378         Collections.emptySet());
379   }
380 
381   void loadWMTSCapabilities(GeoService geoService, ResponseTeeingHTTPClient client) throws Exception {
382     WebMapTileServer wmts = new WebMapTileServer(new URL(geoService.getUrl()), client);
383     setServiceInfo(geoService, client, wmts);
384 
385     // TODO set capabilities if we need something from it
386 
387     List<WMTSLayer> layers = wmts.getCapabilities().getLayerList();
388     geoService.setLayers(
389         layers.stream().map(l -> toGeoServiceLayer(l, layers)).collect(Collectors.toList()));
390   }
391 
392   public Map<String, SimpleWFSLayerDescription> findRelatedWFS(GeoService geoService) {
393     // TODO: report back progress
394 
395     if (CollectionUtils.isEmpty(geoService.getLayers())) {
396       return Collections.emptyMap();
397     }
398 
399     // Do one DescribeLayer request for all layers in a WMS. This is faster than one request per
400     // layer, but when one layer has an error this prevents describing valid layers. But that's a
401     // problem with the WMS / GeoServer.
402     // For now at least ignore layers with space in the name because GeoServer chokes out invalid
403     // XML for those.
404 
405     List<String> layers = geoService.getLayers().stream()
406         .filter(l -> !l.getVirtual())
407         .map(GeoServiceLayer::getName)
408         .filter(n -> {
409           // filter out white-space (non-greedy regex)
410           boolean noWhitespace = !n.contains("(.*?)\\s(.*?)");
411           if (!noWhitespace) {
412             logger.warn(
413                 "Not doing WFS DescribeLayer request for layer name with space: \"{}\" of WMS {}",
414                 n,
415                 geoService.getUrl());
416           }
417           return noWhitespace;
418         })
419         .collect(Collectors.toList());
420 
421     // TODO: add authentication
422     Map<String, SimpleWFSLayerDescription> descriptions =
423         SimpleWFSHelper.describeWMSLayers(geoService.getUrl(), null, null, layers);
424 
425     for (Map.Entry<String, SimpleWFSLayerDescription> entry : descriptions.entrySet()) {
426       String layerName = entry.getKey();
427       SimpleWFSLayerDescription description = entry.getValue();
428       if (description.typeNames().size() == 1 && layerName.equals(description.getFirstTypeName())) {
429         logger.info(
430             "layer \"{}\" linked to feature type with same name of WFS {}",
431             layerName,
432             description.wfsUrl());
433       } else {
434         logger.info(
435             "layer \"{}\" -> feature type(s) {} of WFS {}",
436             layerName,
437             description.typeNames(),
438             description.wfsUrl());
439       }
440     }
441     return descriptions;
442   }
443 
444   public void findAndSaveRelatedWFS(GeoService geoService) {
445     if (geoService.getProtocol() != WMS) {
446       throw new IllegalArgumentException();
447     }
448 
449     // TODO: report back progress
450 
451     Map<String, SimpleWFSLayerDescription> wfsByLayer = this.findRelatedWFS(geoService);
452 
453     wfsByLayer.values().stream()
454         .map(SimpleWFSLayerDescription::wfsUrl)
455         .distinct()
456         .forEach(url -> {
457           TMFeatureSource fs = featureSourceRepository.findByUrl(url);
458           if (fs == null) {
459             fs = new TMFeatureSource()
460                 .setProtocol(WFS)
461                 .setUrl(url)
462                 .setTitle("WFS for " + geoService.getTitle())
463                 .setLinkedService(geoService);
464             try {
465               new WFSFeatureSourceHelper().loadCapabilities(fs, tailormapConfig.getTimeout());
466             } catch (IOException e) {
467               String msg = String.format(
468                   "Error loading WFS from URL %s: %s: %s", url, e.getClass(), e.getMessage());
469               if (logger.isTraceEnabled()) {
470                 logger.error(msg, e);
471               } else {
472                 logger.error(msg);
473               }
474             }
475             featureSourceRepository.save(fs);
476           }
477         });
478   }
479 
480   /**
481    * Try to extract the legend url from the styles of the layer. This works by getting all styles for the layer and
482    * then removing any styles attached to other (parent) layers from the list of all styles. What remains is/are the
483    * legend url(s) for the layer. <i>NOTE: when a layer has more than one -not an inherited style- style the first
484    * style is used.</i>
485    *
486    * @param service the service that has the layer
487    * @param serviceLayer the layer to get the legend url for
488    * @return a URI to the legend image or null if not found
489    */
490   public static URI getLayerLegendUrlFromStyles(GeoService service, GeoServiceLayer serviceLayer) {
491     if (serviceLayer.getRoot()) {
492       // if this is a root layer, there are no parent layers, return the first style we find for
493       // this layer, if any
494       return serviceLayer.getStyles().stream()
495           .findFirst()
496           .map(WMSStyle::getLegendURL)
497           .orElse(null);
498     }
499 
500     final List<WMSStyle> allOurLayersStyles = serviceLayer.getStyles();
501     if (allOurLayersStyles.size() == 1) {
502       return allOurLayersStyles.get(0).getLegendURL();
503     }
504     // remove the styles from all the other layer(s) from the list of all our layers styles
505     service.getLayers().stream()
506         .filter(layer -> !layer.equals(serviceLayer))
507         .forEach(layer -> allOurLayersStyles.removeAll(layer.getStyles()));
508 
509     return allOurLayersStyles.stream()
510         .findFirst()
511         .map(WMSStyle::getLegendURL)
512         .orElse(null);
513   }
514 }