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   /** Resolve the server type if set to AUTO. */
306   public org.tailormap.api.viewer.model.Service.ServerTypeEnum getResolvedServerType() {
307     if (settings.getServerType() == GeoServiceSettings.ServerTypeEnum.AUTO) {
308       return GeoServiceHelper.guessServerTypeFromUrl(getUrl());
309     } else {
310       return Service.ServerTypeEnum.fromValue(settings.getServerType().getValue());
311     }
312   }
313 
314   public Service toJsonPojo(GeoServiceHelper geoServiceHelper) {
315     Service s = new Service()
316         .id(this.id)
317         .title(this.title)
318         .url(Boolean.TRUE.equals(this.getSettings().getUseProxy()) ? null : this.url)
319         .protocol(this.protocol)
320         .serverType(getResolvedServerType());
321 
322     if (this.protocol == GeoServiceProtocol.WMTS) {
323       // Frontend requires WMTS capabilities to parse TilingMatrix, but WMS capabilities aren't used
324       // XXX UTF-8 only, maybe use base64
325       s.capabilities(new String(getCapabilities(), StandardCharsets.UTF_8));
326     }
327 
328     return s;
329   }
330 
331   public GeoServiceLayer findLayer(String name) {
332     return getLayers().stream()
333         .filter(sl -> name.equals(sl.getName()))
334         .findFirst()
335         .orElse(null);
336   }
337 
338   public GeoServiceLayerSettings getLayerSettings(String layerName) {
339     return getSettings().getLayerSettings().get(layerName);
340   }
341 
342   @NonNull public String getTitleWithSettingsOverrides(String layerName) {
343     // First use title in layer settings
344     String title = Optional.ofNullable(getLayerSettings(layerName))
345         .map(GeoServiceLayerSettings::getTitle)
346         .map(TMStringUtils::nullIfEmpty)
347         .orElse(null);
348 
349     // If not set, title from capabilities
350     if (title == null) {
351       title = Optional.ofNullable(findLayer(layerName))
352           .map(GeoServiceLayer::getTitle)
353           .map(TMStringUtils::nullIfEmpty)
354           .orElse(null);
355     }
356 
357     // Do not get title from default layer settings (a default title wouldn't make sense)
358 
359     // If still not set, use layer name as title
360     if (title == null) {
361       title = layerName;
362     }
363 
364     return title;
365   }
366 
367   public TMFeatureType findFeatureTypeForLayer(
368       GeoServiceLayer layer, FeatureSourceRepository featureSourceRepository) {
369 
370     GeoServiceDefaultLayerSettings defaultLayerSettings = getSettings().getDefaultLayerSettings();
371     GeoServiceLayerSettings layerSettings = getLayerSettings(layer.getName());
372 
373     Long featureSourceId = null;
374     String featureTypeName;
375 
376     if (layerSettings != null && layerSettings.getFeatureType() != null) {
377       featureTypeName = Optional.ofNullable(layerSettings.getFeatureType().getFeatureTypeName())
378           .orElse(layer.getName());
379       featureSourceId = layerSettings.getFeatureType().getFeatureSourceId();
380     } else {
381       featureTypeName = layer.getName();
382     }
383 
384     if (featureSourceId == null && defaultLayerSettings != null && defaultLayerSettings.getFeatureType() != null) {
385       featureSourceId = defaultLayerSettings.getFeatureType().getFeatureSourceId();
386     }
387 
388     if (featureTypeName == null) {
389       return null;
390     }
391 
392     TMFeatureSource tmfs = null;
393     TMFeatureType tmft = null;
394 
395     if (featureSourceId == null) {
396       List<TMFeatureSource> linkedSources = featureSourceRepository.findByLinkedServiceId(getId());
397       for (TMFeatureSource linkedFs : linkedSources) {
398         tmft = linkedFs.findFeatureTypeByName(featureTypeName);
399         if (tmft != null) {
400           tmfs = linkedFs;
401           break;
402         }
403       }
404     } else {
405       tmfs = featureSourceRepository.findById(featureSourceId).orElse(null);
406       if (tmfs != null) {
407         tmft = tmfs.findFeatureTypeByName(featureTypeName);
408       }
409     }
410 
411     if (tmfs == null) {
412       return null;
413     }
414 
415     if (tmft == null) {
416       String[] split = featureTypeName.split(":", 2);
417       if (split.length == 2) {
418         String shortFeatureTypeName = split[1];
419         tmft = tmfs.findFeatureTypeByName(shortFeatureTypeName);
420         if (tmft != null) {
421           logger.debug(
422               "Did not find feature type with full name \"{}\", using \"{}\" of feature source {}",
423               featureTypeName,
424               shortFeatureTypeName,
425               tmfs);
426         }
427       }
428     }
429     return tmft;
430   }
431 
432   /**
433    * Remove all parameters from the URL that are listed in {@link #REMOVE_PARAMS}.
434    *
435    * @param url URL to sanitise
436    * @return sanitised URL
437    */
438   private String sanitiseUrl(String url) {
439     if (url != null && url.contains("?")) {
440       MultiValueMap<String, String> sanitisedParams = new LinkedMultiValueMap<>();
441       UriComponentsBuilder uri = UriComponentsBuilder.fromUriString(url);
442       MultiValueMap<String, String> /* unmodifiable */ requestParams =
443           uri.build().getQueryParams();
444       for (Map.Entry<String, List<String>> param : requestParams.entrySet()) {
445         if (!REMOVE_PARAMS.contains(param.getKey().toUpperCase(Locale.ROOT))) {
446           sanitisedParams.put(param.getKey(), param.getValue());
447         }
448       }
449 
450       url = uri.replaceQueryParams(sanitisedParams).build().toUriString();
451       if (url.endsWith("?")) {
452         url = url.substring(0, url.length() - 1);
453       }
454     }
455     return url;
456   }
457 
458   public GeoServiceLayer getParentLayer(String layerId) {
459     for (GeoServiceLayer layer : this.getLayers()) {
460       if (layer.getChildren().contains(layerId)) {
461         return layer;
462       }
463     }
464     return null;
465   }
466 }