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