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