[Maps] populate WMS layers from getCapabilities response (#32342)

* [Maps] populate WMS layers from getCapabilities response

* move wms get capabilities into seperate class

* move xml2js from devDependency to dependency

* localize get capabilities text

* get jest test wired together

* implement getCapabilities in WmsClient

* display layers and styles in EuiComboBox

* extract root common path to avoid UI where options text is all the same prefix

* handle case where only single layer is returned in capabilities

* feedback on WmsClient

* clear layers and styles when serviceUrl changes
This commit is contained in:
Nathan Reese 2019-03-13 10:15:49 -06:00 committed by GitHub
parent 6374c9676f
commit b32b852afa
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 674 additions and 166 deletions

View file

@ -129,7 +129,6 @@
"typescript": "^3.3.3333",
"vinyl-fs": "^3.0.2",
"xml-crypto": "^0.10.1",
"xml2js": "^0.4.19",
"yargs": "4.8.1"
},
"dependencies": {
@ -285,6 +284,7 @@
"unstated": "^2.1.1",
"uuid": "3.0.1",
"venn.js": "0.2.9",
"xml2js": "^0.4.19",
"xregexp": "3.2.0"
},
"engines": {

View file

@ -1,165 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { Fragment } from 'react';
import {
EuiFieldText,
EuiFormRow,
} from '@elastic/eui';
import { AbstractTMSSource } from './tms_source';
import { TileLayer } from '../tile_layer';
import { i18n } from '@kbn/i18n';
import { getDataSourceLabel, getUrlLabel } from '../../../../common/i18n_getters';
export class WMSSource extends AbstractTMSSource {
static type = 'WMS';
static title = i18n.translate('xpack.maps.source.wmsTitle', {
defaultMessage: 'Web Map Service'
});
static description = i18n.translate('xpack.maps.source.wmsDescription', {
defaultMessage: 'Maps from OGC Standard WMS'
});
static icon = 'grid';
static createDescriptor({ serviceUrl, layers, styles }) {
return {
type: WMSSource.type,
serviceUrl: serviceUrl,
layers: layers,
styles: styles
};
}
static renderEditor({ onPreviewSource, inspectorAdapters }) {
const previewWMS = (options) => {
const sourceDescriptor = WMSSource.createDescriptor(options);
const source = new WMSSource(sourceDescriptor, inspectorAdapters);
onPreviewSource(source);
};
return (<WMSEditor previewWMS={previewWMS} />);
}
async getImmutableProperties() {
return [
{ label: getDataSourceLabel(), value: WMSSource.title },
{ label: getUrlLabel(), value: this._descriptor.serviceUrl },
{ label: i18n.translate('xpack.maps.source.wms.layersLabel', {
defaultMessage: 'Layers'
}), value: this._descriptor.layers },
{ label: i18n.translate('xpack.maps.source.wms.stylesLabel', {
defaultMessage: 'Styles'
}), value: this._descriptor.styles },
];
}
_createDefaultLayerDescriptor(options) {
return TileLayer.createDescriptor({
sourceDescriptor: this._descriptor,
...options
});
}
createDefaultLayer(options) {
return new TileLayer({
layerDescriptor: this._createDefaultLayerDescriptor(options),
source: this
});
}
async getDisplayName() {
return this._descriptor.serviceUrl;
}
getUrlTemplate() {
const styles = this._descriptor.styles || '';
// eslint-disable-next-line max-len
return `${this._descriptor.serviceUrl}?bbox={bbox-epsg-3857}&format=image/png&service=WMS&version=1.1.1&request=GetMap&srs=EPSG:3857&transparent=true&width=256&height=256&layers=${this._descriptor.layers}&styles=${styles}`;
}
}
class WMSEditor extends React.Component {
state = {
serviceUrl: '',
layers: '',
styles: ''
}
_previewIfPossible() {
if (this.state.serviceUrl && this.state.layers) {
//todo: should really debounce this so we don't get a ton of changes during typing
this.props.previewWMS({
serviceUrl: this.state.serviceUrl,
layers: this.state.layers,
styles: this.state.styles
});
}
}
async _handleServiceUrlChange(e) {
await this.setState({
serviceUrl: e.target.value
});
this._previewIfPossible();
}
async _handleLayersChange(e) {
await this.setState({
layers: e.target.value
});
this._previewIfPossible();
}
async _handleStylesChange(e) {
await this.setState({
styles: e.target.value
});
this._previewIfPossible();
}
render() {
return (
<Fragment>
<EuiFormRow label="Url">
<EuiFieldText
value={this.state.serviceUrl}
onChange={(e) => this._handleServiceUrlChange(e)}
/>
</EuiFormRow>
<EuiFormRow
label={i18n.translate('xpack.maps.source.wms.layersLabel', {
defaultMessage: 'Layers'
})}
helpText={i18n.translate('xpack.maps.source.wms.layersHelpText', {
defaultMessage: 'use comma separated list of layer names'
})}
>
<EuiFieldText
onChange={(e) => this._handleLayersChange(e)}
/>
</EuiFormRow>
<EuiFormRow
label={i18n.translate('xpack.maps.source.wms.stylesLabel', {
defaultMessage: 'Styles'
})}
helpText={i18n.translate('xpack.maps.source.wms.stylesHelpText', {
defaultMessage: 'use comma separated list of style names'
})}
>
<EuiFieldText
onChange={(e) => this._handleStylesChange(e)}
/>
</EuiFormRow>
</Fragment>
);
}
}

View file

@ -0,0 +1,7 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export { WMSSource } from './wms_source';

View file

@ -0,0 +1,127 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import _ from 'lodash';
import { parseString } from 'xml2js';
import fetch from 'node-fetch';
export class WmsClient {
constructor({ serviceUrl }) {
this._serviceUrl = serviceUrl;
}
async _fetch(url) {
return fetch(url);
}
async _fetchCapabilities() {
const resp = await this._fetch(`${this._serviceUrl}?version=1.1.1&request=GetCapabilities&service=WMS`);
if (resp.status >= 400) {
throw new Error(`Unable to access ${this.state.serviceUrl}`);
}
const body = await resp.text();
const parsePromise = new Promise((resolve, reject) => {
parseString(body, (error, result) => {
if (error) {
reject(error);
} else {
resolve(result);
}
});
});
return await parsePromise;
}
async getCapabilities() {
const rawCapabilities = await this._fetchCapabilities();
const { layers, styles } = reduceLayers([], _.get(rawCapabilities, 'WMT_MS_Capabilities.Capability[0].Layer', []));
return {
layers: groupCapabilities(layers),
styles: groupCapabilities(styles)
};
}
}
function reduceLayers(path, layers) {
const emptyCapabilities = {
layers: [],
styles: [],
};
function createOption(optionPath, optionTitle, optionName) {
return {
path: [...optionPath, optionTitle],
value: optionName
};
}
return layers.reduce((accumulatedCapabilities, layer) => {
// Layer is hierarchical, continue traversing
if (layer.Layer) {
const hierarchicalCapabilities = reduceLayers([...path, layer.Title[0]], layer.Layer);
return {
layers: [...accumulatedCapabilities.layers, ...hierarchicalCapabilities.layers],
styles: [...accumulatedCapabilities.styles, ...hierarchicalCapabilities.styles]
};
}
const updatedStyles = [...accumulatedCapabilities.styles];
if (_.has(layer, 'Style[0]')) {
updatedStyles.push(createOption(
path,
_.get(layer, 'Style[0].Title[0]'),
_.get(layer, 'Style[0].Name[0]')
));
}
return {
layers: [
...accumulatedCapabilities.layers,
createOption(path, layer.Title[0], layer.Name[0])
],
styles: updatedStyles
};
}, emptyCapabilities);
}
// Avoid filling select box option label with text that is all the same
// Create a single group from common parts of Layer hierarchy
function groupCapabilities(list) {
if (list.length === 0) {
return [];
}
let rootCommonPath = list[0].path;
for(let listIndex = 1; listIndex < list.length; listIndex++) {
if (rootCommonPath.length === 0) {
// No commonality in root path, nothing left to verify
break;
}
const path = list[listIndex].path;
for(let pathIndex = 0; pathIndex < path.length && pathIndex < rootCommonPath.length; pathIndex++) {
if (rootCommonPath[pathIndex] !== path[pathIndex]) {
// truncate root common path at location of divergence
rootCommonPath = rootCommonPath.slice(0, pathIndex);
break;
}
}
}
if (rootCommonPath.length === 0 || list.length === 1) {
return list.map(({ path, value }) => {
return { label: path.join(' - '), value };
});
}
return [{
label: rootCommonPath.join(' - '),
options: list.map(({ path, value }) => {
return { label: path.splice(rootCommonPath.length).join(' - '), value };
})
}];
}

View file

@ -0,0 +1,198 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { WmsClient } from './wms_client';
describe('getCapabilities', () => {
it('Should extract flat Layer elements', async () => {
const wmsClient = new WmsClient({ serviceUrl: 'myWMSUrl' });
wmsClient._fetch = () => {
return {
status: 200,
text: () => {
return `
<WMT_MS_Capabilities version="1.1.1">
<Capability>
<Layer>
<Title>layer1</Title>
<Name>1</Name>
<Style>
<Name>default</Name>
<Title>defaultStyle</Title>
</Style>
</Layer>
<Layer>
<Title>layer2</Title>
<Name>2</Name>
<Style>
<Name>fancy</Name>
<Title>fancyStyle</Title>
</Style>
</Layer>
</Capability>
</WMT_MS_Capabilities>
`;
}
};
};
const capabilities = await wmsClient.getCapabilities();
expect(capabilities.layers).toEqual([
{ label: 'layer1', value: '1' },
{ label: 'layer2', value: '2' }
]);
expect(capabilities.styles).toEqual([
{ label: 'defaultStyle', value: 'default' },
{ label: 'fancyStyle', value: 'fancy' }
]);
});
// Good example of Layer hierarchy in the wild can be found at
// https://idpgis.ncep.noaa.gov/arcgis/services/NWS_Forecasts_Guidance_Warnings/NDFD_temp/MapServer/WMSServer
it('Should extract hierarchical Layer elements', async () => {
const wmsClient = new WmsClient({ serviceUrl: 'myWMSUrl' });
wmsClient._fetch = () => {
return {
status: 200,
text: () => {
return `
<WMT_MS_Capabilities version="1.1.1">
<Capability>
<Layer>
<Title><![CDATA[hierarchyLevel1PathA]]></Title>
<Layer>
<Title>hierarchyLevel2</Title>
<Layer>
<Title>layer1</Title>
<Name>1</Name>
<Style>
<Name>default</Name>
<Title>defaultStyle</Title>
</Style>
</Layer>
<Layer>
<Title>layer2</Title>
<Name>2</Name>
</Layer>
</Layer>
</Layer>
<Layer>
<Title>hierarchyLevel1PathB</Title>
<Layer>
<Title>layer3</Title>
<Name>3</Name>
<Style>
<Name>fancy</Name>
<Title>fancyStyle</Title>
</Style>
</Layer>
</Layer>
</Capability>
</WMT_MS_Capabilities>
`;
}
};
};
const capabilities = await wmsClient.getCapabilities();
expect(capabilities.layers).toEqual([
{ label: 'hierarchyLevel1PathA - hierarchyLevel2 - layer1', value: '1' },
{ label: 'hierarchyLevel1PathA - hierarchyLevel2 - layer2', value: '2' },
{ label: 'hierarchyLevel1PathB - layer3', value: '3' }
]);
expect(capabilities.styles).toEqual([
{ label: 'hierarchyLevel1PathA - hierarchyLevel2 - defaultStyle', value: 'default' },
{ label: 'hierarchyLevel1PathB - fancyStyle', value: 'fancy' }
]);
});
it('Should create group from common parts of Layer hierarchy', async () => {
const wmsClient = new WmsClient({ serviceUrl: 'myWMSUrl' });
wmsClient._fetch = () => {
return {
status: 200,
text: () => {
return `
<WMT_MS_Capabilities version="1.1.1">
<Capability>
<Layer>
<Title>hierarchyLevel1PathA</Title>
<Layer>
<Title>hierarchyLevel2</Title>
<Layer>
<Title>layer1</Title>
<Name>1</Name>
<Style>
<Name>default</Name>
<Title>defaultStyle</Title>
</Style>
</Layer>
</Layer>
</Layer>
<Layer>
<Title>hierarchyLevel1PathA</Title>
<Layer>
<Title>hierarchyLevel2</Title>
<Layer>
<Title>layer2</Title>
<Name>2</Name>
<Style>
<Name>fancy</Name>
<Title>fancyStyle</Title>
</Style>
</Layer>
</Layer>
</Layer>
</Capability>
</WMT_MS_Capabilities>
`;
}
};
};
const capabilities = await wmsClient.getCapabilities();
expect(capabilities.layers).toEqual([
{
label: 'hierarchyLevel1PathA - hierarchyLevel2',
options: [
{ label: 'layer1', value: '1' },
{ label: 'layer2', value: '2' },
]
}
]);
expect(capabilities.styles).toEqual([
{
label: 'hierarchyLevel1PathA - hierarchyLevel2',
options: [
{ label: 'defaultStyle', value: 'default' },
{ label: 'fancyStyle', value: 'fancy' },
]
}
]);
});
it('Should create not group common hierarchy when there is only a single layer', async () => {
const wmsClient = new WmsClient({ serviceUrl: 'myWMSUrl' });
wmsClient._fetch = () => {
return {
status: 200,
text: () => {
return `
<WMT_MS_Capabilities version="1.1.1">
<Capability>
<Layer>
<Title>layer1</Title>
<Name>1</Name>
</Layer>
</Capability>
</WMT_MS_Capabilities>
`;
}
};
};
const capabilities = await wmsClient.getCapabilities();
expect(capabilities.layers).toEqual([
{ label: 'layer1', value: '1' },
]);
});
});

View file

@ -0,0 +1,250 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { Component, Fragment } from 'react';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import {
EuiButton,
EuiCallOut,
EuiComboBox,
EuiFieldText,
EuiFormRow,
EuiForm,
EuiSpacer,
} from '@elastic/eui';
import { WmsClient } from './wms_client';
const LAYERS_LABEL = i18n.translate('xpack.maps.source.wms.layersLabel', {
defaultMessage: 'Layers'
});
const STYLES_LABEL = i18n.translate('xpack.maps.source.wms.stylesLabel', {
defaultMessage: 'Styles'
});
export class WMSCreateSourceEditor extends Component {
state = {
serviceUrl: '',
layers: '',
styles: '',
isLoadingCapabilities: false,
getCapabilitiesError: null,
hasAttemptedToLoadCapabilities: false,
layerOptions: [],
styleOptions: [],
selectedLayerOptions: [],
selectedStyleOptions: [],
}
componentDidMount() {
this._isMounted = true;
}
componentWillUnmount() {
this._isMounted = false;
}
_previewIfPossible() {
const {
serviceUrl,
layers,
styles
} = this.state;
const sourceConfig = (serviceUrl && layers)
? { serviceUrl, layers, styles }
: null;
this.props.previewWMS(sourceConfig);
}
_loadCapabilities = async () => {
if (!this.state.serviceUrl) {
return;
}
this.setState({
hasAttemptedToLoadCapabilities: true,
isLoadingCapabilities: true,
getCapabilitiesError: null,
});
const wmsClient = new WmsClient({ serviceUrl: this.state.serviceUrl });
let capabilities;
try {
capabilities = await wmsClient.getCapabilities();
} catch (error) {
if (this._isMounted) {
this.setState({
isLoadingCapabilities: false,
getCapabilitiesError: error.message
});
}
return;
}
if (!this._isMounted) {
return;
}
this.setState({
isLoadingCapabilities: false,
layerOptions: capabilities.layers,
styleOptions: capabilities.styles
});
}
_handleServiceUrlChange = (e) => {
this.setState({
serviceUrl: e.target.value,
hasAttemptedToLoadCapabilities: false,
layerOptions: [],
styleOptions: [],
selectedLayerOptions: [],
selectedStyleOptions: [],
layers: '',
styles: '',
}, this._previewIfPossible);
}
_handleLayersChange = (e) => {
this.setState({ layers: e.target.value }, this._previewIfPossible);
}
_handleLayerOptionsChange = (selectedOptions) => {
this.setState({
selectedLayerOptions: selectedOptions,
layers: selectedOptions.map(selectedOption => {
return selectedOption.value;
}).join(',')
}, this._previewIfPossible);
}
_handleStylesChange = (e) => {
this.setState({ styles: e.target.value }, this._previewIfPossible);
}
_handleStyleOptionsChange = (selectedOptions) => {
this.setState({
selectedStyleOptions: selectedOptions,
styles: selectedOptions.map(selectedOption => {
return selectedOption.value;
}).join(',')
}, this._previewIfPossible);
}
_renderLayerAndStyleInputs() {
if (!this.state.hasAttemptedToLoadCapabilities || this.state.isLoadingCapabilities) {
return null;
}
if (this.state.getCapabilitiesError || this.state.layerOptions.length === 0) {
return (
<Fragment>
<EuiCallOut
title={i18n.translate('xpack.maps.source.wms.getCapabilitiesErrorCalloutTitle', {
defaultMessage: 'Unable to load service metadata'
})}
color="warning"
>
<p>{this.state.getCapabilitiesError}</p>
</EuiCallOut>
<EuiFormRow
label={LAYERS_LABEL}
helpText={i18n.translate('xpack.maps.source.wms.layersHelpText', {
defaultMessage: 'use comma separated list of layer names'
})}
>
<EuiFieldText
onChange={this._handleLayersChange}
/>
</EuiFormRow>
<EuiFormRow
label={STYLES_LABEL}
helpText={i18n.translate('xpack.maps.source.wms.stylesHelpText', {
defaultMessage: 'use comma separated list of style names'
})}
>
<EuiFieldText
onChange={this._handleStylesChange}
/>
</EuiFormRow>
</Fragment>
);
}
return (
<Fragment>
<EuiFormRow
label={LAYERS_LABEL}
>
<EuiComboBox
options={this.state.layerOptions}
selectedOptions={this.state.selectedLayerOptions}
onChange={this._handleLayerOptionsChange}
/>
</EuiFormRow>
<EuiFormRow
label={STYLES_LABEL}
>
<EuiComboBox
options={this.state.styleOptions}
selectedOptions={this.state.selectedStyleOptions}
onChange={this._handleStyleOptionsChange}
/>
</EuiFormRow>
</Fragment>
);
}
_renderGetCapabilitiesButton() {
if (!this.state.isLoadingCapabilities && this.state.hasAttemptedToLoadCapabilities) {
return null;
}
return (
<Fragment>
<EuiButton
onClick={this._loadCapabilities}
isDisabled={!this.state.serviceUrl}
isLoading={this.state.isLoadingCapabilities}
>
<FormattedMessage
id="xpack.maps.source.wms.getCapabilitiesButtonText"
defaultMessage="Load capabilities"
/>
</EuiButton>
<EuiSpacer size="m" />
</Fragment>
);
}
render() {
return (
<EuiForm>
<EuiFormRow
label={i18n.translate('xpack.maps.source.wms.urlLabel', {
defaultMessage: 'Url'
})}
>
<EuiFieldText
value={this.state.serviceUrl}
onChange={this._handleServiceUrlChange}
/>
</EuiFormRow>
{this._renderGetCapabilitiesButton()}
{this._renderLayerAndStyleInputs()}
</EuiForm>
);
}
}

View file

@ -0,0 +1,91 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { AbstractTMSSource } from '../tms_source';
import { TileLayer } from '../../tile_layer';
import { WMSCreateSourceEditor } from './wms_create_source_editor';
import { i18n } from '@kbn/i18n';
import { getDataSourceLabel, getUrlLabel } from '../../../../../common/i18n_getters';
export class WMSSource extends AbstractTMSSource {
static type = 'WMS';
static title = i18n.translate('xpack.maps.source.wmsTitle', {
defaultMessage: 'Web Map Service'
});
static description = i18n.translate('xpack.maps.source.wmsDescription', {
defaultMessage: 'Maps from OGC Standard WMS'
});
static icon = 'grid';
static createDescriptor({ serviceUrl, layers, styles }) {
return {
type: WMSSource.type,
serviceUrl: serviceUrl,
layers: layers,
styles: styles
};
}
static renderEditor({ onPreviewSource, inspectorAdapters }) {
const previewWMS = (sourceConfig) => {
if (!sourceConfig) {
onPreviewSource(null);
return;
}
const sourceDescriptor = WMSSource.createDescriptor(sourceConfig);
const source = new WMSSource(sourceDescriptor, inspectorAdapters);
onPreviewSource(source);
};
return (<WMSCreateSourceEditor previewWMS={previewWMS} />);
}
async getImmutableProperties() {
return [
{ label: getDataSourceLabel(), value: WMSSource.title },
{ label: getUrlLabel(), value: this._descriptor.serviceUrl },
{
label: i18n.translate('xpack.maps.source.wms.layersLabel', {
defaultMessage: 'Layers'
}),
value: this._descriptor.layers
},
{
label: i18n.translate('xpack.maps.source.wms.stylesLabel', {
defaultMessage: 'Styles'
}),
value: this._descriptor.styles
},
];
}
_createDefaultLayerDescriptor(options) {
return TileLayer.createDescriptor({
sourceDescriptor: this._descriptor,
...options
});
}
createDefaultLayer(options) {
return new TileLayer({
layerDescriptor: this._createDefaultLayerDescriptor(options),
source: this
});
}
async getDisplayName() {
return this._descriptor.serviceUrl;
}
getUrlTemplate() {
const styles = this._descriptor.styles || '';
// eslint-disable-next-line max-len
return `${this._descriptor.serviceUrl}?bbox={bbox-epsg-3857}&format=image/png&service=WMS&version=1.1.1&request=GetMap&srs=EPSG:3857&transparent=true&width=256&height=256&layers=${this._descriptor.layers}&styles=${styles}`;
}
}