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.json.GeoServiceProtocol.QUANTIZEDMESH;
9   import static org.tailormap.api.persistence.json.GeoServiceProtocol.TILES3D;
10  import static org.tailormap.api.persistence.json.GeoServiceProtocol.XYZ;
11  
12  import java.io.IOException;
13  import java.lang.invoke.MethodHandles;
14  import java.net.URI;
15  import java.net.URISyntaxException;
16  import java.nio.charset.StandardCharsets;
17  import java.time.Instant;
18  import java.util.ArrayList;
19  import java.util.List;
20  import java.util.Map;
21  import java.util.Objects;
22  import java.util.Optional;
23  import java.util.Set;
24  import java.util.function.Predicate;
25  import java.util.stream.Collectors;
26  import org.apache.commons.lang3.StringUtils;
27  import org.geotools.api.data.ServiceInfo;
28  import org.geotools.data.ows.AbstractOpenWebService;
29  import org.geotools.data.ows.Capabilities;
30  import org.geotools.data.ows.OperationType;
31  import org.geotools.http.HTTPClientFinder;
32  import org.geotools.ows.wms.Layer;
33  import org.geotools.ows.wms.WMSCapabilities;
34  import org.geotools.ows.wms.WebMapServer;
35  import org.geotools.ows.wmts.WebMapTileServer;
36  import org.geotools.ows.wmts.model.WMTSLayer;
37  import org.geotools.xml.DocumentFactory;
38  import org.slf4j.Logger;
39  import org.slf4j.LoggerFactory;
40  import org.springframework.beans.factory.annotation.Autowired;
41  import org.springframework.http.MediaType;
42  import org.springframework.stereotype.Service;
43  import org.springframework.web.util.UriComponentsBuilder;
44  import org.tailormap.api.configuration.TailormapConfig;
45  import org.tailormap.api.geotools.ResponseTeeingHTTPClient;
46  import org.tailormap.api.geotools.WMSServiceExceptionUtil;
47  import org.tailormap.api.persistence.GeoService;
48  import org.tailormap.api.persistence.json.GeoServiceLayer;
49  import org.tailormap.api.persistence.json.ServiceAuthentication;
50  import org.tailormap.api.persistence.json.TMServiceCapabilitiesRequest;
51  import org.tailormap.api.persistence.json.TMServiceCapabilitiesRequestGetFeatureInfo;
52  import org.tailormap.api.persistence.json.TMServiceCapabilitiesRequestGetMap;
53  import org.tailormap.api.persistence.json.TMServiceCaps;
54  import org.tailormap.api.persistence.json.TMServiceCapsCapabilities;
55  import org.tailormap.api.persistence.json.TMServiceInfo;
56  import org.tailormap.api.persistence.json.WMSStyle;
57  
58  @Service
59  public class GeoServiceHelper {
60  
61    private static final Logger logger =
62        LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
63    private final TailormapConfig tailormapConfig;
64  
65    @Autowired
66    public GeoServiceHelper(TailormapConfig tailormapConfig) {
67      this.tailormapConfig = tailormapConfig;
68    }
69  
70    public static org.tailormap.api.viewer.model.Service.ServerTypeEnum guessServerTypeFromUrl(String url) {
71  
72      if (StringUtils.isBlank(url)) {
73        return org.tailormap.api.viewer.model.Service.ServerTypeEnum.GENERIC;
74      }
75      if (url.contains("/arcgis/")) {
76        return org.tailormap.api.viewer.model.Service.ServerTypeEnum.GENERIC;
77      }
78      if (url.contains("/geoserver/")) {
79        return org.tailormap.api.viewer.model.Service.ServerTypeEnum.GEOSERVER;
80      }
81      if (url.contains("/mapserv")) { // /cgi-bin/mapserv, /cgi-bin/mapserv.cgi, /cgi-bin/mapserv.fcgi
82        return org.tailormap.api.viewer.model.Service.ServerTypeEnum.MAPSERVER;
83      }
84      return org.tailormap.api.viewer.model.Service.ServerTypeEnum.GENERIC;
85    }
86  
87    public static String getWmsRequest(String uri) {
88      return getWmsRequest(uri == null ? null : URI.create(uri));
89    }
90  
91    /**
92     * Extracts the value of the WMS "REQUEST" parameter (case-insensitive) from the given URI.
93     *
94     * @param uri the URI to extract the request parameter from
95     * @return the value of the request parameter, or null if not found
96     */
97    public static String getWmsRequest(URI uri) {
98      if (uri == null || uri.getQuery() == null) {
99        return null;
100     }
101     return UriComponentsBuilder.fromUri(uri).build().getQueryParams().entrySet().stream()
102         .filter(entry -> "request".equalsIgnoreCase(entry.getKey()))
103         .map(entry -> entry.getValue().getFirst())
104         .findFirst()
105         .orElse(null);
106   }
107 
108   public void loadServiceCapabilities(GeoService geoService) throws Exception {
109 
110     if (geoService.getProtocol() == XYZ) {
111       setXyzCapabilities(geoService);
112       return;
113     }
114 
115     if (geoService.getProtocol() == TILES3D) {
116       set3DTilesCapabilities(geoService);
117       return;
118     }
119 
120     if (geoService.getProtocol() == QUANTIZEDMESH) {
121       setQuantizedMeshCapabilities(geoService);
122       return;
123     }
124 
125     ResponseTeeingHTTPClient client = new ResponseTeeingHTTPClient(
126         HTTPClientFinder.createClient(), null, Set.of("Access-Control-Allow-Origin"));
127 
128     ServiceAuthentication auth = geoService.getAuthentication();
129     if (auth != null && auth.getMethod() == ServiceAuthentication.MethodEnum.PASSWORD) {
130       client.setUser(auth.getUsername());
131       client.setPassword(auth.getPassword());
132     }
133 
134     client.setReadTimeout(this.tailormapConfig.getTimeout());
135     client.setConnectTimeout(this.tailormapConfig.getTimeout());
136     client.setTryGzip(true);
137 
138     logger.info(
139         "Get capabilities for {} {} from URL {}",
140         geoService.getProtocol(),
141         geoService.getId() == null ? "(new)" : "id " + geoService.getId(),
142         geoService.getUrl());
143 
144     // TODO: micrometer met tags voor URL/id van service
145 
146     switch (geoService.getProtocol()) {
147       case WMS -> loadWMSCapabilities(geoService, client);
148       case WMTS -> loadWMTSCapabilities(geoService, client);
149       default ->
150         throw new UnsupportedOperationException(
151             "Unsupported geo service protocol: " + geoService.getProtocol());
152     }
153 
154     if (geoService.getTitle() == null) {
155       geoService.setTitle(Optional.ofNullable(geoService.getServiceCapabilities())
156           .map(TMServiceCaps::getServiceInfo)
157           .map(TMServiceInfo::getTitle)
158           .orElse(null));
159     }
160 
161     if (logger.isDebugEnabled()) {
162       logger.debug("Loaded service layers: {}", geoService.getLayers());
163     } else {
164       logger.info(
165           "Loaded service layers: {}",
166           geoService.getLayers().stream()
167               .filter(Predicate.not(GeoServiceLayer::getVirtual))
168               .map(GeoServiceLayer::getName)
169               .collect(Collectors.toList()));
170     }
171   }
172 
173   private static void setXyzCapabilities(GeoService geoService) {
174     geoService.setLayers(List.of(new GeoServiceLayer()
175         .id("0")
176         .root(true)
177         .name("xyz")
178         .title(geoService.getTitle())
179         .crs(Set.of(geoService.getSettings().getXyzCrs()))
180         .virtual(false)
181         .queryable(false)));
182   }
183 
184   private static void set3DTilesCapabilities(GeoService geoService) {
185     geoService.setLayers(List.of(new GeoServiceLayer()
186         .id("0")
187         .root(true)
188         .name("tiles3d")
189         .title(geoService.getTitle())
190         .virtual(false)
191         .queryable(false)));
192   }
193 
194   private static void setQuantizedMeshCapabilities(GeoService geoService) {
195     geoService.setLayers(List.of(new GeoServiceLayer()
196         .id("0")
197         .root(true)
198         .name("quantizedmesh")
199         .title(geoService.getTitle())
200         .virtual(false)
201         .queryable(false)));
202   }
203 
204   private void setServiceInfo(
205       GeoService geoService,
206       ResponseTeeingHTTPClient client,
207       AbstractOpenWebService<? extends Capabilities, Layer> ows) {
208     geoService.setCapabilities(client.getLatestResponseCopy());
209     geoService.setCapabilitiesContentType(MediaType.APPLICATION_XML_VALUE);
210     geoService.setCapabilitiesFetched(Instant.now());
211 
212     ServiceInfo info = ows.getInfo();
213 
214     TMServiceCaps caps = new TMServiceCaps();
215     geoService.setServiceCapabilities(caps);
216 
217     caps.setCorsAllowOrigin(client.getLatestResponse().getResponseHeader("Access-Control-Allow-Origin"));
218 
219     if (info != null) {
220       if (StringUtils.isBlank(geoService.getTitle())) {
221         geoService.setTitle(info.getTitle());
222       }
223 
224       caps.serviceInfo(new TMServiceInfo()
225           .keywords(info.getKeywords())
226           .description(info.getDescription())
227           .title(info.getTitle())
228           .publisher(info.getPublisher())
229           .schema(info.getSchema())
230           .source(info.getSource()));
231 
232       geoService.setAdvertisedUrl(info.getSource().toString());
233     } else if (ows.getCapabilities() != null && ows.getCapabilities().getService() != null) {
234       org.geotools.data.ows.Service service = ows.getCapabilities().getService();
235 
236       if (StringUtils.isBlank(geoService.getTitle())) {
237         geoService.setTitle(service.getTitle());
238       }
239       caps.setServiceInfo(new TMServiceInfo().keywords(Set.copyOf(List.of(service.getKeywordList()))));
240     }
241   }
242 
243   private GeoServiceLayer toGeoServiceLayer(Layer l, List<? extends Layer> layers) {
244     return new GeoServiceLayer()
245         .id(String.valueOf(layers.indexOf(l)))
246         .name(l.getName())
247         .root(l.getParent() == null)
248         .title(l.getTitle())
249         .maxScale(Double.isNaN(l.getScaleDenominatorMax()) ? null : l.getScaleDenominatorMax())
250         .minScale(Double.isNaN(l.getScaleDenominatorMin()) ? null : l.getScaleDenominatorMin())
251         .virtual(l.getName() == null)
252         .crs(l.getSrs())
253         .latLonBoundingBox(GeoToolsHelper.boundsFromCRSEnvelope(l.getLatLonBoundingBox()))
254         .styles(l.getStyles().stream()
255             .map(gtStyle -> {
256               WMSStyle style = new WMSStyle()
257                   .name(gtStyle.getName())
258                   .title(Optional.ofNullable(gtStyle.getTitle())
259                       .map(Objects::toString)
260                       .orElse(null))
261                   .abstractText(Optional.ofNullable(gtStyle.getAbstract())
262                       .map(Objects::toString)
263                       .orElse(null));
264               try {
265                 List<?> legendUrls = gtStyle.getLegendURLs();
266                 // GeoTools will replace invalid URLs with null in legendURLs
267                 if (legendUrls != null && !legendUrls.isEmpty() && legendUrls.getFirst() != null) {
268                   style.legendUrl(new URI((String) legendUrls.getFirst()));
269                 }
270               } catch (URISyntaxException ignored) {
271                 // Won't occur because GeoTools would have already returned null on
272                 // invalid URL
273               }
274               return style;
275             })
276             .collect(Collectors.toList()))
277         .queryable(l.isQueryable())
278         .abstractText(l.get_abstract())
279         .keywords(l.getKeywords() == null ? Set.of() : Set.copyOf(List.of(l.getKeywords())))
280         .children(l.getLayerChildren().stream()
281             .map(layers::indexOf)
282             .map(String::valueOf)
283             .collect(Collectors.toList()));
284   }
285 
286   private void addLayerRecursive(
287       GeoService geoService, List<? extends Layer> layers, Layer layer, Set<String> parentCrs) {
288     GeoServiceLayer geoServiceLayer = toGeoServiceLayer(layer, layers);
289     // Crses are inherited from the parent and this is applied by GeoTools so Layer.getSrs() has all
290     // supported crses, but to save space we reverse that by only saving new crses for child layers
291     // -- just like the original WMS capabilities.
292     geoServiceLayer.getCrs().removeAll(parentCrs);
293     geoService.getLayers().add(geoServiceLayer);
294     for (Layer l : layer.getLayerChildren()) {
295       addLayerRecursive(geoService, layers, l, layer.getSrs());
296     }
297   }
298 
299   private void loadWMSCapabilities(GeoService geoService, ResponseTeeingHTTPClient client) throws Exception {
300     WebMapServer wms;
301     try {
302       wms = new WebMapServer(
303           new URI(geoService.getUrl()).toURL(),
304           client,
305           Map.of(
306               // allow DTD for WMS 1.1.x
307               DocumentFactory.ENABLE_DTD, true));
308     } catch (ClassCastException | IllegalStateException e) {
309       // The gt-wms module tries to cast the XML unmarshalling result expecting capabilities, but a
310       // WMS 1.0.0/1.1.0 ServiceException may have been unmarshalled which leads to a
311       // ClassCastException.
312 
313       // A WMS 1.3.0 ServiceExceptionReport leads to an IllegalStateException because of a call to
314       // Throwable.initCause() on a SAXException that already has a cause.
315       // In these cases, try to extract a message from the HTTP response
316       String contentType = client.getLatestResponse().getContentType();
317       if (contentType != null && contentType.contains("text/xml")) {
318         String wmsException =
319             WMSServiceExceptionUtil.tryGetServiceExceptionMessage(client.getLatestResponseCopy());
320         throw new Exception("Error loading WMS capabilities: "
321             + (wmsException != null
322                 ? wmsException
323                 : new String(client.getLatestResponseCopy(), StandardCharsets.UTF_8)));
324       } else {
325         throw e;
326       }
327     } catch (IOException e) {
328       // This tries to match a HttpURLConnection (which the default GeoTools SimpleHTTPClient uses)
329       // exception message. In a container environment the JVM is always in English so never
330       // localized.
331       if (e.getMessage().contains("Server returned HTTP response code: 401 for URL:")) {
332         throw new Exception(
333             "Error loading WMS, got 401 unauthorized response (credentials may be required or invalid)");
334       } else {
335         throw e;
336       }
337     }
338 
339     OperationType getMap = wms.getCapabilities().getRequest().getGetMap();
340     OperationType getFeatureInfo = wms.getCapabilities().getRequest().getGetFeatureInfo();
341 
342     if (getMap == null) {
343       throw new Exception("Service does not support GetMap");
344     }
345 
346     setServiceInfo(geoService, client, wms);
347 
348     WMSCapabilities wmsCapabilities = wms.getCapabilities();
349 
350     // TODO Jackson annotations op GeoTools classes of iets anders slims?
351 
352     geoService
353         .getServiceCapabilities()
354         .capabilities(new TMServiceCapsCapabilities()
355             .version(wmsCapabilities.getVersion())
356             .updateSequence(wmsCapabilities.getUpdateSequence())
357             .abstractText(wmsCapabilities.getService().get_abstract())
358             .request(new TMServiceCapabilitiesRequest()
359                 .getMap(new TMServiceCapabilitiesRequestGetMap()
360                     .formats(Set.copyOf(getMap.getFormats())))
361                 .getFeatureInfo(
362                     getFeatureInfo == null
363                         ? null
364                         : new TMServiceCapabilitiesRequestGetFeatureInfo()
365                             .formats(Set.copyOf(getFeatureInfo.getFormats())))
366                 .describeLayer(
367                     wms.getCapabilities().getRequest().getDescribeLayer() != null)));
368 
369     if (logger.isDebugEnabled()) {
370       logger.debug("Loaded capabilities, service capabilities: {}", geoService.getServiceCapabilities());
371     } else {
372       logger.info(
373           "Loaded capabilities from \"{}\", title: \"{}\"",
374           geoService.getUrl(),
375           geoService.getServiceCapabilities() != null
376                   && geoService.getServiceCapabilities().getServiceInfo() != null
377               ? geoService
378                   .getServiceCapabilities()
379                   .getServiceInfo()
380                   .getTitle()
381               : "(none)");
382     }
383     geoService.setLayers(new ArrayList<>());
384     addLayerRecursive(
385         geoService,
386         wms.getCapabilities().getLayerList(),
387         wms.getCapabilities().getLayer(),
388         Set.of());
389   }
390 
391   private void loadWMTSCapabilities(GeoService geoService, ResponseTeeingHTTPClient client) throws Exception {
392     WebMapTileServer wmts = new WebMapTileServer(new URI(geoService.getUrl()).toURL(), client);
393     setServiceInfo(geoService, client, wmts);
394 
395     // TODO set capabilities if we need something from it
396 
397     List<WMTSLayer> layers = wmts.getCapabilities().getLayerList();
398     geoService.setLayers(
399         layers.stream().map(l -> toGeoServiceLayer(l, layers)).collect(Collectors.toList()));
400   }
401 
402   /**
403    * Try to extract the legend url from the styles of the layer. This works by getting all styles for the layer and
404    * then removing any styles attached to other (parent) layers from the list of all styles. What remains is/are the
405    * legend url(s) for the layer. <i>NOTE: when a layer has more than one -not an inherited style- style the first
406    * style is used.</i>
407    *
408    * @param service the service that has the layer
409    * @param serviceLayer the layer to get the legend url for
410    * @return a URI to the legend image or null if not found
411    */
412   public static URI getLayerLegendUrlFromStyles(GeoService service, GeoServiceLayer serviceLayer) {
413     if (serviceLayer.getRoot()) {
414       // if this is a root layer, there are no parent layers, return the first style we find for
415       // this layer, if any
416       return serviceLayer.getStyles().stream()
417           .findFirst()
418           .map(WMSStyle::getLegendUrl)
419           .orElse(null);
420     }
421 
422     final List<WMSStyle> allOurLayersStyles = serviceLayer.getStyles();
423     if (allOurLayersStyles.size() == 1) {
424       return allOurLayersStyles.getFirst().getLegendUrl();
425     }
426     // remove the styles from all the other layer(s) from the list of all our layers styles
427     service.getLayers().stream()
428         .filter(layer -> !layer.equals(serviceLayer))
429         .forEach(layer -> allOurLayersStyles.removeAll(layer.getStyles()));
430 
431     return allOurLayersStyles.stream()
432         .findFirst()
433         .map(WMSStyle::getLegendUrl)
434         .orElse(null);
435   }
436 }