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