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