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',
+ });
+}