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