diff --git a/src/index.ts b/src/index.ts index e38e10a..3a12753 100644 --- a/src/index.ts +++ b/src/index.ts @@ -45,8 +45,9 @@ export { } from './shared/http-utils.js'; export { check, - default as ServiceExceptionError, -} from './shared/service-exception-error.js'; + ServiceExceptionError, + EndpointError, +} from './shared/errors.js'; export { enableFallbackWithoutWorker } from './worker/index.js'; import './worker-fallback/index.js'; diff --git a/src/ogc-api/endpoint.ts b/src/ogc-api/endpoint.ts index 58a425e..a7efe4a 100644 --- a/src/ogc-api/endpoint.ts +++ b/src/ogc-api/endpoint.ts @@ -4,12 +4,12 @@ import { checkStyleConformance, checkTileConformance, parseBaseCollectionInfo, - parseFullStyleInfo, + parseBasicStyleInfo, parseCollectionParameters, parseCollections, parseConformance, parseEndpointInfo, - parseBasicStyleInfo, + parseFullStyleInfo, parseTileMatrixSets, } from './info.js'; import { diff --git a/src/shared/service-exception-error.spec.ts b/src/shared/errors.spec.ts similarity index 98% rename from src/shared/service-exception-error.spec.ts rename to src/shared/errors.spec.ts index e33a308..0f813c0 100644 --- a/src/shared/service-exception-error.spec.ts +++ b/src/shared/errors.spec.ts @@ -12,10 +12,7 @@ import wmsException110 from '../../fixtures/wms/service-exception-report-1-1-0.x import wmsException111 from '../../fixtures/wms/service-exception-report-1-1-1.xml'; // @ts-expect-error ts-migrate(7016) import wmsException130 from '../../fixtures/wms/service-exception-report-1-3-0.xml'; -import ServiceExceptionError, { - check, - parse, -} from './service-exception-error.js'; +import { check, parse, ServiceExceptionError } from './errors.js'; import { findChildElement, getRootElement, diff --git a/src/shared/errors.ts b/src/shared/errors.ts index 7b866c0..367359d 100644 --- a/src/shared/errors.ts +++ b/src/shared/errors.ts @@ -1,9 +1,96 @@ +import type { XmlDocument, XmlElement } from '@rgrove/parse-xml'; +import { + findChildElement, + getElementAttribute, + getElementName, + getElementText, + getRootElement, + stripNamespace, +} from '../shared/xml-utils.js'; + export class EndpointError extends Error { constructor( - public message: string, - public httpStatus?: number, - public isCrossOriginRelated?: boolean + message: string, + public readonly httpStatus?: number, + public readonly isCrossOriginRelated?: boolean ) { super(message); } } + +/** + * Representation of an Exception reported by an OWS service + * + * This is usually contained in a ServiceExceptionReport or ExceptionReport + * document and represented as a ServiceException or Exception element + */ +export class ServiceExceptionError extends Error { + /** + * Constructor + * @param message Error message + * @param requestUrl URL which resulted in the ServiceException + * @param code Optional ServiceException code + * @param locator Optional ServiceException locator + * @param response Optional response content received + */ + public constructor( + message: string, + public readonly requestUrl?: string, + public readonly code?: string, + public readonly locator?: string, + public readonly response?: XmlDocument + ) { + super(message); + } +} + +/** + * Parse a ServiceException element to a ServiceExceptionError + * @param serviceException ServiceException element + * @param url URL from which the ServiceException was generated + */ +export function parse( + serviceException: XmlElement, + url?: string +): ServiceExceptionError { + const errorCode = + getElementAttribute(serviceException, 'code') || + getElementAttribute(serviceException, 'exceptionCode'); + const errorLocator = getElementAttribute(serviceException, 'locator'); + const textElement = + findChildElement(serviceException, 'ExceptionText') || serviceException; + const errorMessage = getElementText(textElement).trim(); + return new ServiceExceptionError( + errorMessage, + url, + errorCode, + errorLocator, + serviceException.document + ); +} + +/** + * Check the response for a ServiceExceptionReport and if present throw one + * @param response Response to check + * @param url URL from which response was generated + */ +export function check(response: XmlDocument, url?: string): XmlDocument { + const rootEl = getRootElement(response); + const rootElName = stripNamespace(getElementName(rootEl)); + if (rootElName === 'ServiceExceptionReport') { + // document contains a ServiceExceptionReport, so generate an Error from + // the first ServiceException contained in it + const error = findChildElement(rootEl, 'ServiceException'); + if (error) { + throw parse(error, url); + } + } + if (rootElName === 'ExceptionReport') { + const error = findChildElement(rootEl, 'Exception'); + if (error) { + throw parse(error, url); + } + } + // there was nothing to convert to an Error so just pass the document on + return response; +} diff --git a/src/shared/http-utils.ts b/src/shared/http-utils.ts index 641b6b0..0e9ce90 100644 --- a/src/shared/http-utils.ts +++ b/src/shared/http-utils.ts @@ -101,7 +101,7 @@ export function queryXmlDocument(url: string) { ); }) ) - .then(async (resp) => { + .then(async (resp: Response) => { if (!resp.ok) { const text = await resp.text(); throw new EndpointError( diff --git a/src/shared/service-exception-error.ts b/src/shared/service-exception-error.ts deleted file mode 100644 index e02c9b8..0000000 --- a/src/shared/service-exception-error.ts +++ /dev/null @@ -1,86 +0,0 @@ -import type { XmlDocument, XmlElement } from '@rgrove/parse-xml'; -import { - findChildElement, - getElementAttribute, - getElementName, - getElementText, - getRootElement, - stripNamespace, -} from '../shared/xml-utils.js'; - -/** - * Representation of an Exception reported by an OWS service - * - * This is usually contained in a ServiceExceptionReport or ExceptionReport - * document and represented as a ServiceException or Exception element - */ -export default class ServiceExceptionError extends Error { - /** - * Constructor - * @param message Error message - * @param requestUrl URL which resulted in the ServiceException - * @param code Optional ServiceException code - * @param locator Optional ServiceException locator - * @param response Optional response content received - */ - public constructor( - message: string, - public readonly requestUrl?: string, - public readonly code?: string, - public readonly locator?: string, - public readonly response?: XmlDocument - ) { - super(message); - } -} - -/** - * Parse a ServiceException element to a ServiceExceptionError - * @param serviceException ServiceException element - * @param url URL from which the ServiceException was generated - */ -export function parse( - serviceException: XmlElement, - url?: string -): ServiceExceptionError { - const errorCode = - getElementAttribute(serviceException, 'code') || - getElementAttribute(serviceException, 'exceptionCode'); - const errorLocator = getElementAttribute(serviceException, 'locator'); - const textElement = - findChildElement(serviceException, 'ExceptionText') || serviceException; - const errorMessage = getElementText(textElement).trim(); - return new ServiceExceptionError( - errorMessage, - url, - errorCode, - errorLocator, - serviceException.document - ); -} - -/** - * Check the response for a ServiceExceptionReport and if present throw one - * @param response Response to check - * @param url URL from which response was generated - */ -export function check(response: XmlDocument, url?: string): XmlDocument { - const rootEl = getRootElement(response); - const rootElName = stripNamespace(getElementName(rootEl)); - if (rootElName === 'ServiceExceptionReport') { - // document contains a ServiceExceptionReport, so generate an Error from - // the first ServiceException contained in it - const error = findChildElement(rootEl, 'ServiceException'); - if (error) { - throw parse(error, url); - } - } - if (rootElName === 'ExceptionReport') { - const error = findChildElement(rootEl, 'Exception'); - if (error) { - throw parse(error, url); - } - } - // there was nothing to convert to an Error so just pass the document on - return response; -} diff --git a/src/wfs/endpoint.spec.ts b/src/wfs/endpoint.spec.ts index 9806fb8..73e20d3 100644 --- a/src/wfs/endpoint.spec.ts +++ b/src/wfs/endpoint.spec.ts @@ -12,6 +12,7 @@ import capabilitiesStates from '../../fixtures/wfs/capabilities-states-2-0-0.xml import exceptionReportWms from '../../fixtures/wfs/exception-report-wms.xml'; import WfsEndpoint from './endpoint.js'; import { useCache } from '../shared/cache.js'; +import { EndpointError, ServiceExceptionError } from '../shared/errors.js'; jest.mock('../shared/cache', () => ({ useCache: jest.fn((factory) => factory()), @@ -28,6 +29,7 @@ describe('WfsEndpoint', () => { }); beforeEach(() => { + global.fetchPreHandler = () => {}; global.fetchResponseFactory = (url) => { if (url.indexOf('GetCapabilities') > -1) return capabilities200; if (url.indexOf('GetFeature') > -1) { @@ -74,15 +76,88 @@ describe('WfsEndpoint', () => { await expect(endpoint.isReady()).resolves.toEqual(endpoint); }); + describe('CORS error handling', () => { + beforeEach(() => { + global.fetchPreHandler = (url, options) => { + if (options?.method === 'HEAD') return 'ok!'; + throw new Error('CORS problem'); + }; + endpoint = new WfsEndpoint('https://my.test.service/ogc/wfs'); + }); + it('rejects with a relevant error', async () => { + const error = (await endpoint + .isReady() + .catch((e) => e)) as EndpointError; + expect(error.constructor.name).toBe('EndpointError'); + expect(error.message).toBe( + 'The document could not be fetched due to CORS limitations' + ); + expect(error.httpStatus).toBe(0); + expect(error.isCrossOriginRelated).toBe(true); + }); + }); + + describe('endpoint error handling', () => { + beforeEach(() => { + global.fetchPreHandler = () => { + throw new TypeError('other kind of problem'); + }; + endpoint = new WfsEndpoint('https://my.test.service/ogc/wfs'); + }); + it('rejects with a relevant error', async () => { + const error = (await endpoint + .isReady() + .catch((e) => e)) as EndpointError; + expect(error.constructor.name).toBe('EndpointError'); + expect(error.message).toBe( + 'Fetching the document failed either due to network errors or unreachable host, error is: other kind of problem' + ); + expect(error.httpStatus).toBe(0); + expect(error.isCrossOriginRelated).toBe(false); + }); + }); + + describe('http error handling', () => { + beforeEach(() => { + global.fetchPreHandler = () => ({ + ok: false, + text: () => Promise.resolve('something broke in the server'), + status: 500, + statusText: 'Internal Server Error', + }); + endpoint = new WfsEndpoint('https://my.test.service/ogc/wfs'); + }); + it('rejects with a relevant error', async () => { + const error = (await endpoint + .isReady() + .catch((e) => e)) as EndpointError; + expect(error.constructor.name).toBe('EndpointError'); + expect(error.message).toBe( + 'Received an error with code 500: something broke in the server' + ); + expect(error.httpStatus).toBe(500); + expect(error.isCrossOriginRelated).toBe(false); + }); + }); + describe('service exception handling', () => { beforeEach(() => { global.fetchResponseFactory = () => exceptionReportWms; - endpoint = new WfsEndpoint('https://my.test.service/ogc/wms'); + endpoint = new WfsEndpoint('https://my.test.service/ogc/wfs'); }); it('rejects when the endpoint returns an exception report', async () => { - await expect(endpoint.isReady()).rejects.toThrow( + const error = (await endpoint + .isReady() + .catch((e) => e)) as ServiceExceptionError; + expect(error.constructor.name).toBe('ServiceExceptionError'); + expect(error.message).toBe( 'msWFSDispatch(): WFS server error. WFS request not enabled. Check wfs/ows_enable_request settings.' ); + expect(error.requestUrl).toBe( + 'https://my.test.service/ogc/wfs?SERVICE=WFS&REQUEST=GetCapabilities' + ); + expect(error.code).toBe('InvalidParameterValue'); + expect(error.locator).toBe('request'); }); }); }); diff --git a/src/wms/endpoint.spec.ts b/src/wms/endpoint.spec.ts index bf70ec4..2adcb93 100644 --- a/src/wms/endpoint.spec.ts +++ b/src/wms/endpoint.spec.ts @@ -6,6 +6,7 @@ import capabilitiesStates from '../../fixtures/wms/capabilities-states-1-3-0.xml import exceptionReportWfs from '../../fixtures/wms/service-exception-report-wfs.xml'; import WmsEndpoint from './endpoint.js'; import { useCache } from '../shared/cache.js'; +import { EndpointError, ServiceExceptionError } from '../shared/errors.js'; jest.mock('../shared/cache', () => ({ useCache: jest.fn((factory) => factory()), @@ -18,6 +19,7 @@ describe('WmsEndpoint', () => { beforeEach(() => { jest.clearAllMocks(); + global.fetchPreHandler = () => {}; global.fetchResponseFactory = () => capabilities130; endpoint = new WmsEndpoint( 'https://my.test.service/ogc/wms?service=wms&request=GetMap&aa=bb' @@ -54,15 +56,89 @@ describe('WmsEndpoint', () => { it('resolves with the endpoint object', async () => { await expect(endpoint.isReady()).resolves.toEqual(endpoint); }); + + describe('CORS error handling', () => { + beforeEach(() => { + global.fetchPreHandler = (url, options) => { + if (options?.method === 'HEAD') return 'ok!'; + throw new Error('CORS problem'); + }; + endpoint = new WmsEndpoint('https://my.test.service/ogc/wms'); + }); + it('rejects with a relevant error', async () => { + const error = (await endpoint + .isReady() + .catch((e) => e)) as EndpointError; + expect(error.constructor.name).toBe('EndpointError'); + expect(error.message).toBe( + 'The document could not be fetched due to CORS limitations' + ); + expect(error.httpStatus).toBe(0); + expect(error.isCrossOriginRelated).toBe(true); + }); + }); + + describe('endpoint error handling', () => { + beforeEach(() => { + global.fetchPreHandler = () => { + throw new TypeError('other kind of problem'); + }; + endpoint = new WmsEndpoint('https://my.test.service/ogc/wms'); + }); + it('rejects with a relevant error', async () => { + const error = (await endpoint + .isReady() + .catch((e) => e)) as EndpointError; + expect(error.constructor.name).toBe('EndpointError'); + expect(error.message).toBe( + 'Fetching the document failed either due to network errors or unreachable host, error is: other kind of problem' + ); + expect(error.httpStatus).toBe(0); + expect(error.isCrossOriginRelated).toBe(false); + }); + }); + + describe('http error handling', () => { + beforeEach(() => { + global.fetchPreHandler = () => ({ + ok: false, + text: () => Promise.resolve('something broke in the server'), + status: 500, + statusText: 'Internal Server Error', + }); + endpoint = new WmsEndpoint('https://my.test.service/ogc/wms'); + }); + it('rejects with a relevant error', async () => { + const error = (await endpoint + .isReady() + .catch((e) => e)) as EndpointError; + expect(error.constructor.name).toBe('EndpointError'); + expect(error.message).toBe( + 'Received an error with code 500: something broke in the server' + ); + expect(error.httpStatus).toBe(500); + expect(error.isCrossOriginRelated).toBe(false); + }); + }); + describe('service exception handling', () => { beforeEach(() => { global.fetchResponseFactory = () => exceptionReportWfs; - endpoint = new WmsEndpoint('https://my.test.service/ogc/wfs'); + endpoint = new WmsEndpoint('https://my.test.service/ogc/wms'); }); it('rejects when the endpoint returns an exception report', async () => { - await expect(endpoint.isReady()).rejects.toThrow( + const error = (await endpoint + .isReady() + .catch((e) => e)) as ServiceExceptionError; + expect(error.constructor.name).toBe('ServiceExceptionError'); + expect(error.message).toBe( 'msWMSGetCapabilities(): WMS server error. WMS request not enabled. Check wms/ows_enable_request settings.' ); + expect(error.requestUrl).toBe( + 'https://my.test.service/ogc/wms?SERVICE=WMS&REQUEST=GetCapabilities' + ); + expect(error.code).toBe(''); + expect(error.locator).toBe(''); }); }); }); diff --git a/src/worker/worker.ts b/src/worker/worker.ts index a34cd0e..ee3a392 100644 --- a/src/worker/worker.ts +++ b/src/worker/worker.ts @@ -1,6 +1,6 @@ import { addTaskHandler } from './utils.js'; import { queryXmlDocument, setFetchOptions } from '../shared/http-utils.js'; -import { check } from '../shared/service-exception-error.js'; +import { check } from '../shared/errors.js'; import * as wmsCapabilities from '../wms/capabilities.js'; import * as wfsCapabilities from '../wfs/capabilities.js'; import * as wmtsCapabilities from '../wmts/capabilities.js'; diff --git a/test-setup.ts b/test-setup.ts index 0a388f6..4948ef6 100644 --- a/test-setup.ts +++ b/test-setup.ts @@ -2,24 +2,29 @@ import 'regenerator-runtime/runtime'; import mitt from 'mitt'; import * as util from 'util'; +import { TextDecoder } from 'util'; import CacheMock from 'browser-cache-mock'; import 'isomorphic-fetch'; -import { TextDecoder } from 'util'; import { Buffer } from './node_modules/buffer/index.js'; globalThis.Buffer = Buffer; // mock the global fetch API -window.fetchResponseFactory = (url) => ''; +window.fetchPreHandler = (url, options) => {}; +window.fetchResponseFactory = (url, options) => ''; window.originalFetch = window.fetch; -window.mockFetch = jest.fn().mockImplementation((url) => - Promise.resolve({ - text: () => Promise.resolve(globalThis.fetchResponseFactory(url)), +window.mockFetch = jest.fn().mockImplementation(async (url, options) => { + const preResult = await window.fetchPreHandler(url, options); + if (preResult) return preResult; + return { + text: () => Promise.resolve(globalThis.fetchResponseFactory(url, options)), json: () => - Promise.resolve(JSON.parse(globalThis.fetchResponseFactory(url))), + Promise.resolve( + JSON.parse(globalThis.fetchResponseFactory(url, options)) + ), arrayBuffer: () => Promise.resolve( - Buffer.from(globalThis.fetchResponseFactory(url), 'utf-8') + Buffer.from(globalThis.fetchResponseFactory(url, options), 'utf-8') ), clone: function () { return this; @@ -27,8 +32,8 @@ window.mockFetch = jest.fn().mockImplementation((url) => status: 200, ok: true, headers: { get: () => null }, - }) -); + }; +}); window.fetch = window.mockFetch; // reset fetch response to XML by default