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