@geogirafe/lib-geoportal
Version:
GeoGirafe is a flexible application to build online geoportals.
383 lines (382 loc) • 16.9 kB
JavaScript
import WfsParser from './wfsparser.js';
import SelectionParam from '../../models/selectionparam.js';
import LayerWms from '../../models/layers/layerwms.js';
import ServerWfs from '../../models/serverwfs.js';
import { isGeometry, xmlTypesStrList } from '../../models/xmlTypes.js';
import LayerTimeFormatter from '../time/layertimeformatter.js';
import { and } from 'ol/format/filter.js';
export default class WfsClient {
context;
version = '1.1.0';
get state() {
return this.context.stateManager.state;
}
ogcServer;
maxFeatures;
featurePrefix;
featureNS;
featureNsWithPrefix = {};
urlParameters = new URLSearchParams();
serverWfs;
metadataLayerNamePlaceholder = '###LAYERNAME###';
metadataAttributeNamePlaceholder = '###ATTRIBUTENAME###';
constructor(ogcServer, options, context) {
this.ogcServer = ogcServer;
this.featureNS = options.featureNS;
this.featurePrefix = options.featurePrefix;
this.featureNsWithPrefix[this.featurePrefix] = this.featureNS;
this.context = context;
this.configMaxFeatures();
this.version = this.extractVersionFromUrl(this.wfsUrl);
}
get wfsUrl() {
return this.ogcServer.urlWfs ?? '';
}
extractVersionFromUrl(url) {
const urlObj = new URL(url);
const version = urlObj.searchParams.get('version');
if (version) {
return version;
}
else {
return this.version;
}
}
configMaxFeatures() {
this.maxFeatures = this.context.configManager.Config.selection.maxFeature ?? this.maxFeatures;
}
getServerWfs() {
return this.describeFeatureType();
}
describeFeatureType() {
if (!this.serverWfs) {
this.serverWfs = this.parseDescribeFeatureTypeResponse();
this.serverWfs.catch((error) => {
const msg = `WFS server with URL ${this.wfsUrl} could not be initialized. Error: `;
console.error(msg, error);
this.serverWfs = undefined;
});
}
return this.serverWfs;
}
async requestDescribeFeatureType() {
const url = this.getDescribeFeatureTypeUrl();
const response = await fetch(url);
const content = await response.text();
return new DOMParser().parseFromString(content, 'text/xml');
}
async parseDescribeFeatureTypeResponse() {
const serverWfs = new ServerWfs('', this.wfsUrl);
const xml = await this.requestDescribeFeatureType();
// First, find all direct "element" children
const elementTypeToName = this.getElementToTypeName(xml);
// Then, find all "complexType" elements
const tags = xml.getElementsByTagName('complexType');
for (const tag of tags) {
this.initializeAttribute(tag, serverWfs, elementTypeToName);
}
// This WFS is now initialized
serverWfs.initialized = true;
return serverWfs;
}
initializeAttribute(tag, serverWfs, elementTypeToName) {
// Takes an XML element, extract the attribute's type and name
// Adds it to the serverWfs featureType
const typeName = tag.getAttribute('name');
if (!typeName) {
throw new Error('Could not find a name for the complex type');
}
const featureType = elementTypeToName[typeName];
const elements = tag.getElementsByTagName('sequence')[0].getElementsByTagName('element');
try {
serverWfs.addLayer(featureType);
for (const element of elements) {
this.manageLayerAttribute(serverWfs, element, featureType);
}
// If we didn't find any geometry attribute for this featureType, wfs querying won't be possible
if (!serverWfs.featureTypeToGeometryColumnName[featureType]) {
throw new Error(`No Geometry column for the type ${featureType}`);
}
}
catch (error) {
serverWfs.removeLayer(featureType);
console.error(`Error while parsing feature type ${typeName}: ${error}`);
}
}
manageLayerAttribute(serverWfs, element, featureType) {
const attributeType = element.getAttribute('type');
const attributeName = element.getAttribute('name');
if (!attributeName || !attributeType) {
console.warn(`Error while loading attributes for layer ${featureType}. Attribute name and/or type is missing. Element ignored.`);
return;
}
// Handle the geometry attribute
if (isGeometry(attributeType)) {
serverWfs.addLayerGeometryAttribute(featureType, attributeName, attributeType);
return;
}
// Handle all remaining regular attributes
if (!this.validateLayerAttributeType(attributeType)) {
console.warn(`Unmanaged layer attribute type: ${attributeType} for attribute ${attributeName} of featureType ${featureType}. ${attributeName} ignored.`);
return;
}
serverWfs.addLayerAttribute(featureType, attributeName, attributeType);
}
validateLayerAttributeType(type) {
return xmlTypesStrList.includes(type);
}
getElementToTypeName(xml) {
const elementTypeToName = {};
const elements = xml.querySelectorAll(':scope>element');
for (const element of elements) {
if (element.hasAttribute('name') && element.hasAttribute('type')) {
const name = element.getAttribute('name');
let type = element.getAttribute('type');
if (type && name) {
if (type.includes(':')) {
type = type.split(':')[1];
}
elementTypeToName[type] = name;
}
}
else {
console.log('What happend with this element? element', element);
}
}
return elementTypeToName;
}
getDescribeFeatureTypeUrl() {
const url = new URL(this.wfsUrl);
url.searchParams.set('service', 'WFS');
url.searchParams.set('request', 'DescribeFeatureType');
url.searchParams.set('version', this.version);
return url.href;
}
/**
* Retrieves the list of valid values for an enumerated attribute within a given layer.
*/
async getAttributeValueList(layerName, attributeName) {
const serverWfs = await this.getServerWfs();
if (!(layerName in serverWfs.layers)) {
return [];
}
const layerAttribute = serverWfs.layers[layerName].find((a) => a.name === attributeName);
if (!layerAttribute) {
console.warn(`Attribute value list not retrievable, enumerated attribute ${attributeName} does not exist in layer ${layerName}`);
return [];
}
if (layerAttribute.valueList) {
return layerAttribute.valueList;
}
layerAttribute.valueList = await this.fetchAttributeValueList(layerName, attributeName);
return layerAttribute.valueList;
}
/**
* Fetch GMF-style value lists in the form of {"items": [{"value": "Curling"}, {"value": "Patinoire"} ]}
*/
async fetchAttributeValueList(layerName, attributeName) {
const urlTemplate = this.context.configManager.Config.filtering?.gmfLayerMetadataUrl;
if (!urlTemplate) {
console.info(`Attribute value list not retrievable, config value "filtering" > "gmfLayerMetadataUrl" is missing`);
return [];
}
const url = urlTemplate
.replace(this.metadataLayerNamePlaceholder, layerName)
.replace(this.metadataAttributeNamePlaceholder, attributeName);
const response = await fetch(url);
if (response.ok) {
try {
const content = await response.json();
return content.items.map((item) => item.value);
}
catch (e) {
console.error(e);
}
}
return [];
}
async getFeature(options) {
let partialOptions;
if (options instanceof SelectionParam) {
partialOptions = this.getFeatureOptionsFromSelectionParam(options);
}
else {
partialOptions = options;
this.removeTimeRestrictionAsUrlParameter();
}
if (!partialOptions) {
return [];
}
const getFeatureOptions = await this.completeGetFeatureOptions(partialOptions);
// Create a request for each getFeatureOptions object
const getFeatureRequests = getFeatureOptions.map(async (options) => this.getFeatureRaw(options));
const getFeatureResponses = await Promise.all(getFeatureRequests);
const flattenedResponses = getFeatureResponses.flat();
if (flattenedResponses.length == this.maxFeatures) {
this.context.errorManager.pushMessage('wfs-max-features-reached', 'wfs-max-features-reached', 'warning');
}
return flattenedResponses;
}
/**
* Transforms a `SelectionParam` object, originating from a map selection,
* into a `GetFeatureOptionsPartial` object, needed for performing the WFS GetFeature request.
*/
getFeatureOptionsFromSelectionParam(selectionParam) {
// First, keep only queryable and visible layers and verify that all layers have the same WFS URL
const currentResolution = this.state.position.resolution;
if (!currentResolution) {
console.log('WFSClient called before resolution is set.');
return null;
}
const queryableLayers = selectionParam.layers.filter((l) => l.wfsQueryable && l.isVisibleAtResolution(currentResolution));
if (queryableLayers.length <= 0) {
return null;
}
const featureTypes = queryableLayers.flatMap((l) => l.queryLayers
.split(',')
.filter((el) => LayerWms.isInVisibleRange(currentResolution, l.queryLayersRanges[el]?.minResolution, l.queryLayersRanges[el]?.maxResolution)));
// Attribute filter based on URL parameters in permalink
const featureSelectionFilter = selectionParam.selectionQuery?.toOpenLayersFilter();
// Layer tree attribute filter
const layerFilter = queryableLayers[0].filter?.toOpenLayersFilter();
// Layer tree time filter
const timeFilter = this.setTimeRestriction(queryableLayers[0]);
const filterList = [featureSelectionFilter, layerFilter, timeFilter]
.flat()
.filter((f) => f !== undefined);
// Combine filter with an AND operator
let combinedFilters = undefined;
if (filterList.length > 1) {
combinedFilters = and(...filterList);
}
else if (filterList.length === 1) {
combinedFilters = filterList[0];
}
return {
featureTypes: featureTypes,
srsName: selectionParam.srid,
bbox: selectionParam.selectionBox,
filter: combinedFilters
};
}
/**
* Creates a `WriteGetFeatureOptions` object by expanding the provided partial options.
* Required for a ol GetFeature WFS request.
*/
async completeGetFeatureOptions(options) {
if (!options.featureTypes || options.featureTypes.length === 0) {
throw new Error(`WFS GetFeature: no feature types specified, not able to query.\nWFS: ${this.wfsUrl}`);
}
// Ensure the WFS server is initialized
const serverWfs = await this.getServerWfs();
// Organize layers by their geometry column name in the form of: <geometry column name, layer list>
const geometryColumnNameToFeatureType = serverWfs.getGeometryColumnNameToFeatureTypes(options.featureTypes);
// Create option objects for each group of feature types that share the same geometry column name
// Add missing feature prefix and namespace to the options
return Object.entries(geometryColumnNameToFeatureType).map(([geomColumnName, featureTypesPerGeom]) => {
return {
featurePrefix: this.featurePrefix,
featureNS: this.featureNS,
maxFeatures: this.maxFeatures,
...options,
featureTypes: featureTypesPerGeom,
geometryName: geomColumnName
};
});
}
/**
* Sends a GetFeature request to a WFS endpoint based on provided options and parses the received GML answer.
*/
async getFeatureRaw(getFeatureOptions) {
if (getFeatureOptions.bbox && !getFeatureOptions.geometryName) {
throw new Error(`WFS GetFeature: not possible to query bbox ${getFeatureOptions.bbox} without a geometryName.\n
FeatureTypes: ${getFeatureOptions.featureTypes}\nWFS: ${this.wfsUrl}`);
}
const wfs = new WfsParser({
version: this.version,
featureNS: this.featureNsWithPrefix,
featureType: getFeatureOptions.featureTypes.map((ft) => `${this.featurePrefix}:${ft}`)
});
const featureRequest = wfs.writeGetFeature(getFeatureOptions);
const url = new URL(this.wfsUrl);
// If URL parameters have been specified, add them to the URL (e.g. TIME parameter)
for (const [key, value] of this.urlParameters.entries()) {
url.searchParams.set(key, value);
}
const response = await fetch(url, {
method: 'POST',
body: new XMLSerializer().serializeToString(featureRequest)
});
const gml = await response.text();
this.checkForExceptions(gml);
return wfs.readFeatures(gml);
}
/**
* Sets or removes a time restriction on the provided query layer. Depending on the presence of a time attribute,
* either a temporal XML filter is created or a TIME parameter is added to the URL.
*
* @param {QueryableLayerWms} queryLayer - The layer on which the time restriction is to be applied.
* @return {Filter | undefined} Returns a temporal XML filter if a time attribute exists; otherwise, undefined.
*/
setTimeRestriction(queryLayer) {
if (queryLayer.timeAttribute) {
return this.getTimeRestrictionFilter(queryLayer);
}
else {
this.addTimeRestrictionAsUrlParameter(queryLayer);
}
return undefined;
}
getTimeRestrictionFilter(queryLayer) {
if (queryLayer.timeRestriction && queryLayer.timeAttribute) {
// Create temporal XML filter
const timeFormatter = new LayerTimeFormatter(queryLayer.timeOptions);
return timeFormatter.toOpenLayersWfsFilter(queryLayer.timeRestriction, queryLayer.timeAttribute);
}
return undefined;
}
addTimeRestrictionAsUrlParameter(queryLayer) {
if (queryLayer?.timeRestriction) {
// Add time filter as a URL parameter
this.urlParameters.set('TIME', queryLayer.timeRestriction);
}
else {
this.removeTimeRestrictionAsUrlParameter();
}
}
removeTimeRestrictionAsUrlParameter() {
if (this.urlParameters.has('TIME')) {
this.urlParameters.delete('TIME');
}
}
checkForExceptions(source) {
const domParser = new DOMParser();
const doc = domParser.parseFromString(source, 'application/xml');
const ns = 'http://www.opengis.net/ows'; // NOSONAR
const exceptionReport = doc.getElementsByTagNameNS(ns, 'ExceptionReport')[0];
if (exceptionReport) {
const exception = exceptionReport.getElementsByTagNameNS(ns, 'Exception')[0];
const message = exception?.getElementsByTagNameNS(ns, 'ExceptionText')[0]?.textContent;
console.error(`WFS Exception: ${message || 'Unknown error'}`);
throw new Error(`Feature Selection not possible due to a WFS Exception`);
}
}
}
export class WfsClientMapServer extends WfsClient {
constructor(ogcServer, options, context) {
super(ogcServer, { featurePrefix: 'ms', featureNS: 'http://mapserver.gis.umn.edu/mapserver', ...options }, context); // NOSONAR
}
}
export class WfsClientQgis extends WfsClient {
constructor(ogcServer, options, context) {
super(ogcServer, { featurePrefix: 'qgs', featureNS: 'http://www.qgis.org/gml', ...options }, context); // NOSONAR
}
}
export class WfsClientGeorama extends WfsClient {
version = '2.0.0';
constructor(ogcServer, options, context) {
super(ogcServer, { featurePrefix: 'georama', featureNS: 'https://www.opengis.ch/georama', ...options }, context);
}
}
export const WfsClientDefault = WfsClientQgis;
export const WfsClientGeoServer = WfsClientMapServer;