diff --git a/fixtures/wms/describelayer-wcs.xml b/fixtures/wms/describelayer-wcs.xml new file mode 100644 index 00000000..e5d62585 --- /dev/null +++ b/fixtures/wms/describelayer-wcs.xml @@ -0,0 +1,16 @@ + + + 1.1.0 + + wcs + + + imagery:ortho_coverage + + + diff --git a/fixtures/wms/describelayer-wfs.xml b/fixtures/wms/describelayer-wfs.xml new file mode 100644 index 00000000..ff880d16 --- /dev/null +++ b/fixtures/wms/describelayer-wfs.xml @@ -0,0 +1,16 @@ + + + 1.1.0 + + wfs + + + geodata:geography_vector + + + diff --git a/src/index.ts b/src/index.ts index a1b43e43..e98335d0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -18,6 +18,7 @@ export type { WmsVersion, WmsLayerSummary, WmsLayerAttribution, + WmsLayerDescription, } from './wms/model.js'; export { default as WmtsEndpoint } from './wmts/endpoint.js'; export type { diff --git a/src/wms/describelayer.spec.ts b/src/wms/describelayer.spec.ts new file mode 100644 index 00000000..8967d6fb --- /dev/null +++ b/src/wms/describelayer.spec.ts @@ -0,0 +1,45 @@ +import { parseDescribeLayerResponse } from './describelayer.js'; +// @ts-expect-error ts-migrate(7016) +import describeLayerWfs from '../../fixtures/wms/describelayer-wfs.xml'; +// @ts-expect-error ts-migrate(7016) +import describeLayerWcs from '../../fixtures/wms/describelayer-wcs.xml'; +import { parseXmlString } from '../shared/xml-utils.js'; + +describe('WMS DescribeLayer', () => { + describe('parseDescribeLayerResponse', () => { + it('parses a vector layer description (owsType WFS)', () => { + const doc = parseXmlString(describeLayerWfs); + const result = parseDescribeLayerResponse( + doc, + 'geodata:geography_vector' + ); + expect(result).toEqual({ + layerName: 'geodata:geography_vector', + owsType: 'wfs', + owsUrl: 'https://www.example.com/geoserver/wfs', + typeName: 'geodata:geography_vector', + }); + }); + + it('parses a raster layer description (owsType WCS)', () => { + const doc = parseXmlString(describeLayerWcs); + const result = parseDescribeLayerResponse(doc, 'imagery:ortho_coverage'); + expect(result).toEqual({ + layerName: 'imagery:ortho_coverage', + owsType: 'wcs', + owsUrl: 'https://www.geodata-service.org/ows/wcs', + typeName: 'imagery:ortho_coverage', + }); + }); + + it('returns null when the response contains no layer description', () => { + const emptyResponse = ` + + 1.1.0 +`; + const doc = parseXmlString(emptyResponse); + const result = parseDescribeLayerResponse(doc, 'nonexistent:layer'); + expect(result).toBeNull(); + }); + }); +}); diff --git a/src/wms/describelayer.ts b/src/wms/describelayer.ts new file mode 100644 index 00000000..c954d8ae --- /dev/null +++ b/src/wms/describelayer.ts @@ -0,0 +1,42 @@ +import { XmlDocument } from '@rgrove/parse-xml'; +import { + findChildElement, + getElementAttribute, + getElementText, + getRootElement, +} from '../shared/xml-utils.js'; +import { WmsLayerDescription } from './model.js'; + +/** + * Parses a WMS DescribeLayer response document and returns the description + * for the requested layer. + * @param describeLayerDoc The parsed XML document from a DescribeLayer response + * @param layerName The layer name to look for in the response + * @return The layer description, or null if the layer was not found in the response + */ +export function parseDescribeLayerResponse( + describeLayerDoc: XmlDocument, + layerName: string +): WmsLayerDescription | null { + const root = getRootElement(describeLayerDoc); + const match = findChildElement(root, 'LayerDescription'); + if (!match) return null; + + const owsType = getElementText( + findChildElement(match, 'owsType') + ) as WmsLayerDescription['owsType']; + const onlineResource = findChildElement(match, 'OnlineResource'); + const owsUrl = getElementAttribute(onlineResource, 'xlink:href'); + + const typeNameEl = findChildElement(match, 'TypeName'); + const typeName = + getElementText(findChildElement(typeNameEl, 'FeatureTypeName')) || + getElementText(findChildElement(typeNameEl, 'CoverageTypeName')); + + return { + layerName, + owsType, + owsUrl, + ...(typeName ? { typeName } : {}), + }; +} diff --git a/src/wms/endpoint.spec.ts b/src/wms/endpoint.spec.ts index 278cfcc4..58c8bb95 100644 --- a/src/wms/endpoint.spec.ts +++ b/src/wms/endpoint.spec.ts @@ -4,6 +4,10 @@ import capabilities130 from '../../fixtures/wms/capabilities-brgm-1-3-0.xml'; import capabilitiesStates from '../../fixtures/wms/capabilities-states-1-3-0.xml'; // @ts-expect-error ts-migrate(7016) import exceptionReportWfs from '../../fixtures/wms/service-exception-report-wfs.xml'; +// @ts-expect-error ts-migrate(7016) +import describeLayerWfs from '../../fixtures/wms/describelayer-wfs.xml'; +// @ts-expect-error ts-migrate(7016) +import describeLayerWcs from '../../fixtures/wms/describelayer-wcs.xml'; import WmsEndpoint from './endpoint.js'; import { useCache } from '../shared/cache.js'; import { EndpointError, ServiceExceptionError } from '../shared/errors.js'; @@ -414,4 +418,65 @@ describe('WmsEndpoint', () => { ); }); }); + + describe('#describeLayer', () => { + beforeEach(() => { + globalThis.fetchResponseFactory = (url) => { + if (url.indexOf('DescribeLayer') > -1) { + if (url.indexOf('imagery') > -1) return describeLayerWcs; + return describeLayerWfs; + } + return capabilities130; + }; + endpoint = new WmsEndpoint( + 'https://my.test.service/ogc/wms?service=wms&request=GetMap&aa=bb' + ); + }); + + it('returns the layer description for a vector layer', async () => { + await endpoint.isReady(); + const result = await endpoint.describeLayer('geodata:geography_vector'); + expect(result).toEqual({ + layerName: 'geodata:geography_vector', + owsType: 'wfs', + owsUrl: 'https://www.example.com/geoserver/wfs', + typeName: 'geodata:geography_vector', + }); + }); + + it('returns the layer description for a raster layer', async () => { + await endpoint.isReady(); + const result = await endpoint.describeLayer('imagery:ortho_coverage'); + expect(result).toEqual({ + layerName: 'imagery:ortho_coverage', + owsType: 'wcs', + owsUrl: 'https://www.geodata-service.org/ows/wcs', + typeName: 'imagery:ortho_coverage', + }); + }); + + it('returns null when the response contains no layer description', async () => { + const emptyDescribeLayer = ` + + 1.1.0 +`; + globalThis.fetchResponseFactory = (url) => { + if (url.indexOf('DescribeLayer') > -1) return emptyDescribeLayer; + return capabilities130; + }; + endpoint = new WmsEndpoint( + 'https://my.test.service/ogc/wms?service=wms&request=GetMap&aa=bb' + ); + await endpoint.isReady(); + const result = await endpoint.describeLayer('nonexistent:layer'); + expect(result).toBeNull(); + }); + + it('returns null when DescribeLayer is not advertised', async () => { + globalThis.fetchResponseFactory = () => capabilitiesStates; + endpoint = new WmsEndpoint('https://my.test.service/ogc/wms'); + await endpoint.isReady(); + await expect(endpoint.describeLayer('usa:states')).resolves.toBeNull(); + }); + }); }); diff --git a/src/wms/endpoint.ts b/src/wms/endpoint.ts index 203eda0a..d0b4dd8a 100644 --- a/src/wms/endpoint.ts +++ b/src/wms/endpoint.ts @@ -1,6 +1,6 @@ import { parseWmsCapabilities } from '../worker/index.js'; import { useCache } from '../shared/cache.js'; -import { setQueryParams } from '../shared/http-utils.js'; +import { queryXmlDocument, setQueryParams } from '../shared/http-utils.js'; import { BoundingBox, CrsCode, @@ -10,8 +10,14 @@ import { type OperationName, type OperationUrl, } from '../shared/models.js'; -import { WmsLayerFull, WmsLayerSummary, WmsVersion } from './model.js'; -import { generateGetMapUrl } from './url.js'; +import { + WmsLayerDescription, + WmsLayerFull, + WmsLayerSummary, + WmsVersion, +} from './model.js'; +import { generateDescribeLayerUrl, generateGetMapUrl } from './url.js'; +import { parseDescribeLayerResponse } from './describelayer.js'; /** * Represents a WMS endpoint advertising several layers arranged in a tree structure. @@ -200,6 +206,38 @@ export default class WmsEndpoint { }); } + /** + * Performs a DescribeLayer request for the given layer and returns its description, + * including the underlying OWS type (e.g. "WFS" for vector data). + * @param layerName Layer name to describe + * @return Returns null if the endpoint is not ready or does not advertise DescribeLayer + */ + describeLayer(layerName: string): Promise { + if (!this._layers) { + return Promise.resolve(null); + } + const describeLayerBaseUrl = this.getOperationUrl('DescribeLayer'); + if (!describeLayerBaseUrl) { + return Promise.resolve(null); + } + return useCache( + () => { + const url = generateDescribeLayerUrl( + describeLayerBaseUrl, + this._version, + layerName + ); + return queryXmlDocument(url).then((doc) => + parseDescribeLayerResponse(doc, layerName) + ); + }, + 'WMS', + 'DESCRIBELAYER', + this._capabilitiesUrl, + layerName + ); + } + /** * Returns the URL reported by the WMS for the given operation * @param operationName e.g. GetMap, GetCapabilities, etc. diff --git a/src/wms/model.ts b/src/wms/model.ts index 0f0f06cf..c94fcdcd 100644 --- a/src/wms/model.ts +++ b/src/wms/model.ts @@ -50,4 +50,11 @@ export type WmsLayerFull = { children?: WmsLayerFull[]; }; +export type WmsLayerDescription = { + layerName: string; + owsType: 'wcs' | 'wfs'; + owsUrl: string; + typeName?: string; +}; + export type WmsVersion = '1.1.0' | '1.1.1' | '1.3.0'; diff --git a/src/wms/url.ts b/src/wms/url.ts index 01856e69..147e8f49 100644 --- a/src/wms/url.ts +++ b/src/wms/url.ts @@ -42,3 +42,23 @@ export function generateGetMapUrl( return setQueryParams(serviceUrl, newParams); } + +/** + * Generates an URL for a DescribeLayer operation + * @param serviceUrl + * @param version + * @param layerName Layer name to describe + */ +export function generateDescribeLayerUrl( + serviceUrl: string, + version: WmsVersion, + layerName: string +): string { + return setQueryParams(serviceUrl, { + SERVICE: 'WMS', + REQUEST: 'DescribeLayer', + VERSION: version, + LAYERS: layerName, + SLD_VERSION: '1.1.0', + }); +}