Skip to content
Merged
16 changes: 12 additions & 4 deletions src/client/javascripts/location-map.js
Original file line number Diff line number Diff line change
Expand Up @@ -138,10 +138,18 @@ export function makeTileRequestTransformer(apiPath) {
* @param {string} resourceType - the resource type
*/
return function transformTileRequest(url, resourceType) {
// Only proxy OS API requests that don't already have a key
if (resourceType !== 'Style' && url.startsWith('https://api.os.uk')) {
const urlObj = new URL(url)
if (!urlObj.searchParams.has('key')) {
if (url.startsWith('https://api.os.uk')) {
if (resourceType === 'Tile') {
return {
url: url.replace(
'https://api.os.uk/maps/vector/v1/vts',
`${window.location.origin}${apiPath}`
),
headers: {}
}
}

if (resourceType !== 'Style') {
return {
url: `${apiPath}/map-proxy?url=${encodeURIComponent(url)}`,
headers: {}
Expand Down
6 changes: 4 additions & 2 deletions src/server/plugins/engine/configureEnginePlugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ export const configureEnginePlugin = async (
preparePageEventRequestOptions,
onRequest,
saveAndExit,
ordnanceSurveyApiKey
ordnanceSurveyApiKey,
ordnanceSurveyApiSecret
}: RouteConfig = {},
cache?: CacheService
): Promise<{
Expand Down Expand Up @@ -65,7 +66,8 @@ export const configureEnginePlugin = async (
onRequest,
baseUrl: 'http://localhost:3009', // always runs locally
saveAndExit,
ordnanceSurveyApiKey
ordnanceSurveyApiKey,
ordnanceSurveyApiSecret
}
}
}
Expand Down
3 changes: 2 additions & 1 deletion src/server/plugins/engine/options.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ const pluginRegistrationOptionsSchema = Joi.object({
onRequest: Joi.function().optional(),
baseUrl: Joi.string().uri().required(),
saveAndExit: Joi.function().optional(),
ordnanceSurveyApiKey: Joi.string().optional()
ordnanceSurveyApiKey: Joi.string().optional(),
ordnanceSurveyApiSecret: Joi.string().optional()
})

/**
Expand Down
10 changes: 6 additions & 4 deletions src/server/plugins/engine/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@ export const plugin = {
viewContext,
preparePageEventRequestOptions,
onRequest,
ordnanceSurveyApiKey
ordnanceSurveyApiKey,
ordnanceSurveyApiSecret
} = options

const cacheService =
Expand All @@ -58,12 +59,13 @@ export const plugin = {
})
}

// Register the maps plugin only if we have an OS api key
if (ordnanceSurveyApiKey) {
// Register the maps plugin only if we have an OS api key & secret
if (ordnanceSurveyApiKey && ordnanceSurveyApiSecret) {
await server.register({
plugin: mapPlugin,
options: {
ordnanceSurveyApiKey
ordnanceSurveyApiKey,
ordnanceSurveyApiSecret
}
})
}
Expand Down
1 change: 1 addition & 0 deletions src/server/plugins/engine/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -423,6 +423,7 @@ export interface PluginOptions {
onRequest?: OnRequestCallback
baseUrl: string // base URL of the application, protocol and hostname e.g. "https://myapp.com"
ordnanceSurveyApiKey?: string
ordnanceSurveyApiSecret?: string
}

export interface FormAdapterSubmissionMessageMeta {
Expand Down
41 changes: 41 additions & 0 deletions src/server/plugins/map/routes/get-os-token.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { post } from '~/src/server/services/httpService.js'

/**
* @type {string}
*/
let cachedToken
let tokenExpiry = 0

/**
* Get Ordnance Survey OAuth token
* @param {MapConfiguration} options - Ordnance survey map options
*/
export async function getAccessToken(options) {
const { ordnanceSurveyApiKey: key, ordnanceSurveyApiSecret: secret } = options
const now = Date.now()

if (cachedToken && now < tokenExpiry) {
return cachedToken
}

const creds = `${key}:${secret}`
const result = await post('https://api.os.uk/oauth2/token/v1', {
headers: {
Authorization: `Basic ${btoa(creds)}`,
'Content-Type': 'application/x-www-form-urlencoded'
},
payload: 'grant_type=client_credentials',
json: true
})

const data = result.payload

cachedToken = data.access_token
tokenExpiry = now + (data.expires_in - 60) * 1000 // refresh early

return cachedToken
}

/**
* @import { MapConfiguration } from '~/src/server/plugins/map/types.js'
*/
55 changes: 55 additions & 0 deletions src/server/plugins/map/routes/get-os-token.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { getAccessToken } from '~/src/server/plugins/map/routes/get-os-token.js'
import { post } from '~/src/server/services/httpService.js'

jest.mock('~/src/server/services/httpService.ts')

describe('OS OAuth token', () => {
describe('getAccessToken', () => {
it('should get access token', async () => {
jest.mocked(post).mockResolvedValueOnce({
res: /** @type {IncomingMessage} */ ({
statusCode: 200,
headers: {}
}),
payload: {
access_token: 'access_token',
expires_in: '299',
issued_at: '1770036762387',
token_type: 'Bearer'
},
error: undefined
})

const token = await getAccessToken({
ordnanceSurveyApiKey: 'apikey',
ordnanceSurveyApiSecret: 'apisecret'
})

expect(token).toBe('access_token')

expect(post).toHaveBeenCalledWith('https://api.os.uk/oauth2/token/v1', {
headers: {
Authorization: `Basic ${btoa('apikey:apisecret')}`,
'Content-Type': 'application/x-www-form-urlencoded'
},
payload: 'grant_type=client_credentials',
json: true
})
expect(post).toHaveBeenCalledTimes(1)
})

it('should return an cached token', async () => {
const token = await getAccessToken({
ordnanceSurveyApiKey: 'apikey',
ordnanceSurveyApiSecret: 'apisecret'
})

expect(token).toBe('access_token')
expect(post).toHaveBeenCalledTimes(0)
})
})
})

/**
* @import { IncomingMessage } from 'node:http'
*/
94 changes: 70 additions & 24 deletions src/server/plugins/map/routes/index.js
Original file line number Diff line number Diff line change
@@ -1,27 +1,33 @@
import { resolve } from 'node:path'

import { StatusCodes } from 'http-status-codes'
import Joi from 'joi'

import { getAccessToken } from '~/src/server/plugins/map/routes/get-os-token.js'
import { find, nearest } from '~/src/server/plugins/map/service.js'
import { request as httpRequest } from '~/src/server/services/httpService.js'
import {
get,
request as httpRequest
} from '~/src/server/services/httpService.js'

/**
* Gets the map support routes
* @param {MapConfiguration} options - ordnance survey names api key
*/
export function getRoutes(options) {
return [
mapStyleResourceRoutes(),
mapProxyRoute(options),
tileProxyRoute(options),
geocodeProxyRoute(options),
reverseGeocodeProxyRoute(options),
...tileRoutes()
reverseGeocodeProxyRoute(options)
]
}

/**
* Proxies ordnance survey requests from the front end to api.os.com
* Used for VTS map tiles, sprites and fonts by forwarding on the request
* and adding the apikey and optionally an SRS (spatial reference system)
* Used for the VTS map source by forwarding on the request
* and adding the auth token and SRS (spatial reference system)
* @param {MapConfiguration} options - the map options
* @returns {ServerRoute<MapProxyGetRequestRefs>}
*/
Expand All @@ -32,14 +38,15 @@ function mapProxyRoute(options) {
handler: async (request, h) => {
const { query } = request
const targetUrl = new URL(decodeURIComponent(query.url))
const token = await getAccessToken(options)

// Add API key server-side and set SRS
targetUrl.searchParams.set('key', options.ordnanceSurveyApiKey)
if (!targetUrl.searchParams.has('srs')) {
targetUrl.searchParams.set('srs', '3857')
}
targetUrl.searchParams.set('srs', '3857')

const proxyResponse = await httpRequest('get', targetUrl.toString())
const proxyResponse = await httpRequest('get', targetUrl.toString(), {
headers: {
Authorization: `Bearer ${token}`
}
})
const buffer = proxyResponse.payload
const contentType = proxyResponse.res.headers['content-type']
const response = h.response(buffer)
Expand All @@ -63,7 +70,44 @@ function mapProxyRoute(options) {
}

/**
* Proxies ordnance survey geocode requests from the front end to api.os.com
* Proxies ordnance survey requests from the front end to api.os.uk
* Used for VTS map tiles forwarding on the request and adding the auth token
* @param {MapConfiguration} options - the map options
* @returns {ServerRoute<MapProxyGetRequestRefs>}
*/
function tileProxyRoute(options) {
return {
method: 'GET',
path: '/api/tile/{z}/{y}/{x}.pbf',
handler: async (request, h) => {
const { z, y, x } = request.params
const token = await getAccessToken(options)

const url = `https://api.os.uk/maps/vector/v1/vts/tile/${z}/${y}/${x}.pbf?srs=3857`

const { payload, res } = await get(url, {
headers: {
Authorization: `Bearer ${token}`,
Accept: 'application/x-protobuf'
},
json: false,
gunzip: true
})

if (res.statusCode && res.statusCode !== StatusCodes.OK.valueOf()) {
return h.response('Tile fetch failed').code(res.statusCode)
}

return h
.response(payload)
.type('application/x-protobuf')
.header('Cache-Control', 'public, max-age=86400')
}
}
}

/**
* Proxies ordnance survey geocode requests from the front end to api.os.uk
* Used for the gazzeteer address lookup to find name from query strings like postcode and place names
* @param {MapConfiguration} options - the map options
* @returns {ServerRoute<MapGeocodeGetRequestRefs>}
Expand Down Expand Up @@ -91,7 +135,7 @@ function geocodeProxyRoute(options) {
}

/**
* Proxies ordnance survey reverse geocode requests from the front end to api.os.com
* Proxies ordnance survey reverse geocode requests from the front end to api.os.uk
* Used to find name from easting and northing points.
* N.B this endpoint is currently not used by the front end but will be soon in "maps V2"
* @param {MapConfiguration} options - the map options
Expand Down Expand Up @@ -124,20 +168,22 @@ function reverseGeocodeProxyRoute(options) {
}
}

function tileRoutes() {
return [
{
method: 'GET',
path: '/api/maps/vts/{path*}',
options: {
handler: {
directory: {
path: resolve(import.meta.dirname, './vts')
}
/**
* Resource routes to return sprites and glyphs
* @returns {ServerRoute<MapProxyGetRequestRefs>}
*/
function mapStyleResourceRoutes() {
return {
method: 'GET',
path: '/api/maps/vts/{path*}',
options: {
handler: {
directory: {
path: resolve(import.meta.dirname, './vts')
}
}
}
]
}
}

/**
Expand Down
1 change: 1 addition & 0 deletions src/server/plugins/map/types.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/**
* @typedef {{
* ordnanceSurveyApiKey: string
* ordnanceSurveyApiSecret: string
* }} MapConfiguration
*/

Expand Down
1 change: 1 addition & 0 deletions src/server/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ export interface RouteConfig {
saveAndExit?: PluginOptions['saveAndExit']
cacheServiceCreator?: (server: Server) => CacheService
ordnanceSurveyApiKey?: string
ordnanceSurveyApiSecret?: string
}

export interface OutputService {
Expand Down
11 changes: 0 additions & 11 deletions test/client/javascripts/location-map.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -649,17 +649,6 @@ describe('Location Maps Client JS', () => {
})
})

test('tile request transformer does not apply to api.os.uk requests that already have an api key', () => {
const url = 'https://api.os.uk/test.js?key=abcde'
const transformer = makeTileRequestTransformer(apiPath)
const result = transformer(url, 'Script')

expect(result).toEqual({
url,
headers: {}
})
})

test('tile request transformer does not apply to "Style" api.os.uk requests', () => {
const url = 'https://api.os.uk/test.js'
const transformer = makeTileRequestTransformer(apiPath)
Expand Down
Loading
Loading