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