GeoService.java
/*
* Copyright (C) 2023 B3Partners B.V.
*
* SPDX-License-Identifier: MIT
*/
package org.tailormap.api.persistence;
import com.fasterxml.jackson.annotation.JsonIgnore;
import io.hypersistence.tsid.TSID;
import jakarta.persistence.Basic;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EntityListeners;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.FetchType;
import jakarta.persistence.Id;
import jakarta.persistence.PrePersist;
import jakarta.persistence.Transient;
import jakarta.persistence.Version;
import jakarta.validation.constraints.NotNull;
import java.lang.invoke.MethodHandles;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import org.apache.commons.lang3.StringUtils;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.envers.Audited;
import org.hibernate.envers.NotAudited;
import org.hibernate.type.SqlTypes;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import org.springframework.lang.NonNull;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.util.UriComponentsBuilder;
import org.tailormap.api.persistence.helper.GeoServiceHelper;
import org.tailormap.api.persistence.json.AuthorizationRule;
import org.tailormap.api.persistence.json.GeoServiceDefaultLayerSettings;
import org.tailormap.api.persistence.json.GeoServiceLayer;
import org.tailormap.api.persistence.json.GeoServiceLayerSettings;
import org.tailormap.api.persistence.json.GeoServiceProtocol;
import org.tailormap.api.persistence.json.GeoServiceSettings;
import org.tailormap.api.persistence.json.ServiceAuthentication;
import org.tailormap.api.persistence.json.TMServiceCaps;
import org.tailormap.api.persistence.listener.EntityEventPublisher;
import org.tailormap.api.repository.FeatureSourceRepository;
import org.tailormap.api.util.TMStringUtils;
import org.tailormap.api.viewer.model.Service;
@Audited
@Entity
@EntityListeners({EntityEventPublisher.class, AuditingEntityListener.class})
public class GeoService extends AuditMetadata {
private static final Logger logger =
LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
private static final List<String> REMOVE_PARAMS = List.of("REQUEST");
@Id
private String id;
@Version
private Long version;
@Column(columnDefinition = "text")
private String notes;
@NotNull @Enumerated(EnumType.STRING)
private GeoServiceProtocol protocol;
/**
* The URL from which the capabilities of this service can be loaded and the URL to use for the service, except when
* the advertisedUrl should be used by explicit user request. TODO: never use URL in capabilities? TODO: explicitly
* specify relative URLs can be used? Or even if it should be automatically converted to a relative URL if the
* hostname/port matches our URL? TODO: what to do with parameters such as VERSION in the URL?
*/
@NotNull @Column(length = 2048)
private String url;
@Transient
private boolean refreshCapabilities;
/**
* Non-null when authentication is required for this service. Currently, the only authentication method is password
* (HTTP Basic).
*/
@JdbcTypeCode(SqlTypes.JSON)
@Column(columnDefinition = "jsonb")
private ServiceAuthentication authentication;
/**
* Original capabilities as received from the service. This can be used for capability information not already
* parsed in this entity, such as tiling information.
*/
@NotAudited
@Basic(fetch = FetchType.LAZY)
@JsonIgnore
private byte[] capabilities;
/** Content type of capabilities. "application/xml" for WMS/WMTS and "application/json" for REST services. */
private String capabilitiesContentType;
/** The instant the capabilities where last successfully fetched and parsed. */
private Instant capabilitiesFetched;
/** Title loaded from capabilities or as modified by user for display. */
@NotNull @Column(length = 2048)
private String title;
/**
* A service may advertise a URL in its capabilities which does not actually work, for example if the service is
* behind a proxy. Usually this shouldn't be used.
*/
@Column(length = 2048)
private String advertisedUrl;
@JdbcTypeCode(SqlTypes.JSON)
@Column(columnDefinition = "jsonb")
private TMServiceCaps serviceCapabilities;
@JdbcTypeCode(SqlTypes.JSON)
@Column(columnDefinition = "jsonb")
@NotNull private List<AuthorizationRule> authorizationRules = new ArrayList<>();
@JdbcTypeCode(SqlTypes.JSON)
@Column(columnDefinition = "jsonb")
@NotNull private List<GeoServiceLayer> layers = new ArrayList<>();
private boolean published;
/**
* Settings relevant for Tailormap use cases, such as configuring the specific server type for vendor-specific
* capabilities etc.
*/
@JdbcTypeCode(SqlTypes.JSON)
@Column(columnDefinition = "jsonb")
@NotNull private GeoServiceSettings settings = new GeoServiceSettings();
// <editor-fold desc="getters and setters">
public String getId() {
return id;
}
public GeoService setId(String id) {
this.id = id;
return this;
}
public Long getVersion() {
return version;
}
public GeoService setVersion(Long version) {
this.version = version;
return this;
}
public String getNotes() {
return notes;
}
public GeoService setNotes(String adminComments) {
this.notes = adminComments;
return this;
}
public GeoServiceProtocol getProtocol() {
return protocol;
}
public GeoService setProtocol(GeoServiceProtocol protocol) {
this.protocol = protocol;
return this;
}
public String getUrl() {
return url;
}
/** Sets the url after sanitising (removing unwanted parameters). */
public GeoService setUrl(String url) {
this.url = sanitiseUrl(url);
return this;
}
public boolean isRefreshCapabilities() {
return refreshCapabilities;
}
public void setRefreshCapabilities(boolean refreshCapabilities) {
this.refreshCapabilities = refreshCapabilities;
}
public ServiceAuthentication getAuthentication() {
return authentication;
}
public GeoService setAuthentication(ServiceAuthentication authentication) {
this.authentication = authentication;
return this;
}
public byte[] getCapabilities() {
return capabilities;
}
public GeoService setCapabilities(byte[] capabilities) {
this.capabilities = capabilities;
return this;
}
public String getCapabilitiesContentType() {
return capabilitiesContentType;
}
public GeoService setCapabilitiesContentType(String capabilitiesContentType) {
this.capabilitiesContentType = capabilitiesContentType;
return this;
}
public Instant getCapabilitiesFetched() {
return capabilitiesFetched;
}
public GeoService setCapabilitiesFetched(Instant capabilitiesFetched) {
this.capabilitiesFetched = capabilitiesFetched;
return this;
}
public String getTitle() {
return title;
}
public GeoService setTitle(String title) {
this.title = title;
return this;
}
public String getAdvertisedUrl() {
return advertisedUrl;
}
public GeoService setAdvertisedUrl(String advertisedUrl) {
this.advertisedUrl = advertisedUrl;
return this;
}
public TMServiceCaps getServiceCapabilities() {
return serviceCapabilities;
}
public GeoService setServiceCapabilities(TMServiceCaps serviceCapabilities) {
this.serviceCapabilities = serviceCapabilities;
return this;
}
public List<AuthorizationRule> getAuthorizationRules() {
return authorizationRules;
}
public GeoService setAuthorizationRules(List<AuthorizationRule> authorizationRules) {
this.authorizationRules = authorizationRules;
return this;
}
public List<GeoServiceLayer> getLayers() {
return layers;
}
public GeoService setLayers(List<GeoServiceLayer> layers) {
this.layers = layers;
return this;
}
public boolean isPublished() {
return published;
}
public GeoService setPublished(boolean published) {
this.published = published;
return this;
}
public GeoServiceSettings getSettings() {
return settings;
}
public GeoService setSettings(GeoServiceSettings settings) {
this.settings = settings;
return this;
}
// </editor-fold>
@PrePersist
public void assignId() {
if (StringUtils.isBlank(getId())) {
// We kind of misuse TSIDs here, because we store it as a string. This is because the id
// string can also be manually assigned. There won't be huge numbers of GeoServices, so it's
// more of a convenient way to generate an ID that isn't a huge UUID string.
setId(TSID.fast().toString());
}
}
/** Resolve the server type if set to AUTO. */
public org.tailormap.api.viewer.model.Service.ServerTypeEnum getResolvedServerType() {
if (settings.getServerType() == GeoServiceSettings.ServerTypeEnum.AUTO) {
return GeoServiceHelper.guessServerTypeFromUrl(getUrl());
} else {
return Service.ServerTypeEnum.fromValue(settings.getServerType().getValue());
}
}
public Service toJsonPojo(GeoServiceHelper geoServiceHelper) {
Service s = new Service()
.id(this.id)
.title(this.title)
.url(Boolean.TRUE.equals(this.getSettings().getUseProxy()) ? null : this.url)
.protocol(this.protocol)
.serverType(getResolvedServerType());
if (this.protocol == GeoServiceProtocol.WMTS) {
// Frontend requires WMTS capabilities to parse TilingMatrix, but WMS capabilities aren't used
// XXX UTF-8 only, maybe use base64
s.capabilities(new String(getCapabilities(), StandardCharsets.UTF_8));
}
return s;
}
public GeoServiceLayer findLayer(String name) {
return getLayers().stream()
.filter(sl -> name.equals(sl.getName()))
.findFirst()
.orElse(null);
}
public GeoServiceLayerSettings getLayerSettings(String layerName) {
return getSettings().getLayerSettings().get(layerName);
}
@NonNull public String getTitleWithSettingsOverrides(String layerName) {
// First use title in layer settings
String title = Optional.ofNullable(getLayerSettings(layerName))
.map(GeoServiceLayerSettings::getTitle)
.map(TMStringUtils::nullIfEmpty)
.orElse(null);
// If not set, title from capabilities
if (title == null) {
title = Optional.ofNullable(findLayer(layerName))
.map(GeoServiceLayer::getTitle)
.map(TMStringUtils::nullIfEmpty)
.orElse(null);
}
// Do not get title from default layer settings (a default title wouldn't make sense)
// If still not set, use layer name as title
if (title == null) {
title = layerName;
}
return title;
}
public TMFeatureType findFeatureTypeForLayer(
GeoServiceLayer layer, FeatureSourceRepository featureSourceRepository) {
GeoServiceDefaultLayerSettings defaultLayerSettings = getSettings().getDefaultLayerSettings();
GeoServiceLayerSettings layerSettings = getLayerSettings(layer.getName());
Long featureSourceId = null;
String featureTypeName;
if (layerSettings != null && layerSettings.getFeatureType() != null) {
featureTypeName = Optional.ofNullable(layerSettings.getFeatureType().getFeatureTypeName())
.orElse(layer.getName());
featureSourceId = layerSettings.getFeatureType().getFeatureSourceId();
} else {
featureTypeName = layer.getName();
}
if (featureSourceId == null && defaultLayerSettings != null && defaultLayerSettings.getFeatureType() != null) {
featureSourceId = defaultLayerSettings.getFeatureType().getFeatureSourceId();
}
if (featureTypeName == null) {
return null;
}
TMFeatureSource tmfs = null;
TMFeatureType tmft = null;
if (featureSourceId == null) {
List<TMFeatureSource> linkedSources = featureSourceRepository.findByLinkedServiceId(getId());
for (TMFeatureSource linkedFs : linkedSources) {
tmft = linkedFs.findFeatureTypeByName(featureTypeName);
if (tmft != null) {
tmfs = linkedFs;
break;
}
}
} else {
tmfs = featureSourceRepository.findById(featureSourceId).orElse(null);
if (tmfs != null) {
tmft = tmfs.findFeatureTypeByName(featureTypeName);
}
}
if (tmfs == null) {
return null;
}
if (tmft == null) {
String[] split = featureTypeName.split(":", 2);
if (split.length == 2) {
String shortFeatureTypeName = split[1];
tmft = tmfs.findFeatureTypeByName(shortFeatureTypeName);
if (tmft != null) {
logger.debug(
"Did not find feature type with full name \"{}\", using \"{}\" of feature source {}",
featureTypeName,
shortFeatureTypeName,
tmfs);
}
}
}
return tmft;
}
/**
* Remove all parameters from the URL that are listed in {@link #REMOVE_PARAMS}.
*
* @param url URL to sanitise
* @return sanitised URL
*/
private String sanitiseUrl(String url) {
if (url != null && url.contains("?")) {
MultiValueMap<String, String> sanitisedParams = new LinkedMultiValueMap<>();
UriComponentsBuilder uri = UriComponentsBuilder.fromUriString(url);
MultiValueMap<String, String> /* unmodifiable */ requestParams =
uri.build().getQueryParams();
for (Map.Entry<String, List<String>> param : requestParams.entrySet()) {
if (!REMOVE_PARAMS.contains(param.getKey().toUpperCase(Locale.ROOT))) {
sanitisedParams.put(param.getKey(), param.getValue());
}
}
url = uri.replaceQueryParams(sanitisedParams).build().toUriString();
if (url.endsWith("?")) {
url = url.substring(0, url.length() - 1);
}
}
return url;
}
public GeoServiceLayer getParentLayer(String layerId) {
for (GeoServiceLayer layer : this.getLayers()) {
if (layer.getChildren().contains(layerId)) {
return layer;
}
}
return null;
}
}