ApplicationHelper.java
/*
* Copyright (C) 2023 B3Partners B.V.
*
* SPDX-License-Identifier: MIT
*/
package org.tailormap.api.persistence.helper;
import static java.util.stream.Collectors.toSet;
import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.linkTo;
import static org.tailormap.api.persistence.helper.GeoServiceHelper.getWmsRequest;
import static org.tailormap.api.persistence.json.GeoServiceProtocol.LEGEND;
import static org.tailormap.api.persistence.json.GeoServiceProtocol.QUANTIZEDMESH;
import static org.tailormap.api.persistence.json.GeoServiceProtocol.TILES3D;
import static org.tailormap.api.persistence.json.GeoServiceProtocol.XYZ;
import static org.tailormap.api.util.TMStringUtils.nullIfEmpty;
import jakarta.persistence.EntityManager;
import java.lang.invoke.MethodHandles;
import java.net.URI;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import org.apache.commons.lang3.ObjectUtils;
import org.geotools.api.referencing.crs.CoordinateReferenceSystem;
import org.geotools.referencing.util.CRSUtilities;
import org.geotools.referencing.wkt.Formattable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.tailormap.api.controller.GeoServiceProxyController;
import org.tailormap.api.persistence.Application;
import org.tailormap.api.persistence.Configuration;
import org.tailormap.api.persistence.GeoService;
import org.tailormap.api.persistence.SearchIndex;
import org.tailormap.api.persistence.TMFeatureType;
import org.tailormap.api.persistence.json.AppContent;
import org.tailormap.api.persistence.json.AppLayerSettings;
import org.tailormap.api.persistence.json.AppTreeLayerNode;
import org.tailormap.api.persistence.json.AppTreeLevelNode;
import org.tailormap.api.persistence.json.AppTreeNode;
import org.tailormap.api.persistence.json.Bounds;
import org.tailormap.api.persistence.json.GeoServiceDefaultLayerSettings;
import org.tailormap.api.persistence.json.GeoServiceLayer;
import org.tailormap.api.persistence.json.GeoServiceLayerSettings;
import org.tailormap.api.persistence.json.ServicePublishingSettings;
import org.tailormap.api.persistence.json.TileLayerHiDpiMode;
import org.tailormap.api.repository.ApplicationRepository;
import org.tailormap.api.repository.ConfigurationRepository;
import org.tailormap.api.repository.FeatureSourceRepository;
import org.tailormap.api.repository.GeoServiceRepository;
import org.tailormap.api.repository.SearchIndexRepository;
import org.tailormap.api.security.AuthorisationService;
import org.tailormap.api.viewer.model.AppLayer;
import org.tailormap.api.viewer.model.LayerSearchIndex;
import org.tailormap.api.viewer.model.LayerTreeNode;
import org.tailormap.api.viewer.model.MapResponse;
import org.tailormap.api.viewer.model.TMCoordinateReferenceSystem;
@Service
public class ApplicationHelper {
private static final Logger logger =
LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
private static final String DEFAULT_WEB_MERCATOR_CRS = "EPSG:3857";
private final GeoServiceHelper geoServiceHelper;
private final GeoServiceRepository geoServiceRepository;
private final ConfigurationRepository configurationRepository;
private final ApplicationRepository applicationRepository;
private final FeatureSourceRepository featureSourceRepository;
private final EntityManager entityManager;
private final AuthorisationService authorisationService;
private final SearchIndexRepository searchIndexRepository;
public ApplicationHelper(
GeoServiceHelper geoServiceHelper,
GeoServiceRepository geoServiceRepository,
ConfigurationRepository configurationRepository,
ApplicationRepository applicationRepository,
FeatureSourceRepository featureSourceRepository,
EntityManager entityManager,
AuthorisationService authorisationService,
SearchIndexRepository searchIndexRepository) {
this.geoServiceHelper = geoServiceHelper;
this.geoServiceRepository = geoServiceRepository;
this.configurationRepository = configurationRepository;
this.applicationRepository = applicationRepository;
this.featureSourceRepository = featureSourceRepository;
this.entityManager = entityManager;
this.authorisationService = authorisationService;
this.searchIndexRepository = searchIndexRepository;
}
public Application getServiceApplication(String baseAppName, String projection, GeoService service) {
if (baseAppName == null) {
baseAppName = Optional.ofNullable(service.getSettings().getPublishing())
.map(ServicePublishingSettings::getBaseApp)
.orElseGet(() -> configurationRepository.get(Configuration.DEFAULT_BASE_APP));
}
Application baseApp = null;
if (baseAppName != null) {
baseApp = applicationRepository.findByName(baseAppName);
if (baseApp != null) {
// Caller may be changing the app content to add layers from this service, detach so those
// aren't saved
entityManager.detach(baseApp);
}
}
Application app = baseApp != null ? baseApp : new Application().setContentRoot(new AppContent());
if (projection != null) {
// TODO: filter layers by projection parameter (layer.crs must inherit crs from parent layers)
throw new UnsupportedOperationException("Projection filtering not yet supported");
} else {
if (baseApp != null) {
projection = baseApp.getCrs();
} else {
projection = DEFAULT_WEB_MERCATOR_CRS;
}
}
app.setName(service.getId()).setTitle(service.getTitle()).setCrs(projection);
return app;
}
@Transactional
public MapResponse toMapResponse(Application app) {
MapResponse mapResponse = new MapResponse();
setCrsAndBounds(app, mapResponse);
setLayers(app, mapResponse);
return mapResponse;
}
public void setCrsAndBounds(Application a, MapResponse mapResponse) {
CoordinateReferenceSystem gtCrs = a.getGeoToolsCoordinateReferenceSystem();
if (gtCrs == null) {
throw new IllegalArgumentException("Invalid CRS: " + a.getCrs());
}
TMCoordinateReferenceSystem crs = new TMCoordinateReferenceSystem()
.code(a.getCrs())
.definition(((Formattable) gtCrs).toWKT(0))
.bounds(GeoToolsHelper.fromCRS(gtCrs))
.unit(Optional.ofNullable(CRSUtilities.getUnit(gtCrs.getCoordinateSystem()))
.map(Objects::toString)
.orElse(null));
Bounds maxExtent = a.getMaxExtent() != null ? a.getMaxExtent() : crs.getBounds();
Bounds initialExtent = a.getInitialExtent() != null ? a.getInitialExtent() : maxExtent;
mapResponse.crs(crs).maxExtent(maxExtent).initialExtent(initialExtent);
}
private void setLayers(Application app, MapResponse mr) {
new MapResponseLayerBuilder(app, mr).buildLayers();
}
private String getProxyUrl(GeoService geoService, Application application, AppTreeLayerNode appTreeLayerNode) {
String baseProxyUrl = linkTo(
GeoServiceProxyController.class,
Map.of(
"viewerKind", "app", // XXX
"viewerName", application.getName(),
"appLayerId", appTreeLayerNode.getId()))
.toString();
String protocolPath = "/" + geoService.getProtocol().getValue();
if (geoService.getProtocol() == TILES3D) {
return baseProxyUrl + protocolPath + "/" + GeoServiceProxyController.TILES3D_DESCRIPTION_PATH;
}
return baseProxyUrl + protocolPath;
}
private String getLegendProxyUrl(Application application, AppTreeLayerNode appTreeLayerNode) {
return linkTo(
GeoServiceProxyController.class,
Map.of(
"viewerKind",
"app",
"viewerName",
application.getName(),
"appLayerId",
appTreeLayerNode.getId()))
+ "/" + LEGEND.getValue();
}
private class MapResponseLayerBuilder {
private final Application app;
private final MapResponse mapResponse;
// XXX not needed if we have GeoServiceLayer.getService().getName()
private final Map<GeoServiceLayer, String> serviceLayerServiceIds = new HashMap<>();
MapResponseLayerBuilder(Application app, MapResponse mapResponse) {
this.app = app;
this.mapResponse = mapResponse;
}
void buildLayers() {
if (app.getContentRoot() != null) {
buildBackgroundLayers();
buildOverlayLayers();
buildTerrainLayers();
}
}
private void buildBackgroundLayers() {
if (app.getContentRoot().getBaseLayerNodes() != null) {
for (AppTreeNode node : app.getContentRoot().getBaseLayerNodes()) {
addAppTreeNodeItem(node, mapResponse.getBaseLayerTreeNodes());
}
Set<String> validLayerIds =
mapResponse.getAppLayers().stream().map(AppLayer::getId).collect(toSet());
List<LayerTreeNode> initialLayerTreeNodes = mapResponse.getBaseLayerTreeNodes();
mapResponse.setBaseLayerTreeNodes(cleanLayerTreeNodes(validLayerIds, initialLayerTreeNodes));
}
}
private void buildOverlayLayers() {
if (app.getContentRoot().getLayerNodes() != null) {
for (AppTreeNode node : app.getContentRoot().getLayerNodes()) {
addAppTreeNodeItem(node, mapResponse.getLayerTreeNodes());
}
Set<String> validLayerIds =
mapResponse.getAppLayers().stream().map(AppLayer::getId).collect(toSet());
List<LayerTreeNode> initialLayerTreeNodes = mapResponse.getLayerTreeNodes();
mapResponse.setLayerTreeNodes(cleanLayerTreeNodes(validLayerIds, initialLayerTreeNodes));
}
}
/**
* Cleans the layer tree nodes by removing references to non-existing layers and removing level nodes without
* children, thus preventing exposure of application internals to unauthorized users.
*
* @param validLayerIds the ids of the layers that exist
* @param initialLayerTreeNodes the initial list of layer tree nodes
* @return the cleaned list of layer tree nodes
*/
private List<LayerTreeNode> cleanLayerTreeNodes(
Set<String> validLayerIds, List<LayerTreeNode> initialLayerTreeNodes) {
List<String> levelNodes = initialLayerTreeNodes.stream()
.filter(n -> n.getAppLayerId() == null)
.map(LayerTreeNode::getId)
.toList();
List<LayerTreeNode> newLayerTreeNodes = initialLayerTreeNodes.stream()
.peek(n -> {
n.getChildrenIds()
.removeIf(childId ->
/* remove invalid layers from the children */
!validLayerIds.contains(childId) && !levelNodes.contains(childId));
})
.filter(n ->
/* remove level nodes without children */
!(n.getAppLayerId() == null
&& (n.getChildrenIds() != null
&& n.getChildrenIds().isEmpty())))
.toList();
List<String> cleanLevelNodeIds = newLayerTreeNodes.stream()
.filter(n -> n.getAppLayerId() == null)
.map(LayerTreeNode::getId)
.toList();
return newLayerTreeNodes.stream()
.peek(n -> {
n.getChildrenIds()
.removeIf(childId ->
/* remove invalid layers from the children */
!cleanLevelNodeIds.contains(childId) && levelNodes.contains(childId));
})
.toList();
}
private void buildTerrainLayers() {
if (app.getContentRoot().getTerrainLayerNodes() != null) {
for (AppTreeNode node : app.getContentRoot().getTerrainLayerNodes()) {
addAppTreeNodeItem(node, mapResponse.getTerrainLayerTreeNodes());
}
}
}
private void addAppTreeNodeItem(AppTreeNode node, List<LayerTreeNode> layerTreeNodeList) {
LayerTreeNode layerTreeNode = new LayerTreeNode();
if ("AppTreeLayerNode".equals(node.getObjectType())) {
AppTreeLayerNode appTreeLayerNode = (AppTreeLayerNode) node;
layerTreeNode.setId(appTreeLayerNode.getId());
layerTreeNode.setAppLayerId(appTreeLayerNode.getId());
if (!addAppLayerItem(appTreeLayerNode)) {
return;
}
// This name is not displayed in the frontend, the title from the appLayer node is used
layerTreeNode.setName(appTreeLayerNode.getLayerName());
layerTreeNode.setDescription(appTreeLayerNode.getDescription());
} else if ("AppTreeLevelNode".equals(node.getObjectType())) {
AppTreeLevelNode appTreeLevelNode = (AppTreeLevelNode) node;
layerTreeNode.setId(appTreeLevelNode.getId());
layerTreeNode.setChildrenIds(appTreeLevelNode.getChildrenIds());
layerTreeNode.setRoot(Boolean.TRUE.equals(appTreeLevelNode.getRoot()));
// The name for a level node does show in the frontend
layerTreeNode.setName(appTreeLevelNode.getTitle());
layerTreeNode.setDescription(appTreeLevelNode.getDescription());
}
layerTreeNodeList.add(layerTreeNode);
}
private boolean addAppLayerItem(AppTreeLayerNode layerRef) {
ServiceLayerInfo layerInfo = findServiceLayer(layerRef);
if (layerInfo == null) {
return false;
}
GeoService service = layerInfo.service();
GeoServiceLayer serviceLayer = layerInfo.serviceLayer();
// Some settings can be set on the app layer level, layer level or service level (default
// layer settings). These settings should be used in-order: from app layer if set, otherwise
// from layer level, from default layer setting or the default.
// An empty (blank) string means it is not set. To explicitly clear a layer level string
// setting for an app, an admin should set the app layer setting to spaces.
// The JSON wrapper classes have "null" values as defaults, which means not-set. The defaults
// (such as tilingDisabled being true) are applied below, although the frontend would also
// treat null as non-truthy.
// When default layer settings or settings for a specific layer are missing, construct new
// settings objects so no null check is needed. All properties are initialized to null
// (not-set) by default.
GeoServiceDefaultLayerSettings defaultLayerSettings = Optional.ofNullable(
service.getSettings().getDefaultLayerSettings())
.orElseGet(GeoServiceDefaultLayerSettings::new);
GeoServiceLayerSettings serviceLayerSettings =
Optional.ofNullable(layerInfo.layerSettings()).orElseGet(GeoServiceLayerSettings::new);
AppLayerSettings appLayerSettings = app.getAppLayerSettings(layerRef);
String title = Objects.requireNonNullElse(
nullIfEmpty(appLayerSettings.getTitle()),
// Get title from layer settings, title from capabilities or the layer name -- never
// null
service.getTitleWithSettingsOverrides(layerRef.getLayerName()));
// These settings can be overridden per appLayer
String description = ObjectUtils.firstNonNull(
nullIfEmpty(appLayerSettings.getDescription()),
nullIfEmpty(serviceLayerSettings.getDescription()),
nullIfEmpty(defaultLayerSettings.getDescription()));
String attribution = ObjectUtils.firstNonNull(
nullIfEmpty(appLayerSettings.getAttribution()),
nullIfEmpty(serviceLayerSettings.getAttribution()),
nullIfEmpty(defaultLayerSettings.getAttribution()));
// These settings can't be overridden per appLayer but can be set on a per-layer and
// service-level default basis
boolean tilingDisabled = ObjectUtils.firstNonNull(
serviceLayerSettings.getTilingDisabled(), defaultLayerSettings.getTilingDisabled(), true);
Integer tilingGutter = ObjectUtils.firstNonNull(
serviceLayerSettings.getTilingGutter(), defaultLayerSettings.getTilingGutter(), 0);
boolean hiDpiDisabled = ObjectUtils.firstNonNull(
serviceLayerSettings.getHiDpiDisabled(), defaultLayerSettings.getHiDpiDisabled(), true);
TileLayerHiDpiMode hiDpiMode = ObjectUtils.firstNonNull(
serviceLayerSettings.getHiDpiMode(), defaultLayerSettings.getHiDpiMode(), null);
// Do not get from defaultLayerSettings because a default wouldn't make sense
String hiDpiSubstituteLayer = serviceLayerSettings.getHiDpiSubstituteLayer();
TMFeatureType tmft = service.findFeatureTypeForLayer(serviceLayer, featureSourceRepository);
boolean proxied = service.getSettings().getUseProxy();
String legendImageUrl = serviceLayerSettings.getLegendImageId();
AppLayer.LegendTypeEnum legendType = AppLayer.LegendTypeEnum.STATIC;
if (legendImageUrl == null && serviceLayer.getStyles() != null) {
// no user defined legend image, try to get legend image from styles
legendImageUrl = Optional.ofNullable(
GeoServiceHelper.getLayerLegendUrlFromStyles(service, serviceLayer))
.map(URI::toString)
.orElse(null);
if (legendImageUrl != null) {
// Check whether the legend is dynamic or static based on the original legend URL, before it is
// possibly replaced by URL to the proxy controller
legendType = "GetLegendGraphic".equalsIgnoreCase(getWmsRequest(legendImageUrl))
? AppLayer.LegendTypeEnum.DYNAMIC
: AppLayer.LegendTypeEnum.STATIC;
if (proxied) {
// service styles provides a legend image, but we need to proxy it
legendImageUrl = getLegendProxyUrl(app, layerRef);
}
}
}
SearchIndex searchIndex = null;
if (appLayerSettings.getSearchIndexId() != null) {
searchIndex = searchIndexRepository
.findById(appLayerSettings.getSearchIndexId())
.orElse(null);
}
boolean webMercatorAvailable = this.isWebMercatorAvailable(service, serviceLayer, hiDpiSubstituteLayer);
mapResponse.addAppLayersItem(new AppLayer()
.id(layerRef.getId())
.serviceId(serviceLayerServiceIds.get(serviceLayer))
.layerName(layerRef.getLayerName())
.hasAttributes(tmft != null)
.editable(TMFeatureTypeHelper.isEditable(app, layerRef, tmft))
.url(proxied ? getProxyUrl(service, app, layerRef) : null)
// Can't set whether layer is opaque, not mapped from WMS capabilities by GeoTools
// gt-wms Layer class?
.maxScale(serviceLayer.getMaxScale())
.minScale(serviceLayer.getMinScale())
.title(title)
.tilingDisabled(tilingDisabled)
.tilingGutter(tilingGutter)
.hiDpiDisabled(hiDpiDisabled)
.hiDpiMode(hiDpiMode)
.hiDpiSubstituteLayer(hiDpiSubstituteLayer)
.minZoom(serviceLayerSettings.getMinZoom())
.maxZoom(serviceLayerSettings.getMaxZoom())
.tileSize(serviceLayerSettings.getTileSize())
.tileGridExtent(serviceLayerSettings.getTileGridExtent())
.opacity(appLayerSettings.getOpacity())
.autoRefreshInSeconds(appLayerSettings.getAutoRefreshInSeconds())
.searchIndex(
searchIndex != null
? new LayerSearchIndex()
.id(searchIndex.getId())
.name(searchIndex.getName())
: null)
.legendImageUrl(legendImageUrl)
.legendType(legendType)
.visible(layerRef.getVisible())
.attribution(attribution)
.description(description)
.webMercatorAvailable(webMercatorAvailable)
.tileset3dStyle(appLayerSettings.getTileset3dStyle())
.hiddenFunctionality(appLayerSettings.getHiddenFunctionality()));
return true;
}
private ServiceLayerInfo findServiceLayer(AppTreeLayerNode layerRef) {
GeoService service =
geoServiceRepository.findById(layerRef.getServiceId()).orElse(null);
if (service == null) {
logger.warn(
"App {} references layer \"{}\" of missing service {}",
app.getId(),
layerRef.getLayerName(),
layerRef.getServiceId());
return null;
}
if (!authorisationService.userAllowedToViewGeoService(service)) {
return null;
}
GeoServiceLayer serviceLayer = service.findLayer(layerRef.getLayerName());
if (serviceLayer == null) {
logger.warn(
"App {} references layer \"{}\" not found in capabilities of service {}",
app.getId(),
layerRef.getLayerName(),
service.getId());
return null;
}
if (!authorisationService.userAllowedToViewGeoServiceLayer(service, serviceLayer)) {
logger.debug(
"User not allowed to view layer {} of service {}", serviceLayer.getName(), service.getId());
return null;
}
serviceLayerServiceIds.put(serviceLayer, service.getId());
if (mapResponse.getServices().stream()
.filter(s -> s.getId().equals(service.getId()))
.findAny()
.isEmpty()) {
mapResponse.addServicesItem(service.toJsonPojo(geoServiceHelper));
}
GeoServiceLayerSettings layerSettings = service.getLayerSettings(layerRef.getLayerName());
return new ServiceLayerInfo(service, serviceLayer, layerSettings);
}
private boolean isWebMercatorAvailable(
GeoService service, GeoServiceLayer serviceLayer, String hiDpiSubstituteLayer) {
if (service.getProtocol() == XYZ) {
return DEFAULT_WEB_MERCATOR_CRS.equals(service.getSettings().getXyzCrs());
}
if (service.getProtocol() == TILES3D || service.getProtocol() == QUANTIZEDMESH) {
return false;
}
if (hiDpiSubstituteLayer != null) {
GeoServiceLayer hiDpiSubstituteServiceLayer = service.findLayer(hiDpiSubstituteLayer);
if (hiDpiSubstituteServiceLayer != null
&& !this.isWebMercatorAvailable(service, hiDpiSubstituteServiceLayer, null)) {
return false;
}
}
while (serviceLayer != null) {
Set<String> layerCrs = serviceLayer.getCrs();
if (layerCrs.contains(DEFAULT_WEB_MERCATOR_CRS)) {
return true;
}
if (serviceLayer.getRoot()) {
break;
}
serviceLayer = service.getParentLayer(serviceLayer.getId());
}
return false;
}
record ServiceLayerInfo(
GeoService service, GeoServiceLayer serviceLayer, GeoServiceLayerSettings layerSettings) {}
}
}