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 java.util.stream.Collectors.toSet;
9   import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.linkTo;
10  import static org.tailormap.api.persistence.helper.GeoServiceHelper.getWmsRequest;
11  import static org.tailormap.api.persistence.json.GeoServiceProtocol.LEGEND;
12  import static org.tailormap.api.persistence.json.GeoServiceProtocol.QUANTIZEDMESH;
13  import static org.tailormap.api.persistence.json.GeoServiceProtocol.TILES3D;
14  import static org.tailormap.api.persistence.json.GeoServiceProtocol.XYZ;
15  import static org.tailormap.api.util.TMStringUtils.nullIfEmpty;
16  
17  import jakarta.persistence.EntityManager;
18  import java.lang.invoke.MethodHandles;
19  import java.net.URI;
20  import java.nio.charset.StandardCharsets;
21  import java.util.HashMap;
22  import java.util.List;
23  import java.util.Map;
24  import java.util.Objects;
25  import java.util.Optional;
26  import java.util.Set;
27  import org.apache.commons.lang3.ObjectUtils;
28  import org.geotools.api.referencing.crs.CoordinateReferenceSystem;
29  import org.geotools.referencing.util.CRSUtilities;
30  import org.geotools.referencing.wkt.Formattable;
31  import org.slf4j.Logger;
32  import org.slf4j.LoggerFactory;
33  import org.springframework.stereotype.Service;
34  import org.springframework.transaction.annotation.Transactional;
35  import org.springframework.web.util.UriComponentsBuilder;
36  import org.springframework.web.util.UriUtils;
37  import org.tailormap.api.controller.GeoServiceProxyController;
38  import org.tailormap.api.persistence.Application;
39  import org.tailormap.api.persistence.Configuration;
40  import org.tailormap.api.persistence.GeoService;
41  import org.tailormap.api.persistence.SearchIndex;
42  import org.tailormap.api.persistence.TMFeatureType;
43  import org.tailormap.api.persistence.json.AppContent;
44  import org.tailormap.api.persistence.json.AppLayerSettings;
45  import org.tailormap.api.persistence.json.AppTreeLayerNode;
46  import org.tailormap.api.persistence.json.AppTreeLevelNode;
47  import org.tailormap.api.persistence.json.AppTreeNode;
48  import org.tailormap.api.persistence.json.Bounds;
49  import org.tailormap.api.persistence.json.GeoServiceDefaultLayerSettings;
50  import org.tailormap.api.persistence.json.GeoServiceLayer;
51  import org.tailormap.api.persistence.json.GeoServiceLayerSettings;
52  import org.tailormap.api.persistence.json.ServicePublishingSettings;
53  import org.tailormap.api.persistence.json.TileLayerHiDpiMode;
54  import org.tailormap.api.persistence.json.WMSStyle;
55  import org.tailormap.api.repository.ApplicationRepository;
56  import org.tailormap.api.repository.ConfigurationRepository;
57  import org.tailormap.api.repository.FeatureSourceRepository;
58  import org.tailormap.api.repository.GeoServiceRepository;
59  import org.tailormap.api.repository.SearchIndexRepository;
60  import org.tailormap.api.security.AuthorisationService;
61  import org.tailormap.api.viewer.model.AppLayer;
62  import org.tailormap.api.viewer.model.LayerSearchIndex;
63  import org.tailormap.api.viewer.model.LayerTreeNode;
64  import org.tailormap.api.viewer.model.MapResponse;
65  import org.tailormap.api.viewer.model.TMCoordinateReferenceSystem;
66  
67  @Service
68  public class ApplicationHelper {
69    private static final Logger logger =
70        LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
71    private static final String DEFAULT_WEB_MERCATOR_CRS = "EPSG:3857";
72  
73    private final GeoServiceHelper geoServiceHelper;
74    private final GeoServiceRepository geoServiceRepository;
75    private final ConfigurationRepository configurationRepository;
76    private final ApplicationRepository applicationRepository;
77    private final FeatureSourceRepository featureSourceRepository;
78    private final EntityManager entityManager;
79    private final AuthorisationService authorisationService;
80    private final SearchIndexRepository searchIndexRepository;
81  
82    public ApplicationHelper(
83        GeoServiceHelper geoServiceHelper,
84        GeoServiceRepository geoServiceRepository,
85        ConfigurationRepository configurationRepository,
86        ApplicationRepository applicationRepository,
87        FeatureSourceRepository featureSourceRepository,
88        EntityManager entityManager,
89        AuthorisationService authorisationService,
90        SearchIndexRepository searchIndexRepository) {
91      this.geoServiceHelper = geoServiceHelper;
92      this.geoServiceRepository = geoServiceRepository;
93      this.configurationRepository = configurationRepository;
94      this.applicationRepository = applicationRepository;
95      this.featureSourceRepository = featureSourceRepository;
96      this.entityManager = entityManager;
97      this.authorisationService = authorisationService;
98      this.searchIndexRepository = searchIndexRepository;
99    }
100 
101   public Application getServiceApplication(String baseAppName, String projection, GeoService service) {
102     if (baseAppName == null) {
103       baseAppName = Optional.ofNullable(service.getSettings().getPublishing())
104           .map(ServicePublishingSettings::getBaseApp)
105           .orElseGet(() -> configurationRepository.get(Configuration.DEFAULT_BASE_APP));
106     }
107 
108     Application baseApp = null;
109     if (baseAppName != null) {
110       baseApp = applicationRepository.findByName(baseAppName);
111       if (baseApp != null) {
112         // Caller may be changing the app content to add layers from this service, detach so those
113         // aren't saved
114         entityManager.detach(baseApp);
115       }
116     }
117 
118     Application app = baseApp != null ? baseApp : new Application().setContentRoot(new AppContent());
119 
120     if (projection != null) {
121       // TODO: filter layers by projection parameter (layer.crs must inherit crs from parent layers)
122       throw new UnsupportedOperationException("Projection filtering not yet supported");
123     } else {
124       if (baseApp != null) {
125         projection = baseApp.getCrs();
126       } else {
127         projection = DEFAULT_WEB_MERCATOR_CRS;
128       }
129     }
130 
131     app.setName(service.getId()).setTitle(service.getTitle()).setCrs(projection);
132 
133     return app;
134   }
135 
136   @Transactional
137   public MapResponse toMapResponse(Application app) {
138     MapResponse mapResponse = new MapResponse();
139     setCrsAndBounds(app, mapResponse);
140     setLayers(app, mapResponse);
141     return mapResponse;
142   }
143 
144   public void setCrsAndBounds(Application a, MapResponse mapResponse) {
145     CoordinateReferenceSystem gtCrs = a.getGeoToolsCoordinateReferenceSystem();
146     if (gtCrs == null) {
147       throw new IllegalArgumentException("Invalid CRS: " + a.getCrs());
148     }
149 
150     TMCoordinateReferenceSystem crs = new TMCoordinateReferenceSystem()
151         .code(a.getCrs())
152         .definition(((Formattable) gtCrs).toWKT(0))
153         .bounds(GeoToolsHelper.fromCRS(gtCrs))
154         .unit(Optional.ofNullable(CRSUtilities.getUnit(gtCrs.getCoordinateSystem()))
155             .map(Objects::toString)
156             .orElse(null));
157 
158     Bounds maxExtent = a.getMaxExtent() != null ? a.getMaxExtent() : crs.getBounds();
159     Bounds initialExtent = a.getInitialExtent() != null ? a.getInitialExtent() : maxExtent;
160 
161     mapResponse.crs(crs).maxExtent(maxExtent).initialExtent(initialExtent);
162   }
163 
164   private void setLayers(Application app, MapResponse mr) {
165     new MapResponseLayerBuilder(app, mr).buildLayers();
166   }
167 
168   private String getProxyUrl(GeoService geoService, Application application, AppTreeLayerNode appTreeLayerNode) {
169     String baseProxyUrl = linkTo(
170             GeoServiceProxyController.class,
171             Map.of(
172                 "viewerKind", "app", // XXX
173                 "viewerName", application.getName(),
174                 "appLayerId", appTreeLayerNode.getId()))
175         .toString();
176 
177     String protocolPath = "/" + geoService.getProtocol().getValue();
178 
179     if (geoService.getProtocol() == TILES3D) {
180       return baseProxyUrl + protocolPath + "/" + GeoServiceProxyController.TILES3D_DESCRIPTION_PATH;
181     }
182     return baseProxyUrl + protocolPath;
183   }
184 
185   private String getLegendProxyUrl(Application application, AppTreeLayerNode appTreeLayerNode) {
186     return linkTo(
187             GeoServiceProxyController.class,
188             Map.of(
189                 "viewerKind",
190                 "app",
191                 "viewerName",
192                 application.getName(),
193                 "appLayerId",
194                 appTreeLayerNode.getId()))
195         + "/" + LEGEND.getValue();
196   }
197 
198   private List<WMSStyle> getProxiedLegendStyles(
199       Application application, AppTreeLayerNode appTreeLayerNode, List<WMSStyle> legendStyles) {
200     String legendProxyUrl = getLegendProxyUrl(application, appTreeLayerNode);
201     return legendStyles.stream()
202         .map(style -> {
203           try {
204             // Create a copy of the style so we don't mutate configuration objects
205             return new WMSStyle()
206                 .name(style.getName())
207                 .title(style.getTitle())
208                 .abstractText(style.getAbstractText())
209                 .legendUrl(UriComponentsBuilder.fromUriString(legendProxyUrl)
210                     .queryParam("STYLE", UriUtils.encode(style.getName(), StandardCharsets.UTF_8))
211                     .build(true)
212                     .toUri());
213           } catch (Exception e) {
214             logger.warn(
215                 "Failed to create proxied legend style for application {} layer {} style {}: {}",
216                 application.getId(),
217                 appTreeLayerNode.getId(),
218                 style.getName(),
219                 e.getMessage());
220             return null;
221           }
222         })
223         .filter(Objects::nonNull)
224         .toList();
225   }
226 
227   private class MapResponseLayerBuilder {
228     private final Application app;
229     private final MapResponse mapResponse;
230     // XXX not needed if we have GeoServiceLayer.getService().getName()
231     private final Map<GeoServiceLayer, String> serviceLayerServiceIds = new HashMap<>();
232 
233     MapResponseLayerBuilder(Application app, MapResponse mapResponse) {
234       this.app = app;
235       this.mapResponse = mapResponse;
236     }
237 
238     void buildLayers() {
239       if (app.getContentRoot() != null) {
240         buildBackgroundLayers();
241         buildOverlayLayers();
242         buildTerrainLayers();
243       }
244     }
245 
246     private void buildBackgroundLayers() {
247       if (app.getContentRoot().getBaseLayerNodes() != null) {
248         for (AppTreeNode node : app.getContentRoot().getBaseLayerNodes()) {
249           addAppTreeNodeItem(node, mapResponse.getBaseLayerTreeNodes());
250         }
251 
252         Set<String> validLayerIds =
253             mapResponse.getAppLayers().stream().map(AppLayer::getId).collect(toSet());
254         List<LayerTreeNode> initialLayerTreeNodes = mapResponse.getBaseLayerTreeNodes();
255 
256         mapResponse.setBaseLayerTreeNodes(cleanLayerTreeNodes(validLayerIds, initialLayerTreeNodes));
257       }
258     }
259 
260     private void buildOverlayLayers() {
261       if (app.getContentRoot().getLayerNodes() != null) {
262         for (AppTreeNode node : app.getContentRoot().getLayerNodes()) {
263           addAppTreeNodeItem(node, mapResponse.getLayerTreeNodes());
264         }
265         Set<String> validLayerIds =
266             mapResponse.getAppLayers().stream().map(AppLayer::getId).collect(toSet());
267         List<LayerTreeNode> initialLayerTreeNodes = mapResponse.getLayerTreeNodes();
268 
269         mapResponse.setLayerTreeNodes(cleanLayerTreeNodes(validLayerIds, initialLayerTreeNodes));
270       }
271     }
272 
273     /**
274      * Cleans the layer tree nodes by removing references to non-existing layers and removing level nodes without
275      * children, thus preventing exposure of application internals to unauthorized users.
276      *
277      * @param validLayerIds the ids of the layers that exist
278      * @param initialLayerTreeNodes the initial list of layer tree nodes
279      * @return the cleaned list of layer tree nodes
280      */
281     private List<LayerTreeNode> cleanLayerTreeNodes(
282         Set<String> validLayerIds, List<LayerTreeNode> initialLayerTreeNodes) {
283       List<String> levelNodes = initialLayerTreeNodes.stream()
284           .filter(n -> n.getAppLayerId() == null)
285           .map(LayerTreeNode::getId)
286           .toList();
287 
288       List<LayerTreeNode> newLayerTreeNodes = initialLayerTreeNodes.stream()
289           .peek(n -> {
290             n.getChildrenIds()
291                 .removeIf(childId ->
292                     /* remove invalid layers from the children */
293                     !validLayerIds.contains(childId) && !levelNodes.contains(childId));
294           })
295           .filter(n ->
296               /* remove level nodes without children */
297               !(n.getAppLayerId() == null
298                   && (n.getChildrenIds() != null
299                       && n.getChildrenIds().isEmpty())))
300           .toList();
301 
302       List<String> cleanLevelNodeIds = newLayerTreeNodes.stream()
303           .filter(n -> n.getAppLayerId() == null)
304           .map(LayerTreeNode::getId)
305           .toList();
306 
307       return newLayerTreeNodes.stream()
308           .peek(n -> {
309             n.getChildrenIds()
310                 .removeIf(childId ->
311                     /* remove invalid layers from the children */
312                     !cleanLevelNodeIds.contains(childId) && levelNodes.contains(childId));
313           })
314           .toList();
315     }
316 
317     private void buildTerrainLayers() {
318       if (app.getContentRoot().getTerrainLayerNodes() != null) {
319         for (AppTreeNode node : app.getContentRoot().getTerrainLayerNodes()) {
320           addAppTreeNodeItem(node, mapResponse.getTerrainLayerTreeNodes());
321         }
322       }
323     }
324 
325     private void addAppTreeNodeItem(AppTreeNode node, List<LayerTreeNode> layerTreeNodeList) {
326       LayerTreeNode layerTreeNode = new LayerTreeNode();
327       if ("AppTreeLayerNode".equals(node.getObjectType())) {
328         AppTreeLayerNode appTreeLayerNode = (AppTreeLayerNode) node;
329         layerTreeNode.setId(appTreeLayerNode.getId());
330         layerTreeNode.setAppLayerId(appTreeLayerNode.getId());
331         if (!addAppLayerItem(appTreeLayerNode)) {
332           return;
333         }
334         // This name is not displayed in the frontend, the title from the appLayer node is used
335         layerTreeNode.setName(appTreeLayerNode.getLayerName());
336         layerTreeNode.setDescription(appTreeLayerNode.getDescription());
337       } else if ("AppTreeLevelNode".equals(node.getObjectType())) {
338         AppTreeLevelNode appTreeLevelNode = (AppTreeLevelNode) node;
339         layerTreeNode.setId(appTreeLevelNode.getId());
340         layerTreeNode.setChildrenIds(appTreeLevelNode.getChildrenIds());
341         layerTreeNode.setRoot(Boolean.TRUE.equals(appTreeLevelNode.getRoot()));
342         // The name for a level node does show in the frontend
343         layerTreeNode.setName(appTreeLevelNode.getTitle());
344         layerTreeNode.setDescription(appTreeLevelNode.getDescription());
345         layerTreeNode.setExpandOnStartup(appTreeLevelNode.getExpandOnStartup());
346       }
347       layerTreeNodeList.add(layerTreeNode);
348     }
349 
350     private boolean addAppLayerItem(AppTreeLayerNode layerRef) {
351       ServiceLayerInfo layerInfo = findServiceLayer(layerRef);
352       if (layerInfo == null) {
353         return false;
354       }
355       GeoService service = layerInfo.service();
356       GeoServiceLayer serviceLayer = layerInfo.serviceLayer();
357 
358       // Some settings can be set on the app layer level, layer level or service level (default
359       // layer settings). These settings should be used in-order: from app layer if set, otherwise
360       // from layer level, from default layer setting or the default.
361 
362       // An empty (blank) string means it is not set. To explicitly clear a layer level string
363       // setting for an app, an admin should set the app layer setting to spaces.
364 
365       // The JSON wrapper classes have "null" values as defaults, which means not-set. The defaults
366       // (such as tilingDisabled being true) are applied below, although the frontend would also
367       // treat null as non-truthy.
368 
369       // When default layer settings or settings for a specific layer are missing, construct new
370       // settings objects so no null check is needed. All properties are initialized to null
371       // (not-set) by default.
372       GeoServiceDefaultLayerSettings defaultLayerSettings = Optional.ofNullable(
373               service.getSettings().getDefaultLayerSettings())
374           .orElseGet(GeoServiceDefaultLayerSettings::new);
375       GeoServiceLayerSettings serviceLayerSettings =
376           Optional.ofNullable(layerInfo.layerSettings()).orElseGet(GeoServiceLayerSettings::new);
377 
378       AppLayerSettings appLayerSettings = app.getAppLayerSettings(layerRef);
379 
380       String title = Objects.requireNonNullElse(
381           nullIfEmpty(appLayerSettings.getTitle()),
382           // Get title from layer settings, title from capabilities or the layer name -- never
383           // null
384           service.getTitleWithSettingsOverrides(layerRef.getLayerName()));
385 
386       // These settings can be overridden per appLayer
387 
388       String description = ObjectUtils.firstNonNull(
389           nullIfEmpty(appLayerSettings.getDescription()),
390           nullIfEmpty(serviceLayerSettings.getDescription()),
391           nullIfEmpty(defaultLayerSettings.getDescription()));
392 
393       String attribution = ObjectUtils.firstNonNull(
394           nullIfEmpty(appLayerSettings.getAttribution()),
395           nullIfEmpty(serviceLayerSettings.getAttribution()),
396           nullIfEmpty(defaultLayerSettings.getAttribution()));
397 
398       // These settings can't be overridden per appLayer but can be set on a per-layer and
399       // service-level default basis
400 
401       boolean tilingDisabled = ObjectUtils.firstNonNull(
402           serviceLayerSettings.getTilingDisabled(), defaultLayerSettings.getTilingDisabled(), true);
403       Integer tilingGutter = ObjectUtils.firstNonNull(
404           serviceLayerSettings.getTilingGutter(), defaultLayerSettings.getTilingGutter(), 0);
405       boolean hiDpiDisabled = ObjectUtils.firstNonNull(
406           serviceLayerSettings.getHiDpiDisabled(), defaultLayerSettings.getHiDpiDisabled(), true);
407       TileLayerHiDpiMode hiDpiMode = ObjectUtils.firstNonNull(
408           serviceLayerSettings.getHiDpiMode(), defaultLayerSettings.getHiDpiMode(), null);
409       // Do not get from defaultLayerSettings because a default wouldn't make sense
410       String hiDpiSubstituteLayer = serviceLayerSettings.getHiDpiSubstituteLayer();
411 
412       TMFeatureType tmft = service.findFeatureTypeForLayer(serviceLayer, featureSourceRepository);
413 
414       boolean proxied = service.getSettings().getUseProxy();
415 
416       String legendImageUrl = serviceLayerSettings.getLegendImageId();
417       AppLayer.LegendTypeEnum legendType = AppLayer.LegendTypeEnum.STATIC;
418 
419       if (legendImageUrl == null && serviceLayer.getStyles() != null) {
420         // no user defined legend image, try to get legend image from styles
421         legendImageUrl = Optional.ofNullable(
422                 GeoServiceHelper.getLayerLegendUrlFromStyles(service, serviceLayer))
423             .map(URI::toString)
424             .orElse(null);
425 
426         if (legendImageUrl != null) {
427           // Check whether the legend is dynamic or static based on the original legend URL, before it is
428           // possibly replaced by URL to the proxy controller
429           legendType = "GetLegendGraphic".equalsIgnoreCase(getWmsRequest(legendImageUrl))
430               ? AppLayer.LegendTypeEnum.DYNAMIC
431               : AppLayer.LegendTypeEnum.STATIC;
432 
433           if (proxied) {
434             // service styles provides a legend image, but we need to proxy it
435             legendImageUrl = getLegendProxyUrl(app, layerRef);
436           }
437         }
438       }
439 
440       List<WMSStyle> legendStyles = appLayerSettings.getSelectedStyles();
441       if (proxied && legendStyles != null) {
442         // when proxied the urls must be passed through getProxiedLegendStyles to be accessible,
443         // so don't pass the original style URLs to the frontend
444         legendStyles = getProxiedLegendStyles(app, layerRef, legendStyles);
445       }
446 
447       SearchIndex searchIndex = null;
448       if (appLayerSettings.getSearchIndexId() != null) {
449         searchIndex = searchIndexRepository
450             .findById(appLayerSettings.getSearchIndexId())
451             .orElse(null);
452       }
453 
454       boolean webMercatorAvailable = this.isWebMercatorAvailable(service, serviceLayer, hiDpiSubstituteLayer);
455 
456       mapResponse.addAppLayersItem(new AppLayer()
457           .id(layerRef.getId())
458           .serviceId(serviceLayerServiceIds.get(serviceLayer))
459           .layerName(layerRef.getLayerName())
460           .hasAttributes(tmft != null)
461           .editable(TMFeatureTypeHelper.isEditable(app, layerRef, tmft))
462           .url(proxied ? getProxyUrl(service, app, layerRef) : null)
463           // Can't set whether layer is opaque, not mapped from WMS capabilities by GeoTools
464           // gt-wms Layer class?
465           .maxScale(serviceLayer.getMaxScale())
466           .minScale(serviceLayer.getMinScale())
467           .title(title)
468           .tilingDisabled(tilingDisabled)
469           .tilingGutter(tilingGutter)
470           .hiDpiDisabled(hiDpiDisabled)
471           .hiDpiMode(hiDpiMode)
472           .hiDpiSubstituteLayer(hiDpiSubstituteLayer)
473           .minZoom(serviceLayerSettings.getMinZoom())
474           .maxZoom(serviceLayerSettings.getMaxZoom())
475           .tileSize(serviceLayerSettings.getTileSize())
476           .tileGridExtent(serviceLayerSettings.getTileGridExtent())
477           .opacity(appLayerSettings.getOpacity())
478           .autoRefreshInSeconds(appLayerSettings.getAutoRefreshInSeconds())
479           .searchIndex(
480               searchIndex != null
481                   ? new LayerSearchIndex()
482                       .id(searchIndex.getId())
483                       .name(searchIndex.getName())
484                   : null)
485           .legendImageUrl(legendImageUrl)
486           .legendType(legendType)
487           .visible(layerRef.getVisible())
488           .attribution(attribution)
489           .description(description)
490           .webMercatorAvailable(webMercatorAvailable)
491           .tileset3dStyle(appLayerSettings.getTileset3dStyle())
492           .hiddenFunctionality(appLayerSettings.getHiddenFunctionality())
493           .styles(legendStyles));
494 
495       return true;
496     }
497 
498     private ServiceLayerInfo findServiceLayer(AppTreeLayerNode layerRef) {
499       GeoService service =
500           geoServiceRepository.findById(layerRef.getServiceId()).orElse(null);
501       if (service == null) {
502         logger.warn(
503             "App {} references layer \"{}\" of missing service {}",
504             app.getId(),
505             layerRef.getLayerName(),
506             layerRef.getServiceId());
507         return null;
508       }
509 
510       if (!authorisationService.userAllowedToViewGeoService(service)) {
511         return null;
512       }
513 
514       GeoServiceLayer serviceLayer = service.findLayer(layerRef.getLayerName());
515 
516       if (serviceLayer == null) {
517         logger.warn(
518             "App {} references layer \"{}\" not found in capabilities of service {}",
519             app.getId(),
520             layerRef.getLayerName(),
521             service.getId());
522         return null;
523       }
524 
525       if (!authorisationService.userAllowedToViewGeoServiceLayer(service, serviceLayer)) {
526         logger.debug(
527             "User not allowed to view layer {} of service {}", serviceLayer.getName(), service.getId());
528         return null;
529       }
530 
531       serviceLayerServiceIds.put(serviceLayer, service.getId());
532 
533       if (mapResponse.getServices().stream()
534           .filter(s -> s.getId().equals(service.getId()))
535           .findAny()
536           .isEmpty()) {
537         mapResponse.addServicesItem(service.toJsonPojo(geoServiceHelper));
538       }
539 
540       GeoServiceLayerSettings layerSettings = service.getLayerSettings(layerRef.getLayerName());
541       return new ServiceLayerInfo(service, serviceLayer, layerSettings);
542     }
543 
544     private boolean isWebMercatorAvailable(
545         GeoService service, GeoServiceLayer serviceLayer, String hiDpiSubstituteLayer) {
546       if (service.getProtocol() == XYZ) {
547         return DEFAULT_WEB_MERCATOR_CRS.equals(service.getSettings().getXyzCrs());
548       }
549       if (service.getProtocol() == TILES3D || service.getProtocol() == QUANTIZEDMESH) {
550         return false;
551       }
552       if (hiDpiSubstituteLayer != null) {
553         GeoServiceLayer hiDpiSubstituteServiceLayer = service.findLayer(hiDpiSubstituteLayer);
554         if (hiDpiSubstituteServiceLayer != null
555             && !this.isWebMercatorAvailable(service, hiDpiSubstituteServiceLayer, null)) {
556           return false;
557         }
558       }
559       while (serviceLayer != null) {
560         Set<String> layerCrs = serviceLayer.getCrs();
561         if (layerCrs.contains(DEFAULT_WEB_MERCATOR_CRS)) {
562           return true;
563         }
564         if (serviceLayer.getRoot()) {
565           break;
566         }
567         serviceLayer = service.getParentLayer(serviceLayer.getId());
568       }
569       return false;
570     }
571 
572     record ServiceLayerInfo(
573         GeoService service, GeoServiceLayer serviceLayer, GeoServiceLayerSettings layerSettings) {}
574   }
575 }