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