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,
};