From 7979850522c777a8871630adcc7617cf6677043a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dafydd=20Ll=C5=B7r=20Pearson?= Date: Wed, 21 Dec 2022 10:33:51 +0000 Subject: [PATCH] feat: Hide OS API key by allowing proxy endpoint (#241) --- docs/how-to-use-a-proxy.md | 97 ++++++++++++++++ index.html | 2 +- .../address-autocomplete.doc.js | 26 ++++- src/components/address-autocomplete/index.ts | 24 +++- .../address-autocomplete/main.test.ts | 33 ++++++ .../my-map/docs/my-map-basic.doc.js | 5 + src/components/my-map/docs/my-map-draw.doc.js | 5 + .../my-map/docs/my-map-features.doc.js | 5 + .../my-map/docs/my-map-geojson.doc.js | 5 + .../my-map/docs/my-map-proxy.doc.js | 104 ++++++++++++++++++ src/components/my-map/index.ts | 44 +++++--- src/components/my-map/main.test.ts | 8 +- src/components/my-map/os-features.ts | 32 +++--- src/components/my-map/os-layers.test.ts | 83 ++++++++++++++ src/components/my-map/os-layers.ts | 95 +++++++++++----- src/lib/ordnanceSurvey.test.ts | 85 ++++++++++++++ src/lib/ordnanceSurvey.ts | 85 ++++++++++++++ src/test-utils.ts | 7 ++ 18 files changed, 672 insertions(+), 73 deletions(-) create mode 100644 docs/how-to-use-a-proxy.md create mode 100644 src/components/my-map/docs/my-map-proxy.doc.js create mode 100644 src/components/my-map/os-layers.test.ts create mode 100644 src/lib/ordnanceSurvey.test.ts create mode 100644 src/lib/ordnanceSurvey.ts diff --git a/docs/how-to-use-a-proxy.md b/docs/how-to-use-a-proxy.md new file mode 100644 index 0000000..31d7ecd --- /dev/null +++ b/docs/how-to-use-a-proxy.md @@ -0,0 +1,97 @@ +# How to: Use a MyMap & AddressAutocomplete with a proxy + +## Context +Both `MyMap` and `AddressAutocomplete` can call the Ordnance Survey API directly, or via a proxy. + +Calling the API directly may be suitable for internal use, where exposure of API keys is not a concern, whilst calling a proxy may be more suitable for public use. + +A proxy endpoint can be supplied via the `osProxyEndpoint` property on these components. + +Proxies are required to complete the following actions in order to work successfully - + +- Append a valid OS API key as a search parameter to incoming requests +- Modify outgoing response with suitable CORS / CORP headers to allow the originating site access to the returned assets + +## Diagram +```mermaid +sequenceDiagram + autonumber + participant WC as Web Component + participant P as Proxy + participant OS as Ordnance Survey API + + WC ->>+ P: Request + P -->> P: Validate Request + P ->>+ OS: Request + API key + OS ->>- P: Response + P ->>- WC: Response + CORP/CORS headers +``` + +## Examples +Please see the sample code below for how a proxy could be implemented - + +### Express +Below is an annotated example of a simple proxy using [Express](https://github.com/expressjs/express) & [http-proxy-middleware](https://github.com/chimurai/http-proxy-middleware). + +**index.js** +```js +import express from "express"; +import { useOrdnanceSurveyProxy } from "proxy"; + +const app = express() +const port = 3000 + +app.use('/proxy/ordnance-survey', useOrdnanceSurveyProxy) + +app.listen(port) +``` + +**proxy.js** +```js +import { createProxyMiddleware } from "http-proxy-middleware"; + +const OS_DOMAIN = "https://api.os.uk"; + +export const useOrdnanceSurveyProxy = async (req, res, next) => { + if (!isValid(req)) return next({ + status: 401, + message: "Unauthorised" + }) + + return createProxyMiddleware({ + target: OS_DOMAIN, + changeOrigin: true, + onProxyRes: (proxyRes) => setCORPHeaders(proxyRes), + pathRewrite: (fullPath, req) => appendAPIKey(fullPath, req) + onError: (_err, _req, res) => { + res.json({ + status: 500, + message: "Something went wrong", + }); + }, + })(req, res, next); +}; + +const isValid = (req) => { + // Your validation logic here, for example checking req.header.referer against an allowlist of domains +} + +// Ensure that returned tiles can be embedded cross-site +// May not be required if "same-site" policy works for your setup +const setCORPHeaders = (proxyRes: IncomingMessage): void => { + proxyRes.headers["Cross-Origin-Resource-Policy"] = "cross-origin" +} + +export const appendAPIKey = (fullPath, req) => { + const [path, params] = fullPath.split("?"); + // Append API key + const updatedParams = new URLSearchParams(params); + updatedParams.set("key", process.env.ORDNANCE_SURVEY_API_KEY); + // Remove our API baseUrl (/proxy/ordnance-survey) + const updatedPath = path.replace(req.baseUrl, ""); + // Construct and return rewritten path + const resultPath = [updatedPath, updatedParams.toString()].join("?"); + return resultPath; +}; +``` +> A working and more fleshed out example (in TypeScript) can be seen [here in the PlanX API](https://github.com/theopensystemslab/planx-new/blob/production/api.planx.uk/proxy/ordnanceSurvey.ts). \ No newline at end of file diff --git a/index.html b/index.html index d2a91be..153c15b 100644 --- a/index.html +++ b/index.html @@ -43,7 +43,7 @@

*** This is a testing sandbox - these components are unaware of each other! ***

- +
diff --git a/src/components/address-autocomplete/address-autocomplete.doc.js b/src/components/address-autocomplete/address-autocomplete.doc.js index aa99975..cc6ba23 100644 --- a/src/components/address-autocomplete/address-autocomplete.doc.js +++ b/src/components/address-autocomplete/address-autocomplete.doc.js @@ -1,7 +1,7 @@ module.exports = { name: "AddressAutocomplete", description: - "AddressAutocomplete is a Lit wrapper for the Gov.UK accessible-autocomplete component that fetches & displays addresses in a given postcode using the Ordnance Survey Places API.", + "AddressAutocomplete is a Lit wrapper for the Gov.UK accessible-autocomplete component that fetches & displays addresses in a given postcode using the Ordnance Survey Places API. The Ordnance Survey API can be called directly, or via a proxy. Calling the API directly may be suitable for internal use, where exposure of API keys is not a concern, whilst calling a proxy may be more suitable for public use. Any proxy supplied via the osProxyEndpoint property must append a valid Ordnance Survey API key to all requests. For full implementation details, please see https://github.com/theopensystemslab/map/blob/main/docs/how-to-use-a-proxy.md", properties: [ { name: "postcode", @@ -40,6 +40,11 @@ module.exports = { values: "https://osdatahub.os.uk/plans", required: true, }, + { + name: "osProxyEndpoint", + type: "String", + values: "https://api.editor.planx.dev/proxy/ordnance-survey", + }, ], methods: [ { @@ -79,6 +84,25 @@ module.exports = { console.debug("autocomplete ready", { data }); }); + autocomplete.addEventListener( + "addressSelection", + ({ detail: address }) => { + console.debug({ detail: address }); + } + ); + }, + }, + { + title: "Select an address in postcode SE19 1NT", + description: "Standard case (via proxy)", + template: ``, + controller: function (document) { + const autocomplete = document.querySelector("address-autocomplete"); + + autocomplete.addEventListener("ready", ({ detail: data }) => { + console.debug("autocomplete ready", { data }); + }); + autocomplete.addEventListener( "addressSelection", ({ detail: address }) => { diff --git a/src/components/address-autocomplete/index.ts b/src/components/address-autocomplete/index.ts index 1eeb920..13969cd 100644 --- a/src/components/address-autocomplete/index.ts +++ b/src/components/address-autocomplete/index.ts @@ -3,6 +3,7 @@ import { customElement, property, state } from "lit/decorators.js"; import accessibleAutocomplete from "accessible-autocomplete"; import styles from "./styles.scss"; +import { getServiceURL } from "../../lib/ordnanceSurvey"; // https://apidocs.os.uk/docs/os-places-lpi-output type Address = { @@ -33,6 +34,9 @@ export class AddressAutocomplete extends LitElement { @property({ type: String }) osPlacesApiKey = import.meta.env.VITE_APP_OS_PLACES_API_KEY || ""; + @property({ type: String }) + osProxyEndpoint = ""; + @property({ type: String }) arrowStyle: ArrowStyleEnum = "default"; @@ -98,6 +102,10 @@ export class AddressAutocomplete extends LitElement { } async _fetchData(offset: number = 0, prevResults: Address[] = []) { + const isUsingOS = Boolean(this.osPlacesApiKey || this.osProxyEndpoint); + if (!isUsingOS) + throw Error("OS Places API key or OS proxy endpoint not found"); + // https://apidocs.os.uk/docs/os-places-service-metadata const params: Record = { postcode: this.postcode, @@ -105,13 +113,16 @@ export class AddressAutocomplete extends LitElement { maxResults: "100", output_srs: "EPSG:4326", lr: "EN", - key: this.osPlacesApiKey, + offset: offset.toString(), }; - const url = `https://api.os.uk/search/places/v1/postcode?${new URLSearchParams( - params - )}`; + const url = getServiceURL({ + service: "places", + apiKey: this.osPlacesApiKey, + proxyEndpoint: this.osProxyEndpoint, + params, + }); - await fetch(url + `&offset=${offset}`) + await fetch(url) .then((resp) => resp.json()) .then((data) => { // handle error formats returned by OS @@ -224,7 +235,8 @@ export class AddressAutocomplete extends LitElement { render() { // handle various error states let errorMessage; - if (!this.osPlacesApiKey) errorMessage = "Missing OS Places API key"; + if (!this.osPlacesApiKey && !this.osProxyEndpoint) + errorMessage = "Missing OS Places API key or proxy endpoint"; else if (this._osError) errorMessage = this._osError; else if (this._totalAddresses === 0) errorMessage = `No addresses found in postcode ${this.postcode}`; diff --git a/src/components/address-autocomplete/main.test.ts b/src/components/address-autocomplete/main.test.ts index 26e7656..58213c9 100644 --- a/src/components/address-autocomplete/main.test.ts +++ b/src/components/address-autocomplete/main.test.ts @@ -80,3 +80,36 @@ describe("AddressAutocomplete on initial render with empty postcode", async () = ); }); }); + +describe("External API calls", async () => { + const fetchSpy = vi.spyOn(window, "fetch").mockResolvedValue({ + json: async () => ({ header: {}, results: [] }), + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it("calls proxy when 'osProxyEndpoint' provided", async () => { + document.body.innerHTML = ``; + await window.happyDOM.whenAsyncComplete(); + + expect(fetchSpy).toHaveBeenCalled(); + expect(fetchSpy.mock.lastCall?.[0]).toContain( + "https://www.my-site.com/api/v1/os/search/places/v1/postcode?postcode=SE5+0HU" + ); + expect(fetchSpy.mock.lastCall?.[0]).not.toContain("&key="); + }); + + it("calls OS API when 'osPlacesApiKey' provided", async () => { + const mockAPIKey = "test-test-test"; + document.body.innerHTML = ``; + await window.happyDOM.whenAsyncComplete(); + + expect(fetchSpy).toHaveBeenCalled(); + expect(fetchSpy.mock.lastCall?.[0]).toContain( + "https://api.os.uk/search/places/v1/postcode?postcode=SE5+0HU" + ); + expect(fetchSpy.mock.lastCall?.[0]).toContain(`&key=${mockAPIKey}`); + }); +}); diff --git a/src/components/my-map/docs/my-map-basic.doc.js b/src/components/my-map/docs/my-map-basic.doc.js index 9a652f2..4e5d925 100644 --- a/src/components/my-map/docs/my-map-basic.doc.js +++ b/src/components/my-map/docs/my-map-basic.doc.js @@ -81,6 +81,11 @@ module.exports = { type: "String", values: "https://osdatahub.os.uk/plans", }, + { + name: "osProxyEndpoint", + type: "String", + values: "https://api.editor.planx.dev/proxy/ordnance-survey", + }, ], examples: [ { diff --git a/src/components/my-map/docs/my-map-draw.doc.js b/src/components/my-map/docs/my-map-draw.doc.js index 9aa55d3..d285e00 100644 --- a/src/components/my-map/docs/my-map-draw.doc.js +++ b/src/components/my-map/docs/my-map-draw.doc.js @@ -38,6 +38,11 @@ module.exports = { type: "String", values: "https://osdatahub.os.uk/plans", }, + { + name: "osProxyEndpoint", + type: "String", + values: "https://api.editor.planx.dev/proxy/ordnance-survey", + }, ], examples: [ { diff --git a/src/components/my-map/docs/my-map-features.doc.js b/src/components/my-map/docs/my-map-features.doc.js index e7bcc26..78ebccb 100644 --- a/src/components/my-map/docs/my-map-features.doc.js +++ b/src/components/my-map/docs/my-map-features.doc.js @@ -38,6 +38,11 @@ module.exports = { type: "String", values: "https://osdatahub.os.uk/plans", }, + { + name: "osProxyEndpoint", + type: "String", + values: "https://api.editor.planx.dev/proxy/ordnance-survey", + }, ], examples: [ { diff --git a/src/components/my-map/docs/my-map-geojson.doc.js b/src/components/my-map/docs/my-map-geojson.doc.js index c3dcc36..b6a5cb0 100644 --- a/src/components/my-map/docs/my-map-geojson.doc.js +++ b/src/components/my-map/docs/my-map-geojson.doc.js @@ -29,6 +29,11 @@ module.exports = { type: "Number", values: "12", }, + { + name: "osProxyEndpoint", + type: "String", + values: "https://api.editor.planx.dev/proxy/ordnance-survey", + }, ], examples: [ { diff --git a/src/components/my-map/docs/my-map-proxy.doc.js b/src/components/my-map/docs/my-map-proxy.doc.js new file mode 100644 index 0000000..23c2dc6 --- /dev/null +++ b/src/components/my-map/docs/my-map-proxy.doc.js @@ -0,0 +1,104 @@ +module.exports = { + name: "MyMap - Proxy", + description: + "The MyMap component can either call the Ordnance Survey API directly, or via a proxy. Calling the API directly may be suitable for internal use, where exposure of API keys is not a concern, whilst calling a proxy may be more suitable for public use. Any proxy supplied via the osProxyEndpoint property must append a valid Ordnance Survey API key to all requests. For full implementation details, please see https://github.com/theopensystemslab/map/blob/main/docs/how-to-use-a-proxy.md", + properties: [ + { + name: "latitude", + type: "Number", + values: "51.507351 (default)", + required: true, + }, + { + name: "longitude", + type: "Number", + values: "-0.127758 (default)", + required: true, + }, + { + name: "projection", + type: "String", + values: "EPSG:4326 (default), EPSG:27700, EPSG:3857", + }, + { + name: "zoom", + type: "Number", + values: "10 (default)", + required: true, + }, + { + name: "minZoom", + type: "Number", + values: "7 (default)", + }, + { + name: "maxZoom", + type: "Number", + values: "22 (default)", + }, + { + name: "hideResetControl", + type: "Boolean", + values: "false (default)", + }, + { + name: "staticMode", + type: "Boolean", + values: "false (default)", + }, + { + name: "showScale", + type: "Boolean", + values: "false (default)", + }, + { + name: "useScaleBarStyle", + type: "Boolean", + values: "false (default)", + }, + { + name: "id", + type: "String", + values: "map (default)", + }, + { + name: "ariaLabel", + type: "String", + values: "An interactive map (default)", + }, + { + name: "disableVectorTiles", + type: "Boolean", + values: "false (default)", + }, + { + name: "osCopyright", + type: "String", + values: `© Crown copyright and database rights ${new Date().getFullYear()} OS (0)100024857 (default)`, + }, + { + name: "osVectorTileApiKey", + type: "String", + values: "https://osdatahub.os.uk/plans", + }, + { + name: "osProxyEndpoint", + type: "String", + values: "https://api.editor.planx.dev/proxy/ordnance-survey", + }, + ], + examples: [ + { + title: "Basemap: Ordnance Survey vector tiles (proxied)", + description: + "Calls the Ordnance Survey Vector Tiles API via the supplied proxy endpoint. The proxy must append a valid Ordnance Survey API key to each request.", + template: ``, + }, + { + title: "Basemap: Ordnance Survey raster tiles (proxied)", + description: + "Calls the Ordnance Survey Maps API via the supplied proxy endpoint. The proxy must append a valid Ordnance Survey API key to each request.", + template: ``, + }, + ], +}; diff --git a/src/components/my-map/index.ts b/src/components/my-map/index.ts index ae241ae..e1155a2 100644 --- a/src/components/my-map/index.ts +++ b/src/components/my-map/index.ts @@ -150,6 +150,9 @@ export class MyMap extends LitElement { @property({ type: String }) osCopyright = `© Crown copyright and database rights ${new Date().getFullYear()} OS (0)100024857`; + @property({ type: String }) + osProxyEndpoint = ""; + @property({ type: Boolean }) hideResetControl = false; @@ -183,18 +186,20 @@ export class MyMap extends LitElement { firstUpdated() { const target = this.renderRoot.querySelector(`#${this.id}`) as HTMLElement; - const useVectorTiles = - !this.disableVectorTiles && Boolean(this.osVectorTilesApiKey); - const rasterBaseMap = makeRasterBaseMap( - this.osCopyright, - this.osVectorTilesApiKey + this.osVectorTilesApiKey, + this.osProxyEndpoint, + this.osCopyright ); const osVectorTileBaseMap = makeOsVectorTileBaseMap( - this.osCopyright, - this.osVectorTilesApiKey + this.osVectorTilesApiKey, + this.osProxyEndpoint, + this.osCopyright ); + const useVectorTiles = + !this.disableVectorTiles && Boolean(osVectorTileBaseMap); + // @ts-ignore const projection: ProjectionLike = this.projection === "EPSG:27700" && Boolean(proj27700) @@ -208,7 +213,7 @@ export class MyMap extends LitElement { const map = new Map({ target, - layers: [useVectorTiles ? osVectorTileBaseMap : rasterBaseMap], + layers: [useVectorTiles ? osVectorTileBaseMap! : rasterBaseMap], view: new View({ projection: "EPSG:3857", extent: transformExtent( @@ -410,8 +415,8 @@ export class MyMap extends LitElement { if ( this.drawMode && this.drawType === "Polygon" && - Boolean(this.osVectorTilesApiKey) && - !this.disableVectorTiles + useVectorTiles && + osVectorTileBaseMap ) { // define zoom threshold for showing snaps (not @property yet because computationally expensive!) const snapsZoom: number = 20; @@ -443,12 +448,25 @@ export class MyMap extends LitElement { } // OS Features API & click-to-select interactions - if (this.showFeaturesAtPoint && Boolean(this.osFeaturesApiKey)) { - getFeaturesAtPoint(centerCoordinate, this.osFeaturesApiKey, false); + const isUsingOSFeaturesAPI = + this.showFeaturesAtPoint && + Boolean(this.osFeaturesApiKey || this.osProxyEndpoint); + if (isUsingOSFeaturesAPI) { + getFeaturesAtPoint( + centerCoordinate, + this.osFeaturesApiKey, + this.osProxyEndpoint, + false + ); if (this.clickFeatures) { map.on("singleclick", (e) => { - getFeaturesAtPoint(e.coordinate, this.osFeaturesApiKey, true); + getFeaturesAtPoint( + e.coordinate, + this.osFeaturesApiKey, + this.osProxyEndpoint, + true + ); }); } diff --git a/src/components/my-map/main.test.ts b/src/components/my-map/main.test.ts index 1491a3d..76e7507 100644 --- a/src/components/my-map/main.test.ts +++ b/src/components/my-map/main.test.ts @@ -6,7 +6,7 @@ import Point from "ol/geom/Point"; import VectorSource from "ol/source/Vector"; import waitForExpect from "wait-for-expect"; -import { getShadowRoot } from "../../test-utils"; +import { getShadowRoot, setupMap } from "../../test-utils"; import * as snapping from "./snapping"; import "./index"; @@ -14,12 +14,6 @@ declare global { interface Window extends IWindow {} } -const setupMap = async (mapElement: any) => { - document.body.innerHTML = mapElement; - await window.happyDOM.whenAsyncComplete(); - window.olMap?.dispatchEvent("loadend"); -}; - test("olMap is added to the global window for tests", async () => { await setupMap(``); expect(window.olMap).toBeTruthy(); diff --git a/src/components/my-map/os-features.ts b/src/components/my-map/os-features.ts index cc8a68d..e7cf8bf 100644 --- a/src/components/my-map/os-features.ts +++ b/src/components/my-map/os-features.ts @@ -4,11 +4,10 @@ import { Vector as VectorLayer } from "ol/layer"; import { toLonLat } from "ol/proj"; import { Vector as VectorSource } from "ol/source"; import { Fill, Stroke, Style } from "ol/style"; +import { getServiceURL } from "../../lib/ordnanceSurvey"; import { hexToRgba } from "./utils"; -const featureServiceUrl = "https://api.os.uk/features/v1/wfs"; - const featureSource = new VectorSource(); export const outlineSource = new VectorSource(); @@ -39,11 +38,13 @@ export function makeFeatureLayer( * features containing the coordinates of the provided point * @param coord - xy coordinate * @param apiKey - Ordnance Survey Features API key, sign up here: https://osdatahub.os.uk/plans + * @param proxyEndpoint - Endpoint to proxy all requests to Ordnance Survey * @param supportClickFeatures - whether the featureSource should support `clickFeatures` mode or be cleared upfront */ export function getFeaturesAtPoint( coord: Array, apiKey: any, + proxyEndpoint: string, supportClickFeatures: boolean ) { const xml = ` @@ -60,8 +61,7 @@ export function getFeaturesAtPoint( `; // Define (WFS) parameters object - const wfsParams = { - key: apiKey, + const params = { service: "WFS", request: "GetFeature", version: "2.0.0", @@ -70,12 +70,19 @@ export function getFeaturesAtPoint( outputFormat: "GEOJSON", srsName: "urn:ogc:def:crs:EPSG::4326", filter: xml, - count: 1, + count: "1", }; + const url = getServiceURL({ + service: "features", + apiKey, + proxyEndpoint, + params, + }); + // Use fetch() method to request GeoJSON data from the OS Features API // If successful, replace everything in the vector layer with the GeoJSON response - fetch(getUrl(wfsParams)) + fetch(url) .then((response) => response.json()) .then((data) => { if (!data.features.length) return; @@ -130,16 +137,3 @@ export function getFeaturesAtPoint( }) .catch((error) => console.log(error)); } - -/** - * Helper function to return OS Features URL with encoded parameters - * @param {object} params - parameters object to be encoded - * @returns {string} - */ -function getUrl(params: any) { - const encodedParameters = Object.keys(params) - .map((paramName) => paramName + "=" + encodeURI(params[paramName])) - .join("&"); - - return `${featureServiceUrl}?${encodedParameters}`; -} diff --git a/src/components/my-map/os-layers.test.ts b/src/components/my-map/os-layers.test.ts new file mode 100644 index 0000000..f727a64 --- /dev/null +++ b/src/components/my-map/os-layers.test.ts @@ -0,0 +1,83 @@ +import "./index"; +import { setupMap } from "../../test-utils"; + +import { OSM } from "ol/source"; +import VectorTileSource from "ol/source/VectorTile"; +import type { IWindow } from "happy-dom"; + +declare global { + interface Window extends IWindow {} +} + +declare global { + interface Window extends IWindow {} +} + +describe("OS Layer loading", () => { + afterEach(() => { + vi.resetAllMocks(); + }); + + it("requests layers directly from OS when an API key is provided", async () => { + const apiKey = process.env.VITE_APP_OS_VECTOR_TILES_API_KEY; + await setupMap(` + `); + const vectorBaseMap = window.olMap + ?.getAllLayers() + .find((layer) => layer.get("name") === "vectorBaseMap"); + expect(vectorBaseMap).toBeDefined(); + const source = vectorBaseMap?.getSource() as VectorTileSource; + expect(source.getUrls()).toHaveLength(1); + expect(source.getUrls()?.[0]).toEqual( + expect.stringMatching(/^https:\/\/api.os.uk/) + ); + expect(source.getUrls()?.[0]).toEqual( + expect.stringContaining(`key=${apiKey}`) + ); + }); + + it("requests layers via proxy when an API key is not provided", async () => { + const fetchSpy = vi.spyOn(window, "fetch").mockResolvedValue({ + json: async () => ({ version: 8, layers: [] }), + }); + + const osProxyEndpoint = "https://www.my-site.com/api/v1/os"; + await setupMap(` + `); + const vectorBaseMap = window.olMap + ?.getAllLayers() + .find((layer) => layer.get("name") === "vectorBaseMap"); + expect(vectorBaseMap).toBeDefined(); + const source = vectorBaseMap?.getSource() as VectorTileSource; + + // Tiles are being requested via proxy + expect(source.getUrls()).toHaveLength(1); + expect(source.getUrls()?.[0]).toEqual( + expect.stringContaining(osProxyEndpoint) + ); + // Style is being fetched via proxy + expect(fetchSpy).toHaveBeenCalledWith( + "https://www.my-site.com/api/v1/os/maps/vector/v1/vts/resources/styles?srs=3857" + ); + }); + + it("falls back to an OSM basemap without an OS API key or proxy endpoint", async () => { + await setupMap(` + `); + const rasterBaseMap = window.olMap + ?.getAllLayers() + .find((layer) => layer.get("name") === "rasterBaseMap"); + expect(rasterBaseMap).toBeDefined(); + const source = rasterBaseMap?.getSource() as OSM; + expect(source.getUrls()?.length).toBeGreaterThan(0); + source + .getUrls() + ?.forEach((url) => expect(url).toMatch(/openstreetmap\.org/)); + }); +}); diff --git a/src/components/my-map/os-layers.ts b/src/components/my-map/os-layers.ts index 8ec5b44..52e34b3 100644 --- a/src/components/my-map/os-layers.ts +++ b/src/components/my-map/os-layers.ts @@ -5,46 +5,89 @@ import VectorTileLayer from "ol/layer/VectorTile"; import { OSM, XYZ } from "ol/source"; import { ATTRIBUTION } from "ol/source/OSM"; import VectorTileSource from "ol/source/VectorTile"; +import { getServiceURL } from "../../lib/ordnanceSurvey"; -// Ordnance Survey sources -const tileServiceUrl = `https://api.os.uk/maps/raster/v1/zxy/Light_3857/{z}/{x}/{y}.png?key=`; -const vectorTileServiceUrl = `https://api.os.uk/maps/vector/v1/vts/tile/{z}/{y}/{x}.pbf?srs=3857&key=`; -const vectorTileStyleUrl = `https://api.os.uk/maps/vector/v1/vts/resources/styles?srs=3857&key=`; +export function makeRasterBaseMap( + apiKey: string, + proxyEndpoint: string, + copyright: string +): TileLayer { + const isUsingOS = Boolean(apiKey || proxyEndpoint); + // Fallback to OSM if not using OS services + const basemap = isUsingOS + ? makeOSRasterBaseMap(apiKey, proxyEndpoint, copyright) + : makeDefaultTileLayer(); + basemap.set("name", "rasterBaseMap"); + return basemap; +} + +function makeOSRasterBaseMap( + apiKey: string, + proxyEndpoint: string, + copyright: string +): TileLayer { + const tileServiceURL = getServiceURL({ + service: "xyz", + apiKey, + proxyEndpoint, + }); + return new TileLayer({ + source: new XYZ({ + url: tileServiceURL, + attributions: [copyright], + attributionsCollapsible: false, + maxZoom: 20, + }), + }); +} -export function makeRasterBaseMap(copyright: string, apiKey?: string) { +function makeDefaultTileLayer(): TileLayer { return new TileLayer({ - source: apiKey - ? new XYZ({ - url: tileServiceUrl + apiKey, - attributions: [copyright], - attributionsCollapsible: false, - maxZoom: 20, - }) - : // no OS API key found, sign up here https://osdatahub.os.uk/plans - new OSM({ - attributions: [ATTRIBUTION], - }), + source: new OSM({ + attributions: [ATTRIBUTION], + }), }); } -export function makeOsVectorTileBaseMap(copyright: string, apiKey: string) { - let osVectorTileLayer = new VectorTileLayer({ +export function makeOsVectorTileBaseMap( + apiKey: string, + proxyEndpoint: string, + copyright: string +): VectorTileLayer | undefined { + const isUsingOS = Boolean(apiKey || proxyEndpoint); + if (!isUsingOS) return; + + const vectorTileServiceUrl = getServiceURL({ + service: "vectorTile", + apiKey, + proxyEndpoint, + params: { srs: "3857" }, + }); + const osVectorTileLayer = new VectorTileLayer({ declutter: true, + properties: { + name: "vectorBaseMap", + }, source: new VectorTileSource({ format: new MVT(), - url: vectorTileServiceUrl + apiKey, + url: vectorTileServiceUrl, attributions: [copyright], attributionsCollapsible: false, }), }); - if (apiKey) { - // ref https://github.com/openlayers/ol-mapbox-style#usage-example - fetch(vectorTileStyleUrl + apiKey) - .then((response) => response.json()) - .then((glStyle) => stylefunction(osVectorTileLayer, glStyle, "esri")) - .catch((error) => console.log(error)); - } + const vectorTileStyleUrl = getServiceURL({ + service: "vectorTileStyle", + apiKey, + proxyEndpoint, + params: { srs: "3857" }, + }); + + // ref https://github.com/openlayers/ol-mapbox-style#usage-example + fetch(vectorTileStyleUrl) + .then((response) => response.json()) + .then((glStyle) => stylefunction(osVectorTileLayer, glStyle, "esri")) + .catch((error) => console.log(error)); return osVectorTileLayer; } diff --git a/src/lib/ordnanceSurvey.test.ts b/src/lib/ordnanceSurvey.test.ts new file mode 100644 index 0000000..85a6bda --- /dev/null +++ b/src/lib/ordnanceSurvey.test.ts @@ -0,0 +1,85 @@ +import { constructURL, getServiceURL } from "./ordnanceSurvey"; + +describe("constructURL helper function", () => { + test("simple URL construction", () => { + const result = constructURL( + "https://www.test.com", + "/my-path/to-something" + ); + expect(result).toEqual("https://www.test.com/my-path/to-something"); + }); + + test("URL with query params construction", () => { + const result = constructURL( + "https://www.test.com", + "/my-path/to-something", + { test: "params", test2: "more-params" } + ); + expect(result).toEqual( + "https://www.test.com/my-path/to-something?test=params&test2=more-params" + ); + }); +}); + +describe("getServiceURL helper function", () => { + it("returns an OS service URL if an API key is passed in", () => { + const result = getServiceURL({ + service: "vectorTile", + apiKey: "my-api-key", + proxyEndpoint: "", + params: { srs: "3857" }, + }); + + expect(result).toBeDefined(); + const { origin, pathname, searchParams } = new URL(result!); + expect(origin).toEqual("https://api.os.uk"); + expect(decodeURIComponent(pathname)).toEqual( + "/maps/vector/v1/vts/tile/{z}/{y}/{x}.pbf" + ); + expect(searchParams.get("key")).toEqual("my-api-key"); + expect(searchParams.get("srs")).toEqual("3857"); + }); + + it("returns a proxy service URL if a proxy endpoint is passed in", () => { + const result = getServiceURL({ + service: "vectorTileStyle", + proxyEndpoint: "https://www.my-site.com/api/proxy/os", + apiKey: "", + params: { srs: "3857" }, + }); + + expect(result).toBeDefined(); + const { origin, pathname, searchParams } = new URL(result!); + expect(origin).toEqual("https://www.my-site.com"); + expect(decodeURIComponent(pathname)).toEqual( + "/api/proxy/os/maps/vector/v1/vts/resources/styles" + ); + expect(searchParams.get("key")).toBeNull(); + expect(searchParams.get("srs")).toEqual("3857"); + }); + + it("returns a proxy service URL if a proxy endpoint is passed in (with a trailing slash)", () => { + const result = getServiceURL({ + service: "xyz", + proxyEndpoint: "https://www.my-site.com/api/proxy/os/", + apiKey: "", + }); + + expect(result).toBeDefined(); + const { origin, pathname } = new URL(result!); + expect(origin).toEqual("https://www.my-site.com"); + expect(decodeURIComponent(pathname)).toEqual( + "/api/proxy/os/maps/raster/v1/zxy/Light_3857/{z}/{x}/{y}.png" + ); + }); + + it("throws error without an API key or proxy endpoint", () => { + expect(() => + getServiceURL({ + service: "xyz", + apiKey: "", + proxyEndpoint: "", + }) + ).toThrowError(); + }); +}); diff --git a/src/lib/ordnanceSurvey.ts b/src/lib/ordnanceSurvey.ts new file mode 100644 index 0000000..4bb22cf --- /dev/null +++ b/src/lib/ordnanceSurvey.ts @@ -0,0 +1,85 @@ +const OS_DOMAIN = "https://api.os.uk"; + +type OSServices = + | "xyz" + | "vectorTile" + | "vectorTileStyle" + | "places" + | "features"; + +interface ServiceOptions { + service: OSServices; + apiKey: string; + proxyEndpoint: string; + params?: Record; +} + +// Ordnance Survey sources +const PATH_LOOKUP: Record = { + xyz: "/maps/raster/v1/zxy/Light_3857/{z}/{x}/{y}.png", + vectorTile: "/maps/vector/v1/vts/tile/{z}/{y}/{x}.pbf", + vectorTileStyle: "/maps/vector/v1/vts/resources/styles", + places: "/search/places/v1/postcode", + features: "/features/v1/wfs", +}; + +export function constructURL( + domain: string, + path: string, + params: Record = {} +): string { + const url = new URL(path, domain); + url.search = new URLSearchParams(params).toString(); + // OL requires that {z}/{x}/{y} are not encoded in order to substitue in real values + const openLayersURL = decodeURI(url.href); + return openLayersURL; +} + +export function getOSServiceURL({ + service, + apiKey, + params, +}: Omit): string { + const osServiceURL = constructURL(OS_DOMAIN, PATH_LOOKUP[service], { + ...params, + key: apiKey, + }); + return osServiceURL; +} + +/** + * Generate a proxied OS service URL + * XXX: OS API key must be appended to requests by the proxy endpoint + */ +export function getProxyServiceURL({ + service, + proxyEndpoint, + params, +}: Omit): string { + let { origin: proxyOrigin, pathname: proxyPathname } = new URL(proxyEndpoint); + // Remove trailing slash on pathname if present + proxyPathname = proxyPathname.replace(/\/$/, ""); + const proxyServiceURL = constructURL( + proxyOrigin, + proxyPathname + PATH_LOOKUP[service], + params + ); + return proxyServiceURL; +} + +/** + * Get either an OS service URL, or a proxied endpoint to an OS service URL + */ +export function getServiceURL({ + service, + apiKey, + proxyEndpoint, + params, +}: ServiceOptions): string { + if (proxyEndpoint) + return getProxyServiceURL({ service, proxyEndpoint, params }); + if (apiKey) return getOSServiceURL({ service, apiKey, params }); + throw Error( + `Unable to generate URL for OS ${service} API. Either an API key or proxy endpoint must be supplied` + ); +} diff --git a/src/test-utils.ts b/src/test-utils.ts index 073da79..a7dcdb7 100644 --- a/src/test-utils.ts +++ b/src/test-utils.ts @@ -11,7 +11,14 @@ export function getShadowRootEl( return document.body.querySelector(customEl)?.shadowRoot?.querySelector(el); } +export async function setupMap(mapElement: any) { + document.body.innerHTML = mapElement; + await window.happyDOM.whenAsyncComplete(); + window.olMap?.dispatchEvent("loadend"); +} + module.exports = { getShadowRoot, getShadowRootEl, + setupMap, };