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