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