1
2
3
4
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")) {
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
103
104
105
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
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
195
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
294 if (legendUrls != null && !legendUrls.isEmpty() && legendUrls.getFirst() != null) {
295 style.legendUrl(new URI((String) legendUrls.getFirst()));
296 }
297 } catch (URISyntaxException ignored) {
298
299
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
317
318
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
339 DocumentFactory.ENABLE_DTD, true));
340 } catch (ClassCastException | IllegalStateException e) {
341
342
343
344
345
346
347
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
361
362
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
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
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
442
443
444
445
446
447
448
449
450 public static URI getLayerLegendUrlFromStyles(GeoService service, GeoServiceLayer serviceLayer) {
451 if (serviceLayer.getRoot()) {
452
453
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
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 }