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