Skip to content

Commit

Permalink
feat: Hide OS API key by allowing proxy endpoint (#241)
Browse files Browse the repository at this point in the history
  • Loading branch information
DafyddLlyr authored Dec 21, 2022
1 parent c7e45ff commit 7979850
Show file tree
Hide file tree
Showing 18 changed files with 672 additions and 73 deletions.
97 changes: 97 additions & 0 deletions docs/how-to-use-a-proxy.md
Original file line number Diff line number Diff line change
@@ -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).
2 changes: 1 addition & 1 deletion index.html
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@
<div style="display:flex;flex-direction:column;">
<h1 style="color:red;font-family:Inter,Helvetica,sans-serif;font-size:16px;">*** This is a testing sandbox - these components are unaware of each other! ***</h1>
<div style="margin-bottom:1em">
<my-map zoom="20" maxZoom="23" drawMode drawPointer="dot" id="example-map" showScale />
<my-map zoom="20" maxZoom="23" drawMode drawPointer="dot" id="example-map" showScale disableVectorTiles osProxyEndpoint="https://api.editor.planx.dev/proxy/ordnance-survey" />
</div>
<div style="margin-bottom:1em">
<postcode-search hintText="Optional hint text shows up here" id="example-postcode" />
Expand Down
26 changes: 25 additions & 1 deletion src/components/address-autocomplete/address-autocomplete.doc.js
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -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: [
{
Expand Down Expand Up @@ -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: `<address-autocomplete postcode="SE19 1NT" osPlacesApiKey="" osProxyEndpoint="https://api.editor.planx.dev/proxy/ordnance-survey" />`,
controller: function (document) {
const autocomplete = document.querySelector("address-autocomplete");

autocomplete.addEventListener("ready", ({ detail: data }) => {
console.debug("autocomplete ready", { data });
});

autocomplete.addEventListener(
"addressSelection",
({ detail: address }) => {
Expand Down
24 changes: 18 additions & 6 deletions src/components/address-autocomplete/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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";

Expand Down Expand Up @@ -98,20 +102,27 @@ 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<string, string> = {
postcode: this.postcode,
dataset: "LPI", // or "DPA" for only mailable addresses
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
Expand Down Expand Up @@ -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}`;
Expand Down
33 changes: 33 additions & 0 deletions src/components/address-autocomplete/main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = `<address-autocomplete id="autocomplete-vitest" postcode="SE5 0HU" osPlacesApiKey="" osProxyEndpoint="https://www.my-site.com/api/v1/os" />`;
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 = `<address-autocomplete id="autocomplete-vitest" postcode="SE5 0HU" osPlacesApiKey=${mockAPIKey} />`;
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}`);
});
});
5 changes: 5 additions & 0 deletions src/components/my-map/docs/my-map-basic.doc.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
{
Expand Down
5 changes: 5 additions & 0 deletions src/components/my-map/docs/my-map-draw.doc.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
{
Expand Down
5 changes: 5 additions & 0 deletions src/components/my-map/docs/my-map-features.doc.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
{
Expand Down
5 changes: 5 additions & 0 deletions src/components/my-map/docs/my-map-geojson.doc.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ module.exports = {
type: "Number",
values: "12",
},
{
name: "osProxyEndpoint",
type: "String",
values: "https://api.editor.planx.dev/proxy/ordnance-survey",
},
],
examples: [
{
Expand Down
Loading

0 comments on commit 7979850

Please sign in to comment.