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