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