UNPKG

@geogirafe/lib-geoportal

Version:

GeoGirafe is a flexible application to build online geoportals.

383 lines (382 loc) 16.9 kB
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;