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