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.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
119
120 entityManager.detach(baseApp);
121 }
122 }
123
124 Application app = baseApp != null ? baseApp : new Application().setContentRoot(new AppContent());
125
126 if (projection != null) {
127
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",
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
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
237
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
282
283
284
285
286
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
300 !validLayerIds.contains(childId) && !levelNodes.contains(childId));
301 })
302 .filter(n ->
303
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
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
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
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
366
367
368
369
370
371
372
373
374
375
376
377
378
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
390
391 service.getTitleWithSettingsOverrides(layerRef.getLayerName()));
392
393
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
409
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
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
434 legendImageUrl = Optional.ofNullable(
435 GeoServiceHelper.getLayerLegendUrlFromStyles(service, serviceLayer))
436 .map(URI::toString)
437 .orElse(null);
438
439 if (legendImageUrl != null) {
440
441
442 legendType = "GetLegendGraphic".equalsIgnoreCase(getWmsRequest(legendImageUrl))
443 ? AppLayer.LegendTypeEnum.DYNAMIC
444 : AppLayer.LegendTypeEnum.STATIC;
445
446 if (proxied) {
447
448 legendImageUrl = getLegendProxyUrl(app, layerRef);
449 }
450 }
451 }
452
453 List<WMSStyle> legendStyles = appLayerSettings.getSelectedStyles();
454 if (proxied && legendStyles != null) {
455
456
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
482
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 }