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 org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.linkTo;
9   import static org.tailormap.api.persistence.json.GeoServiceProtocol.PROXIEDLEGEND;
10  import static org.tailormap.api.util.TMStringUtils.nullIfEmpty;
11  
12  import jakarta.persistence.EntityManager;
13  import java.lang.invoke.MethodHandles;
14  import java.net.URI;
15  import java.util.HashMap;
16  import java.util.List;
17  import java.util.Map;
18  import java.util.Objects;
19  import java.util.Optional;
20  import org.apache.commons.lang3.ObjectUtils;
21  import org.apache.commons.lang3.tuple.Triple;
22  import org.geotools.api.referencing.crs.CoordinateReferenceSystem;
23  import org.geotools.referencing.util.CRSUtilities;
24  import org.geotools.referencing.wkt.Formattable;
25  import org.slf4j.Logger;
26  import org.slf4j.LoggerFactory;
27  import org.springframework.stereotype.Service;
28  import org.springframework.transaction.annotation.Transactional;
29  import org.tailormap.api.controller.GeoServiceProxyController;
30  import org.tailormap.api.persistence.Application;
31  import org.tailormap.api.persistence.Configuration;
32  import org.tailormap.api.persistence.GeoService;
33  import org.tailormap.api.persistence.SearchIndex;
34  import org.tailormap.api.persistence.TMFeatureType;
35  import org.tailormap.api.persistence.json.AppContent;
36  import org.tailormap.api.persistence.json.AppLayerSettings;
37  import org.tailormap.api.persistence.json.AppTreeLayerNode;
38  import org.tailormap.api.persistence.json.AppTreeLevelNode;
39  import org.tailormap.api.persistence.json.AppTreeNode;
40  import org.tailormap.api.persistence.json.Bounds;
41  import org.tailormap.api.persistence.json.GeoServiceDefaultLayerSettings;
42  import org.tailormap.api.persistence.json.GeoServiceLayer;
43  import org.tailormap.api.persistence.json.GeoServiceLayerSettings;
44  import org.tailormap.api.persistence.json.ServicePublishingSettings;
45  import org.tailormap.api.persistence.json.TileLayerHiDpiMode;
46  import org.tailormap.api.repository.ApplicationRepository;
47  import org.tailormap.api.repository.ConfigurationRepository;
48  import org.tailormap.api.repository.FeatureSourceRepository;
49  import org.tailormap.api.repository.GeoServiceRepository;
50  import org.tailormap.api.repository.SearchIndexRepository;
51  import org.tailormap.api.security.AuthorizationService;
52  import org.tailormap.api.viewer.model.AppLayer;
53  import org.tailormap.api.viewer.model.LayerSearchIndex;
54  import org.tailormap.api.viewer.model.LayerTreeNode;
55  import org.tailormap.api.viewer.model.MapResponse;
56  import org.tailormap.api.viewer.model.TMCoordinateReferenceSystem;
57  
58  @Service
59  public class ApplicationHelper {
60    private static final Logger logger =
61        LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
62    private static final String DEFAULT_WEB_MERCATOR_CRS = "EPSG:3857";
63  
64    private final GeoServiceHelper geoServiceHelper;
65    private final GeoServiceRepository geoServiceRepository;
66    private final ConfigurationRepository configurationRepository;
67    private final ApplicationRepository applicationRepository;
68    private final FeatureSourceRepository featureSourceRepository;
69    private final EntityManager entityManager;
70    private final AuthorizationService authorizationService;
71    private final SearchIndexRepository searchIndexRepository;
72  
73    public ApplicationHelper(
74        GeoServiceHelper geoServiceHelper,
75        GeoServiceRepository geoServiceRepository,
76        ConfigurationRepository configurationRepository,
77        ApplicationRepository applicationRepository,
78        FeatureSourceRepository featureSourceRepository,
79        EntityManager entityManager,
80        AuthorizationService authorizationService,
81        SearchIndexRepository searchIndexRepository) {
82      this.geoServiceHelper = geoServiceHelper;
83      this.geoServiceRepository = geoServiceRepository;
84      this.configurationRepository = configurationRepository;
85      this.applicationRepository = applicationRepository;
86      this.featureSourceRepository = featureSourceRepository;
87      this.entityManager = entityManager;
88      this.authorizationService = authorizationService;
89      this.searchIndexRepository = searchIndexRepository;
90    }
91  
92    public Application getServiceApplication(String baseAppName, String projection, GeoService service) {
93      if (baseAppName == null) {
94        baseAppName = Optional.ofNullable(service.getSettings().getPublishing())
95            .map(ServicePublishingSettings::getBaseApp)
96            .orElseGet(() -> configurationRepository.get(Configuration.DEFAULT_BASE_APP));
97      }
98  
99      Application baseApp = null;
100     if (baseAppName != null) {
101       baseApp = applicationRepository.findByName(baseAppName);
102       if (baseApp != null) {
103         // Caller may be changing the app content to add layers from this service, detach so those
104         // aren't saved
105         entityManager.detach(baseApp);
106       }
107     }
108 
109     Application app = baseApp != null ? baseApp : new Application().setContentRoot(new AppContent());
110 
111     if (projection != null) {
112       // TODO: filter layers by projection parameter (layer.crs must inherit crs from parent layers)
113       throw new UnsupportedOperationException("Projection filtering not yet supported");
114     } else {
115       if (baseApp != null) {
116         projection = baseApp.getCrs();
117       } else {
118         projection = DEFAULT_WEB_MERCATOR_CRS;
119       }
120     }
121 
122     app.setName(service.getId()).setTitle(service.getTitle()).setCrs(projection);
123 
124     return app;
125   }
126 
127   @Transactional
128   public MapResponse toMapResponse(Application app) {
129     MapResponse mapResponse = new MapResponse();
130     setCrsAndBounds(app, mapResponse);
131     setLayers(app, mapResponse);
132     return mapResponse;
133   }
134 
135   public void setCrsAndBounds(Application a, MapResponse mapResponse) {
136     CoordinateReferenceSystem gtCrs = a.getGeoToolsCoordinateReferenceSystem();
137     if (gtCrs == null) {
138       throw new IllegalArgumentException("Invalid CRS: " + a.getCrs());
139     }
140 
141     TMCoordinateReferenceSystem crs = new TMCoordinateReferenceSystem()
142         .code(a.getCrs())
143         .definition(((Formattable) gtCrs).toWKT(0))
144         .bounds(GeoToolsHelper.fromCRS(gtCrs))
145         .unit(Optional.ofNullable(CRSUtilities.getUnit(gtCrs.getCoordinateSystem()))
146             .map(Objects::toString)
147             .orElse(null));
148 
149     Bounds maxExtent = a.getMaxExtent() != null ? a.getMaxExtent() : crs.getBounds();
150     Bounds initialExtent = a.getInitialExtent() != null ? a.getInitialExtent() : maxExtent;
151 
152     mapResponse.crs(crs).maxExtent(maxExtent).initialExtent(initialExtent);
153   }
154 
155   public void setLayers(Application app, MapResponse mr) {
156     new MapResponseLayerBuilder(app, mr).buildLayers();
157   }
158 
159   private String getProxyUrl(GeoService geoService, Application application, AppTreeLayerNode appTreeLayerNode) {
160     return linkTo(
161             GeoServiceProxyController.class,
162             Map.of(
163                 "viewerKind", "app", // XXX
164                 "viewerName", application.getName(),
165                 "appLayerId", appTreeLayerNode.getId(),
166                 "protocol", geoService.getProtocol().getValue()))
167         .toString();
168   }
169 
170   private String getLegendProxyUrl(Application application, AppTreeLayerNode appTreeLayerNode) {
171     return linkTo(
172             GeoServiceProxyController.class,
173             Map.of(
174                 "viewerKind",
175                 "app",
176                 "viewerName",
177                 application.getName(),
178                 "appLayerId",
179                 appTreeLayerNode.getId(),
180                 "protocol",
181                 PROXIEDLEGEND.getValue()))
182         .toString();
183   }
184 
185   private class MapResponseLayerBuilder {
186     private final Application app;
187     private final MapResponse mr;
188     // XXX not needed if we have GeoServiceLayer.getService().getName()
189     private final Map<GeoServiceLayer, String> serviceLayerServiceIds = new HashMap<>();
190 
191     public MapResponseLayerBuilder(Application app, MapResponse mr) {
192       this.app = app;
193       this.mr = mr;
194     }
195 
196     public void buildLayers() {
197       if (app.getContentRoot() != null) {
198         buildBackgroundLayers();
199         buildOverlayLayers();
200       }
201     }
202 
203     private void buildBackgroundLayers() {
204       if (app.getContentRoot().getBaseLayerNodes() != null) {
205         for (AppTreeNode node : app.getContentRoot().getBaseLayerNodes()) {
206           addAppTreeNodeItem(node, mr.getBaseLayerTreeNodes());
207         }
208       }
209     }
210 
211     private void buildOverlayLayers() {
212       if (app.getContentRoot().getLayerNodes() != null) {
213         for (AppTreeNode node : app.getContentRoot().getLayerNodes()) {
214           addAppTreeNodeItem(node, mr.getLayerTreeNodes());
215         }
216       }
217     }
218 
219     private void addAppTreeNodeItem(AppTreeNode node, List<LayerTreeNode> layerTreeNodeList) {
220       LayerTreeNode layerTreeNode = new LayerTreeNode();
221       if ("AppTreeLayerNode".equals(node.getObjectType())) {
222         AppTreeLayerNode appTreeLayerNode = (AppTreeLayerNode) node;
223         layerTreeNode.setId(appTreeLayerNode.getId());
224         layerTreeNode.setAppLayerId(appTreeLayerNode.getId());
225         if (!addAppLayerItem(appTreeLayerNode)) {
226           return;
227         }
228         // This name is not displayed in the frontend, the title from the appLayer node is used
229         layerTreeNode.setName(appTreeLayerNode.getLayerName());
230         layerTreeNode.setDescription(appTreeLayerNode.getDescription());
231       } else if ("AppTreeLevelNode".equals(node.getObjectType())) {
232         AppTreeLevelNode appTreeLevelNode = (AppTreeLevelNode) node;
233         layerTreeNode.setId(appTreeLevelNode.getId());
234         layerTreeNode.setChildrenIds(appTreeLevelNode.getChildrenIds());
235         layerTreeNode.setRoot(Boolean.TRUE.equals(appTreeLevelNode.getRoot()));
236         // The name for a level node does show in the frontend
237         layerTreeNode.setName(appTreeLevelNode.getTitle());
238         layerTreeNode.setDescription(appTreeLevelNode.getDescription());
239       }
240       layerTreeNodeList.add(layerTreeNode);
241     }
242 
243     private boolean addAppLayerItem(AppTreeLayerNode layerRef) {
244       Triple<GeoService, GeoServiceLayer, GeoServiceLayerSettings> serviceWithLayer = findServiceLayer(layerRef);
245       GeoService service = serviceWithLayer.getLeft();
246       GeoServiceLayer serviceLayer = serviceWithLayer.getMiddle();
247 
248       if (service == null || serviceLayer == null) {
249         return false;
250       }
251 
252       // Some settings can be set on the app layer level, layer level or service level (default
253       // layer settings). These settings should be used in-order: from app layer if set, otherwise
254       // from layer level, from default layer setting or the default.
255 
256       // An empty (blank) string means it is not set. To explicitly clear a layer level string
257       // setting for an app, an admin should set the app layer setting to spaces.
258 
259       // The JSON wrapper classes have "null" values as defaults which means not-set. The defaults
260       // (such as tilingDisabled being true) are applied below although the frontend would also
261       // treat null as non-truthy.
262 
263       // When default layer settings or settings for a specific layer are missing, construct new
264       // settings objects so no null check is needed. All properties are initialized to null
265       // (not-set) by default.
266       GeoServiceDefaultLayerSettings defaultLayerSettings = Optional.ofNullable(
267               service.getSettings().getDefaultLayerSettings())
268           .orElseGet(GeoServiceDefaultLayerSettings::new);
269       GeoServiceLayerSettings serviceLayerSettings =
270           Optional.ofNullable(serviceWithLayer.getRight()).orElseGet(GeoServiceLayerSettings::new);
271 
272       AppLayerSettings appLayerSettings = app.getAppLayerSettings(layerRef);
273 
274       String title = Objects.requireNonNullElse(
275           nullIfEmpty(appLayerSettings.getTitle()),
276           // Get title from layer settings, title from capabilities or the layer name -- never
277           // null
278           service.getTitleWithSettingsOverrides(layerRef.getLayerName()));
279 
280       // These settings can be overridden per appLayer
281 
282       String description = ObjectUtils.firstNonNull(
283           nullIfEmpty(appLayerSettings.getDescription()),
284           nullIfEmpty(serviceLayerSettings.getDescription()),
285           nullIfEmpty(defaultLayerSettings.getDescription()));
286 
287       String attribution = ObjectUtils.firstNonNull(
288           nullIfEmpty(appLayerSettings.getAttribution()),
289           nullIfEmpty(serviceLayerSettings.getAttribution()),
290           nullIfEmpty(defaultLayerSettings.getAttribution()));
291 
292       // These settings can't be overridden per appLayer but can be set on a per-layer and
293       // service-level default basis
294 
295       boolean tilingDisabled = ObjectUtils.firstNonNull(
296           serviceLayerSettings.getTilingDisabled(), defaultLayerSettings.getTilingDisabled(), true);
297       Integer tilingGutter = ObjectUtils.firstNonNull(
298           serviceLayerSettings.getTilingGutter(), defaultLayerSettings.getTilingGutter(), 0);
299       boolean hiDpiDisabled = ObjectUtils.firstNonNull(
300           serviceLayerSettings.getHiDpiDisabled(), defaultLayerSettings.getHiDpiDisabled(), true);
301       TileLayerHiDpiMode hiDpiMode = ObjectUtils.firstNonNull(
302           serviceLayerSettings.getHiDpiMode(), defaultLayerSettings.getHiDpiMode(), null);
303       // Do not get from defaultLayerSettings because a default wouldn't make sense
304       String hiDpiSubstituteLayer = serviceLayerSettings.getHiDpiSubstituteLayer();
305 
306       TMFeatureType tmft = service.findFeatureTypeForLayer(serviceLayer, featureSourceRepository);
307 
308       boolean proxied = service.getSettings().getUseProxy();
309 
310       String legendImageUrl = serviceLayerSettings.getLegendImageId();
311       if (legendImageUrl == null && serviceLayer.getStyles() != null) {
312         // no user defined legend image, try to get legend image from styles
313         URI serviceLegendUrl = GeoServiceHelper.getLayerLegendUrlFromStyles(service, serviceLayer);
314         legendImageUrl = serviceLegendUrl != null ? serviceLegendUrl.toString() : null;
315         if (null != legendImageUrl && proxied) {
316           // service styles provides a legend image, but we need to proxy it
317           legendImageUrl = getLegendProxyUrl(app, layerRef);
318         }
319       }
320 
321       SearchIndex searchIndex = null;
322       if (appLayerSettings.getSearchIndexId() != null) {
323         searchIndex = searchIndexRepository
324             .findById(appLayerSettings.getSearchIndexId())
325             .orElse(null);
326       }
327 
328       mr.addAppLayersItem(new AppLayer()
329           .id(layerRef.getId())
330           .serviceId(serviceLayerServiceIds.get(serviceLayer))
331           .layerName(layerRef.getLayerName())
332           .hasAttributes(tmft != null)
333           .editable(TMFeatureTypeHelper.isEditable(app, layerRef, tmft))
334           .url(proxied ? getProxyUrl(service, app, layerRef) : null)
335           // Can't set whether layer is opaque, not mapped from WMS capabilities by GeoTools
336           // gt-wms Layer class?
337           .maxScale(serviceLayer.getMaxScale())
338           .minScale(serviceLayer.getMinScale())
339           .title(title)
340           .tilingDisabled(tilingDisabled)
341           .tilingGutter(tilingGutter)
342           .hiDpiDisabled(hiDpiDisabled)
343           .hiDpiMode(hiDpiMode)
344           .hiDpiSubstituteLayer(hiDpiSubstituteLayer)
345           .minZoom(serviceLayerSettings.getMinZoom())
346           .maxZoom(serviceLayerSettings.getMaxZoom())
347           .tileSize(serviceLayerSettings.getTileSize())
348           .tileGridExtent(serviceLayerSettings.getTileGridExtent())
349           .opacity(appLayerSettings.getOpacity())
350           .autoRefreshInSeconds(appLayerSettings.getAutoRefreshInSeconds())
351           .searchIndex(
352               searchIndex != null
353                   ? new LayerSearchIndex()
354                       .id(searchIndex.getId())
355                       .name(searchIndex.getName())
356                   : null)
357           .legendImageUrl(legendImageUrl)
358           .visible(layerRef.getVisible())
359           .attribution(attribution)
360           .description(description));
361       return true;
362     }
363 
364     private Triple<GeoService, GeoServiceLayer, GeoServiceLayerSettings> findServiceLayer(
365         AppTreeLayerNode layerRef) {
366       GeoService service =
367           geoServiceRepository.findById(layerRef.getServiceId()).orElse(null);
368       if (service == null) {
369         logger.warn(
370             "App {} references layer \"{}\" of missing service {}",
371             app.getId(),
372             layerRef.getLayerName(),
373             layerRef.getServiceId());
374         return Triple.of(null, null, null);
375       }
376 
377       if (!authorizationService.mayUserRead(service)) {
378         return Triple.of(null, null, null);
379       }
380 
381       if (authorizationService.mustDenyAccessForSecuredProxy(app, service)) {
382         return Triple.of(null, null, null);
383       }
384 
385       GeoServiceLayer serviceLayer = service.findLayer(layerRef.getLayerName());
386 
387       if (serviceLayer == null) {
388         logger.warn(
389             "App {} references layer \"{}\" not found in capabilities of service {}",
390             app.getId(),
391             layerRef.getLayerName(),
392             service.getId());
393         return Triple.of(null, null, null);
394       }
395 
396       if (!authorizationService.mayUserRead(service, serviceLayer)) {
397         return Triple.of(null, null, null);
398       }
399 
400       serviceLayerServiceIds.put(serviceLayer, service.getId());
401 
402       if (mr.getServices().stream()
403           .filter(s -> s.getId().equals(service.getId()))
404           .findAny()
405           .isEmpty()) {
406         mr.addServicesItem(service.toJsonPojo(geoServiceHelper));
407       }
408 
409       GeoServiceLayerSettings layerSettings = service.getLayerSettings(layerRef.getLayerName());
410       return Triple.of(service, serviceLayer, layerSettings);
411     }
412   }
413 }