View Javadoc
1   /*
2    * Copyright (C) 2023 B3Partners B.V.
3    *
4    * SPDX-License-Identifier: MIT
5    */
6   package org.tailormap.api.persistence;
7   
8   import com.fasterxml.jackson.annotation.JsonIgnore;
9   import io.hypersistence.tsid.TSID;
10  import jakarta.persistence.Basic;
11  import jakarta.persistence.Column;
12  import jakarta.persistence.Entity;
13  import jakarta.persistence.EntityListeners;
14  import jakarta.persistence.EnumType;
15  import jakarta.persistence.Enumerated;
16  import jakarta.persistence.FetchType;
17  import jakarta.persistence.Id;
18  import jakarta.persistence.PrePersist;
19  import jakarta.persistence.Transient;
20  import jakarta.persistence.Version;
21  import jakarta.validation.constraints.NotNull;
22  import java.lang.invoke.MethodHandles;
23  import java.nio.charset.StandardCharsets;
24  import java.time.Instant;
25  import java.util.ArrayList;
26  import java.util.List;
27  import java.util.Locale;
28  import java.util.Map;
29  import java.util.Optional;
30  import org.apache.commons.lang3.StringUtils;
31  import org.hibernate.annotations.Type;
32  import org.hibernate.envers.Audited;
33  import org.hibernate.envers.NotAudited;
34  import org.slf4j.Logger;
35  import org.slf4j.LoggerFactory;
36  import org.springframework.lang.NonNull;
37  import org.springframework.util.LinkedMultiValueMap;
38  import org.springframework.util.MultiValueMap;
39  import org.springframework.web.util.UriComponentsBuilder;
40  import org.tailormap.api.persistence.helper.GeoServiceHelper;
41  import org.tailormap.api.persistence.json.AuthorizationRule;
42  import org.tailormap.api.persistence.json.GeoServiceDefaultLayerSettings;
43  import org.tailormap.api.persistence.json.GeoServiceLayer;
44  import org.tailormap.api.persistence.json.GeoServiceLayerSettings;
45  import org.tailormap.api.persistence.json.GeoServiceProtocol;
46  import org.tailormap.api.persistence.json.GeoServiceSettings;
47  import org.tailormap.api.persistence.json.ServiceAuthentication;
48  import org.tailormap.api.persistence.json.TMServiceCaps;
49  import org.tailormap.api.persistence.listener.EntityEventPublisher;
50  import org.tailormap.api.repository.FeatureSourceRepository;
51  import org.tailormap.api.util.TMStringUtils;
52  import org.tailormap.api.viewer.model.Service;
53  
54  @Audited
55  @Entity
56  @EntityListeners(EntityEventPublisher.class)
57  public class GeoService {
58    private static final Logger logger =
59        LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
60  
61    private static final List<String> REMOVE_PARAMS = List.of("REQUEST");
62  
63    @Id
64    private String id;
65  
66    @Version
67    private Long version;
68  
69    @Column(columnDefinition = "text")
70    private String notes;
71  
72    @NotNull @Enumerated(EnumType.STRING)
73    private GeoServiceProtocol protocol;
74  
75    /**
76     * The URL from which the capabilities of this service can be loaded and the URL to use for the service, except when
77     * the advertisedUrl should be used by explicit user request. TODO: never use URL in capabilities? TODO: explicitly
78     * specify relative URLs can be used? Or even if it should be automatically converted to a relative URL if the
79     * hostname/port matches our URL? TODO: what to do with parameters such as VERSION in the URL?
80     */
81    @NotNull @Column(length = 2048)
82    private String url;
83  
84    @Transient
85    private boolean refreshCapabilities;
86  
87    /**
88     * Non-null when authentication is required for this service. Currently, the only authentication method is password
89     * (HTTP Basic).
90     */
91    @Type(value = io.hypersistence.utils.hibernate.type.json.JsonBinaryType.class)
92    @Column(columnDefinition = "jsonb")
93    private ServiceAuthentication authentication;
94  
95    /**
96     * Original capabilities as received from the service. This can be used for capability information not already
97     * parsed in this entity, such as tiling information.
98     */
99    @NotAudited
100   @Basic(fetch = FetchType.LAZY)
101   @JsonIgnore
102   private byte[] capabilities;
103 
104   /** Content type of capabilities. "application/xml" for WMS/WMTS and "application/json" for REST services. */
105   private String capabilitiesContentType;
106 
107   /** The instant the capabilities where last successfully fetched and parsed. */
108   private Instant capabilitiesFetched;
109 
110   /** Title loaded from capabilities or as modified by user for display. */
111   @NotNull @Column(length = 2048)
112   private String title;
113 
114   /**
115    * A service may advertise a URL in its capabilities which does not actually work, for example if the service is
116    * behind a proxy. Usually this shouldn't be used.
117    */
118   @Column(length = 2048)
119   private String advertisedUrl;
120 
121   @Type(value = io.hypersistence.utils.hibernate.type.json.JsonBinaryType.class)
122   @Column(columnDefinition = "jsonb")
123   private TMServiceCaps serviceCapabilities;
124 
125   @Type(value = io.hypersistence.utils.hibernate.type.json.JsonBinaryType.class)
126   @Column(columnDefinition = "jsonb")
127   @NotNull private List<AuthorizationRule> authorizationRules = new ArrayList<>();
128 
129   @Type(value = io.hypersistence.utils.hibernate.type.json.JsonBinaryType.class)
130   @Column(columnDefinition = "jsonb")
131   @NotNull private List<GeoServiceLayer> layers = new ArrayList<>();
132 
133   private boolean published;
134 
135   /**
136    * Settings relevant for Tailormap use cases, such as configuring the specific server type for vendor-specific
137    * capabilities etc.
138    */
139   @Type(value = io.hypersistence.utils.hibernate.type.json.JsonBinaryType.class)
140   @Column(columnDefinition = "jsonb")
141   @NotNull private GeoServiceSettings settings = new GeoServiceSettings();
142 
143   // <editor-fold desc="getters and setters">
144   public String getId() {
145     return id;
146   }
147 
148   public GeoService setId(String id) {
149     this.id = id;
150     return this;
151   }
152 
153   public Long getVersion() {
154     return version;
155   }
156 
157   public GeoService setVersion(Long version) {
158     this.version = version;
159     return this;
160   }
161 
162   public String getNotes() {
163     return notes;
164   }
165 
166   public GeoService setNotes(String adminComments) {
167     this.notes = adminComments;
168     return this;
169   }
170 
171   public GeoServiceProtocol getProtocol() {
172     return protocol;
173   }
174 
175   public GeoService setProtocol(GeoServiceProtocol protocol) {
176     this.protocol = protocol;
177     return this;
178   }
179 
180   public String getUrl() {
181     return url;
182   }
183 
184   /** Sets the url after sanitising (removing unwanted parameters). */
185   public GeoService setUrl(String url) {
186     this.url = sanitiseUrl(url);
187     return this;
188   }
189 
190   public boolean isRefreshCapabilities() {
191     return refreshCapabilities;
192   }
193 
194   public void setRefreshCapabilities(boolean refreshCapabilities) {
195     this.refreshCapabilities = refreshCapabilities;
196   }
197 
198   public ServiceAuthentication getAuthentication() {
199     return authentication;
200   }
201 
202   public GeoService setAuthentication(ServiceAuthentication authentication) {
203     this.authentication = authentication;
204     return this;
205   }
206 
207   public byte[] getCapabilities() {
208     return capabilities;
209   }
210 
211   public GeoService setCapabilities(byte[] capabilities) {
212     this.capabilities = capabilities;
213     return this;
214   }
215 
216   public String getCapabilitiesContentType() {
217     return capabilitiesContentType;
218   }
219 
220   public GeoService setCapabilitiesContentType(String capabilitiesContentType) {
221     this.capabilitiesContentType = capabilitiesContentType;
222     return this;
223   }
224 
225   public Instant getCapabilitiesFetched() {
226     return capabilitiesFetched;
227   }
228 
229   public GeoService setCapabilitiesFetched(Instant capabilitiesFetched) {
230     this.capabilitiesFetched = capabilitiesFetched;
231     return this;
232   }
233 
234   public String getTitle() {
235     return title;
236   }
237 
238   public GeoService setTitle(String title) {
239     this.title = title;
240     return this;
241   }
242 
243   public String getAdvertisedUrl() {
244     return advertisedUrl;
245   }
246 
247   public GeoService setAdvertisedUrl(String advertisedUrl) {
248     this.advertisedUrl = advertisedUrl;
249     return this;
250   }
251 
252   public TMServiceCaps getServiceCapabilities() {
253     return serviceCapabilities;
254   }
255 
256   public GeoService setServiceCapabilities(TMServiceCaps serviceCapabilities) {
257     this.serviceCapabilities = serviceCapabilities;
258     return this;
259   }
260 
261   public List<AuthorizationRule> getAuthorizationRules() {
262     return authorizationRules;
263   }
264 
265   public GeoService setAuthorizationRules(List<AuthorizationRule> authorizationRules) {
266     this.authorizationRules = authorizationRules;
267     return this;
268   }
269 
270   public List<GeoServiceLayer> getLayers() {
271     return layers;
272   }
273 
274   public GeoService setLayers(List<GeoServiceLayer> layers) {
275     this.layers = layers;
276     return this;
277   }
278 
279   public boolean isPublished() {
280     return published;
281   }
282 
283   public GeoService setPublished(boolean published) {
284     this.published = published;
285     return this;
286   }
287 
288   public GeoServiceSettings getSettings() {
289     return settings;
290   }
291 
292   public GeoService setSettings(GeoServiceSettings settings) {
293     this.settings = settings;
294     return this;
295   }
296 
297   // </editor-fold>
298 
299   @PrePersist
300   public void assignId() {
301     if (StringUtils.isBlank(getId())) {
302       // We kind of misuse TSIDs here, because we store it as a string. This is because the id
303       // string can also be manually assigned. There won't be huge numbers of GeoServices, so it's
304       // more of a convenient way to generate an ID that isn't a huge UUID string.
305       setId(TSID.fast().toString());
306     }
307   }
308 
309   /** Resolve the server type if set to AUTO. */
310   public org.tailormap.api.viewer.model.Service.ServerTypeEnum getResolvedServerType() {
311     if (settings.getServerType() == GeoServiceSettings.ServerTypeEnum.AUTO) {
312       return GeoServiceHelper.guessServerTypeFromUrl(getUrl());
313     } else {
314       return Service.ServerTypeEnum.fromValue(settings.getServerType().getValue());
315     }
316   }
317 
318   public Service toJsonPojo(GeoServiceHelper geoServiceHelper) {
319     Service s = new Service()
320         .id(this.id)
321         .title(this.title)
322         .url(Boolean.TRUE.equals(this.getSettings().getUseProxy()) ? null : this.url)
323         .protocol(this.protocol)
324         .serverType(getResolvedServerType());
325 
326     if (this.protocol == GeoServiceProtocol.WMTS) {
327       // Frontend requires WMTS capabilities to parse TilingMatrix, but WMS capabilities aren't used
328       // XXX UTF-8 only, maybe use base64
329       s.capabilities(new String(getCapabilities(), StandardCharsets.UTF_8));
330     }
331 
332     return s;
333   }
334 
335   public GeoServiceLayer findLayer(String name) {
336     return getLayers().stream()
337         .filter(sl -> name.equals(sl.getName()))
338         .findFirst()
339         .orElse(null);
340   }
341 
342   public GeoServiceLayerSettings getLayerSettings(String layerName) {
343     return getSettings().getLayerSettings().get(layerName);
344   }
345 
346   @NonNull public String getTitleWithSettingsOverrides(String layerName) {
347     // First use title in layer settings
348     String title = Optional.ofNullable(getLayerSettings(layerName))
349         .map(GeoServiceLayerSettings::getTitle)
350         .map(TMStringUtils::nullIfEmpty)
351         .orElse(null);
352 
353     // If not set, title from capabilities
354     if (title == null) {
355       title = Optional.ofNullable(findLayer(layerName))
356           .map(GeoServiceLayer::getTitle)
357           .map(TMStringUtils::nullIfEmpty)
358           .orElse(null);
359     }
360 
361     // Do not get title from default layer settings (a default title wouldn't make sense)
362 
363     // If still not set, use layer name as title
364     if (title == null) {
365       title = layerName;
366     }
367 
368     return title;
369   }
370 
371   public TMFeatureType findFeatureTypeForLayer(
372       GeoServiceLayer layer, FeatureSourceRepository featureSourceRepository) {
373 
374     GeoServiceDefaultLayerSettings defaultLayerSettings = getSettings().getDefaultLayerSettings();
375     GeoServiceLayerSettings layerSettings = getLayerSettings(layer.getName());
376 
377     Long featureSourceId = null;
378     String featureTypeName;
379 
380     if (layerSettings != null && layerSettings.getFeatureType() != null) {
381       featureTypeName = Optional.ofNullable(layerSettings.getFeatureType().getFeatureTypeName())
382           .orElse(layer.getName());
383       featureSourceId = layerSettings.getFeatureType().getFeatureSourceId();
384     } else {
385       featureTypeName = layer.getName();
386     }
387 
388     if (featureSourceId == null && defaultLayerSettings != null && defaultLayerSettings.getFeatureType() != null) {
389       featureSourceId = defaultLayerSettings.getFeatureType().getFeatureSourceId();
390     }
391 
392     if (featureTypeName == null) {
393       return null;
394     }
395 
396     TMFeatureSource tmfs = null;
397     TMFeatureType tmft = null;
398 
399     if (featureSourceId == null) {
400       List<TMFeatureSource> linkedSources = featureSourceRepository.findByLinkedServiceId(getId());
401       for (TMFeatureSource linkedFs : linkedSources) {
402         tmft = linkedFs.findFeatureTypeByName(featureTypeName);
403         if (tmft != null) {
404           tmfs = linkedFs;
405           break;
406         }
407       }
408     } else {
409       tmfs = featureSourceRepository.findById(featureSourceId).orElse(null);
410       if (tmfs != null) {
411         tmft = tmfs.findFeatureTypeByName(featureTypeName);
412       }
413     }
414 
415     if (tmfs == null) {
416       return null;
417     }
418 
419     if (tmft == null) {
420       String[] split = featureTypeName.split(":", 2);
421       if (split.length == 2) {
422         String shortFeatureTypeName = split[1];
423         tmft = tmfs.findFeatureTypeByName(shortFeatureTypeName);
424         if (tmft != null) {
425           logger.debug(
426               "Did not find feature type with full name \"{}\", using \"{}\" of feature source {}",
427               featureTypeName,
428               shortFeatureTypeName,
429               tmfs);
430         }
431       }
432     }
433     return tmft;
434   }
435 
436   /**
437    * Remove all parameters from the URL that are listed in {@link #REMOVE_PARAMS}.
438    *
439    * @param url URL to sanitise
440    * @return sanitised URL
441    */
442   private String sanitiseUrl(String url) {
443     if (url != null && url.contains("?")) {
444       MultiValueMap<String, String> sanitisedParams = new LinkedMultiValueMap<>();
445       UriComponentsBuilder uri = UriComponentsBuilder.fromUriString(url);
446       MultiValueMap<String, String> /* unmodifiable */ requestParams =
447           uri.build().getQueryParams();
448       for (Map.Entry<String, List<String>> param : requestParams.entrySet()) {
449         if (!REMOVE_PARAMS.contains(param.getKey().toUpperCase(Locale.ROOT))) {
450           sanitisedParams.put(param.getKey(), param.getValue());
451         }
452       }
453 
454       url = uri.replaceQueryParams(sanitisedParams).build().toUriString();
455       if (url.endsWith("?")) {
456         url = url.substring(0, url.length() - 1);
457       }
458     }
459     return url;
460   }
461 
462   public GeoServiceLayer getParentLayer(String layerId) {
463     for (GeoServiceLayer layer : this.getLayers()) {
464       if (layer.getChildren().contains(layerId)) {
465         return layer;
466       }
467     }
468     return null;
469   }
470 }