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