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