diff --git a/x-pack/package.json b/x-pack/package.json index edf731895173..e67273dacbed 100644 --- a/x-pack/package.json +++ b/x-pack/package.json @@ -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": { diff --git a/x-pack/plugins/maps/public/shared/layers/sources/wms_source.js b/x-pack/plugins/maps/public/shared/layers/sources/wms_source.js deleted file mode 100644 index 85bca3f4903f..000000000000 --- a/x-pack/plugins/maps/public/shared/layers/sources/wms_source.js +++ /dev/null @@ -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 (); - } - - 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 ( - - - this._handleServiceUrlChange(e)} - /> - - - this._handleLayersChange(e)} - /> - - - this._handleStylesChange(e)} - /> - - - - ); - } -} diff --git a/x-pack/plugins/maps/public/shared/layers/sources/wms_source/index.js b/x-pack/plugins/maps/public/shared/layers/sources/wms_source/index.js new file mode 100644 index 000000000000..22bc50e601f5 --- /dev/null +++ b/x-pack/plugins/maps/public/shared/layers/sources/wms_source/index.js @@ -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'; diff --git a/x-pack/plugins/maps/public/shared/layers/sources/wms_source/wms_client.js b/x-pack/plugins/maps/public/shared/layers/sources/wms_source/wms_client.js new file mode 100644 index 000000000000..f2dff7924a11 --- /dev/null +++ b/x-pack/plugins/maps/public/shared/layers/sources/wms_source/wms_client.js @@ -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 }; + }) + }]; +} diff --git a/x-pack/plugins/maps/public/shared/layers/sources/wms_source/wms_client.test.js b/x-pack/plugins/maps/public/shared/layers/sources/wms_source/wms_client.test.js new file mode 100644 index 000000000000..4d7f375307c5 --- /dev/null +++ b/x-pack/plugins/maps/public/shared/layers/sources/wms_source/wms_client.test.js @@ -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 ` + + + + layer1 + 1 + + + + layer2 + 2 + + + + + `; + } + }; + }; + 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 ` + + + + <![CDATA[hierarchyLevel1PathA]]> + + hierarchyLevel2 + + layer1 + 1 + + + + layer2 + 2 + + + + + hierarchyLevel1PathB + + layer3 + 3 + + + + + + `; + } + }; + }; + 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 ` + + + + hierarchyLevel1PathA + + hierarchyLevel2 + + layer1 + 1 + + + + + + hierarchyLevel1PathA + + hierarchyLevel2 + + layer2 + 2 + + + + + + + `; + } + }; + }; + 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 ` + + + + layer1 + 1 + + + + `; + } + }; + }; + const capabilities = await wmsClient.getCapabilities(); + expect(capabilities.layers).toEqual([ + { label: 'layer1', value: '1' }, + ]); + }); +}); diff --git a/x-pack/plugins/maps/public/shared/layers/sources/wms_source/wms_create_source_editor.js b/x-pack/plugins/maps/public/shared/layers/sources/wms_source/wms_create_source_editor.js new file mode 100644 index 000000000000..da692a359346 --- /dev/null +++ b/x-pack/plugins/maps/public/shared/layers/sources/wms_source/wms_create_source_editor.js @@ -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 ( + + +

{this.state.getCapabilitiesError}

+
+ + + + + + + + +
+ ); + } + + return ( + + + + + + + + + ); + } + + _renderGetCapabilitiesButton() { + if (!this.state.isLoadingCapabilities && this.state.hasAttemptedToLoadCapabilities) { + return null; + } + + return ( + + + + + + + + ); + } + + render() { + return ( + + + + + + {this._renderGetCapabilitiesButton()} + + {this._renderLayerAndStyleInputs()} + + + ); + } +} diff --git a/x-pack/plugins/maps/public/shared/layers/sources/wms_source/wms_source.js b/x-pack/plugins/maps/public/shared/layers/sources/wms_source/wms_source.js new file mode 100644 index 000000000000..76f20905b953 --- /dev/null +++ b/x-pack/plugins/maps/public/shared/layers/sources/wms_source/wms_source.js @@ -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 (); + } + + 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}`; + } +}