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