Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(server): add redirect route to pre-zoomed tileset BM-1076 #3354

Merged
merged 5 commits into from
Oct 7, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/lambda-tiler/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand Down
114 changes: 114 additions & 0 deletions packages/lambda-tiler/src/routes/__tests__/link.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
55 changes: 55 additions & 0 deletions packages/lambda-tiler/src/routes/link.ts
Original file line number Diff line number Diff line change
@@ -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<LinkGet>): Promise<LambdaHttpResponse> {
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}`,
});
}
Loading