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