diff --git a/packages/lambda-tiler/src/index.ts b/packages/lambda-tiler/src/index.ts index 8c5257e7d..f14ffbd38 100644 --- a/packages/lambda-tiler/src/index.ts +++ b/packages/lambda-tiler/src/index.ts @@ -6,6 +6,7 @@ import { configImageryGet, configTileSetGet } from './routes/config.js'; import { fontGet, fontList } from './routes/fonts.js'; import { healthGet } from './routes/health.js'; import { imageryGet } from './routes/imagery.js'; +import { linkGet } from './routes/link.js'; import { pingGet } from './routes/ping.js'; import { previewIndexGet } from './routes/preview.index.js'; import { tilePreviewGet } from './routes/preview.js'; @@ -102,6 +103,9 @@ handler.router.get('/v1/preview/:tileSet/:tileMatrix/:z/:lon/:lat/:outputType', handler.router.get('/v1/@:location', previewIndexGet); handler.router.get('/@:location', previewIndexGet); +// Link +handler.router.get('/v1/link/:tileSet', linkGet); + // Attribution handler.router.get('/v1/tiles/:tileSet/:tileMatrix/attribution.json', tileAttributionGet); handler.router.get('/v1/attribution/:tileSet/:tileMatrix/summary.json', tileAttributionGet); diff --git a/packages/lambda-tiler/src/routes/__tests__/link.test.ts b/packages/lambda-tiler/src/routes/__tests__/link.test.ts new file mode 100644 index 000000000..81a7700a4 --- /dev/null +++ b/packages/lambda-tiler/src/routes/__tests__/link.test.ts @@ -0,0 +1,114 @@ +import { strictEqual } from 'node:assert'; +import { afterEach, describe, it } from 'node:test'; + +import { ConfigProviderMemory } from '@basemaps/config'; +import { Epsg } from '@basemaps/geo'; + +import { FakeData, Imagery3857 } from '../../__tests__/config.data.js'; +import { mockRequest } from '../../__tests__/xyz.util.js'; +import { handler } from '../../index.js'; +import { ConfigLoader } from '../../util/config.loader.js'; + +describe('/v1/link/:tileSet', () => { + const FakeTileSetName = 'tileset'; + const config = new ConfigProviderMemory(); + + afterEach(() => { + config.objects.clear(); + }); + + /** + * 3xx status responses + */ + + // tileset found, is raster type, has one layer, has '3857' entry, imagery found > 302 response + it('success: redirect to pre-zoomed imagery', async (t) => { + t.mock.method(ConfigLoader, 'getDefaultConfig', () => Promise.resolve(config)); + + config.put(FakeData.tileSetRaster(FakeTileSetName)); + config.put(Imagery3857); + + const req = mockRequest(`/v1/link/${FakeTileSetName}`); + const res = await handler.router.handle(req); + + strictEqual(res.status, 302); + strictEqual(res.statusDescription, 'Redirect to pre-zoomed imagery'); + }); + + /** + * 4xx status responses + */ + + // tileset not found > 404 response + it('failure: tileset not found', async (t) => { + t.mock.method(ConfigLoader, 'getDefaultConfig', () => Promise.resolve(config)); + + const req = mockRequest(`/v1/link/${FakeTileSetName}`); + const res = await handler.router.handle(req); + + strictEqual(res.status, 404); + strictEqual(res.statusDescription, 'Tileset not found'); + }); + + // tileset found, not raster type > 400 response + it('failure: tileset must be raster type', async (t) => { + t.mock.method(ConfigLoader, 'getDefaultConfig', () => Promise.resolve(config)); + + config.put(FakeData.tileSetVector(FakeTileSetName)); + + const req = mockRequest(`/v1/link/${FakeTileSetName}`); + const res = await handler.router.handle(req); + + strictEqual(res.status, 400); + strictEqual(res.statusDescription, 'Tileset must be raster type'); + }); + + // tileset found, is raster type, has more than one layer > 400 response + it('failure: too many layers', async (t) => { + t.mock.method(ConfigLoader, 'getDefaultConfig', () => Promise.resolve(config)); + + const tileSet = FakeData.tileSetRaster(FakeTileSetName); + + // add another layer + tileSet.layers.push(tileSet.layers[0]); + + config.put(tileSet); + + const req = mockRequest(`/v1/link/${FakeTileSetName}`); + const res = await handler.router.handle(req); + + strictEqual(res.status, 400); + strictEqual(res.statusDescription, 'Too many layers'); + }); + + // tileset found, is raster type, has one layer, no '3857' entry > 400 response + it("failure: no imagery for '3857' projection", async (t) => { + t.mock.method(ConfigLoader, 'getDefaultConfig', () => Promise.resolve(config)); + + const tileSet = FakeData.tileSetRaster(FakeTileSetName); + + // delete '3857' entry + delete tileSet.layers[0][Epsg.Google.code]; + + config.put(tileSet); + + const req = mockRequest(`/v1/link/${FakeTileSetName}`); + const res = await handler.router.handle(req); + + strictEqual(res.status, 400); + strictEqual(res.statusDescription, "No imagery for '3857' projection"); + }); + + // tileset found, is raster type, has one layer, has '3857' entry, imagery not found > 400 response + it('failure: imagery not found', async (t) => { + t.mock.method(ConfigLoader, 'getDefaultConfig', () => Promise.resolve(config)); + + config.put(FakeData.tileSetRaster(FakeTileSetName)); + + const req = mockRequest(`/v1/link/${FakeTileSetName}`); + const res = await handler.router.handle(req); + + strictEqual(res.status, 400); + strictEqual(res.statusDescription, 'Imagery not found'); + }); +}); diff --git a/packages/lambda-tiler/src/routes/link.ts b/packages/lambda-tiler/src/routes/link.ts new file mode 100644 index 000000000..6eb150d4e --- /dev/null +++ b/packages/lambda-tiler/src/routes/link.ts @@ -0,0 +1,55 @@ +import { TileSetType } from '@basemaps/config'; +import { Epsg } from '@basemaps/geo'; +import { getPreviewUrl } from '@basemaps/shared'; +import { LambdaHttpRequest, LambdaHttpResponse } from '@linzjs/lambda'; + +import { ConfigLoader } from '../util/config.loader.js'; + +export interface LinkGet { + Params: { + tileSet: string; + }; +} + +/** + * Redirect the client to a Basemaps URL that is already zoomed to the extent of the tileset's imagery. + * + * /v1/link/:tileSet + * + * @example + * '/v1/link/ashburton-2023-0.1m' + * + * @returns on success, 302 redirect response. on failure, 4xx status code response. + */ +export async function linkGet(req: LambdaHttpRequest): Promise { + const config = await ConfigLoader.load(req); + + // get tileset + + req.timer.start('tileset:load'); + const tileSet = await config.TileSet.get(req.params.tileSet); + req.timer.end('tileset:load'); + + if (tileSet == null) return new LambdaHttpResponse(404, 'Tileset not found'); + + if (tileSet.type !== TileSetType.Raster) return new LambdaHttpResponse(400, 'Tileset must be raster type'); + + // TODO: add support for 'aerial' and 'elevation' multi-layer tilesets + if (tileSet.layers.length !== 1) return new LambdaHttpResponse(400, 'Too many layers'); + + // get imagery + + const imageryId = tileSet.layers[0][Epsg.Google.code]; + if (imageryId === undefined) return new LambdaHttpResponse(400, "No imagery for '3857' projection"); + + const imagery = await config.Imagery.get(imageryId); + if (imagery == null) return new LambdaHttpResponse(400, 'Imagery not found'); + + // do redirect + + const url = getPreviewUrl({ imagery }); + + return new LambdaHttpResponse(302, 'Redirect to pre-zoomed imagery', { + location: `/${url.slug}?i=${url.name}`, + }); +}