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(lambda-tiler): Add vector test tiles for the health endpoint. BM-1061 #3337

Merged
merged 7 commits into from
Sep 5, 2024
Merged
Show file tree
Hide file tree
Changes from 3 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
32 changes: 29 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions packages/lambda-tiler/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,10 @@
"@basemaps/tiler-sharp": "^7.7.0",
"@linzjs/geojson": "^7.5.0",
"@linzjs/lambda": "^4.0.0",
"@mapbox/vector-tile": "^2.0.3",
"p-limit": "^4.0.0",
"path-to-regexp": "^6.1.0",
"pbf": "^4.0.1",
"pixelmatch": "^5.1.0",
"sharp": "^0.33.0"
},
Expand Down
23 changes: 18 additions & 5 deletions packages/lambda-tiler/src/routes/__tests__/health.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { FakeData } from '../../__tests__/config.data.js';
import { ConfigLoader } from '../../util/config.loader.js';
import { getTestBuffer, healthGet, TestTiles } from '../health.js';
import { TileXyzRaster } from '../tile.xyz.raster.js';
import { tileXyzVector } from '../tile.xyz.vector.js';

const ctx: LambdaHttpRequest = new LambdaAlbRequest(
{
Expand All @@ -28,11 +29,13 @@ describe('/v1/health', () => {
const sandbox = sinon.createSandbox();
const config = new ConfigProviderMemory();

const fakeTileSet = FakeData.tileSetRaster('health');
const fakeTileSetRaster = FakeData.tileSetRaster('health');
const fakeTileSetVector = FakeData.tileSetVector('topographic');
beforeEach(() => {
config.objects.clear();
sandbox.stub(ConfigLoader, 'getDefaultConfig').resolves(config);
config.put(fakeTileSet);
config.put(fakeTileSetRaster);
config.put(fakeTileSetVector);
});

afterEach(() => {
Expand All @@ -44,6 +47,7 @@ describe('/v1/health', () => {
// Given ... a bad get tile response
const BadResponse = new LambdaHttpResponse(500, 'Can not get Tile Set.');
sandbox.stub(TileXyzRaster, 'tile').resolves(BadResponse);
sandbox.stub(tileXyzVector, 'tile').resolves(BadResponse);

// When ...
const res = await healthGet(ctx);
Expand All @@ -55,20 +59,29 @@ describe('/v1/health', () => {

const Response1 = new LambdaHttpResponse(200, 'ok');
const Response2 = new LambdaHttpResponse(200, 'ok');
const Response3 = new LambdaHttpResponse(200, 'ok');
const Response4 = new LambdaHttpResponse(200, 'ok');

before(async () => {
const testTileFile1 = await getTestBuffer(TestTiles[0]);
Response1.buffer(testTileFile1);
const testTileFile2 = await getTestBuffer(TestTiles[1]);
Response2.buffer(testTileFile2);
const testTileFile3 = await getTestBuffer(TestTiles[2]);
Response3.buffer(testTileFile3);
const testTileFile4 = await getTestBuffer(TestTiles[3]);
Response4.buffer(testTileFile4);
});
// Prepare mock test tile response based on the static test tiles

it('Should give a 200 response', async () => {
// Given ... a series good get tile response
const callback = sandbox.stub(TileXyzRaster, 'tile');
callback.onCall(0).resolves(Response1);
callback.onCall(1).resolves(Response2);
const callbackRaster = sandbox.stub(TileXyzRaster, 'tile');
const callbackVector = sandbox.stub(tileXyzVector, 'tile');
callbackRaster.onCall(0).resolves(Response1);
callbackRaster.onCall(1).resolves(Response2);
callbackVector.onCall(0).resolves(Response3);
callbackVector.onCall(1).resolves(Response4);

// When ...
const res = await healthGet(ctx);
Expand Down
147 changes: 126 additions & 21 deletions packages/lambda-tiler/src/routes/health.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,67 @@
import * as fs from 'node:fs';

import { ConfigTileSetRaster } from '@basemaps/config';
import { ConfigTileSetRaster, ConfigTileSetVector, TileSetType } from '@basemaps/config';
import { GoogleTms, Nztm2000QuadTms } from '@basemaps/geo';
import { HttpHeader, LambdaHttpRequest, LambdaHttpResponse } from '@linzjs/lambda';
import { VectorTile } from '@mapbox/vector-tile';
import Protobuf from 'pbf';
import PixelMatch from 'pixelmatch';
import Sharp from 'sharp';
import { gunzipSync } from 'zlib';

import { ConfigLoader } from '../util/config.loader.js';
import { isGzip } from '../util/cotar.serve.js';
import { TileXyz } from '../util/validate.js';
import { TileXyzRaster } from './tile.xyz.raster.js';
import { tileXyzVector } from './tile.xyz.vector.js';

/**
* Vector feature that need to check existence
*/
interface TestFeature {
layer: string;
property: string;
value: string;
}

interface TestTile extends TileXyz {
buf?: Buffer;
testFeatures?: TestFeature[];
}

export const TestTiles: TestTile[] = [
{ tileSet: 'health', tileMatrix: GoogleTms, tileType: 'png', tile: { x: 252, y: 156, z: 8 } },
{ tileSet: 'health', tileMatrix: Nztm2000QuadTms, tileType: 'png', tile: { x: 30, y: 33, z: 6 } },
{
tileSet: 'topographic',
tileMatrix: GoogleTms,
tileType: 'pbf',
tile: { x: 1009, y: 641, z: 10 },
testFeatures: [
{ layer: 'aeroway', property: 'name', value: 'Wellington Airport' },
{ layer: 'place', property: 'name', value: 'Wellington' },
{ layer: 'coastline', property: 'class', value: 'coastline' },
{ layer: 'landcover', property: 'class', value: 'grass' },
{ layer: 'poi', property: 'name', value: 'Seatoun Wharf' },
{ layer: 'transportation', property: 'name', value: 'Mt Victoria Tunnel' },
],
},
{
tileSet: 'topographic',
tileMatrix: GoogleTms,
tileType: 'pbf',
tile: { x: 62, y: 40, z: 6 },
testFeatures: [
{ layer: 'landuse', property: 'name', value: 'Queenstown' },
{ layer: 'place', property: 'name', value: 'Christchurch' },
{ layer: 'water', property: 'name', value: 'Tasman Lake' },
{ layer: 'coastline', property: 'class', value: 'coastline' },
{ layer: 'landcover', property: 'class', value: 'wood' },
{ layer: 'transportation', property: 'name', value: 'STATE HIGHWAY 6' },
],
},
];

const TileSize = 256;

export async function getTestBuffer(test: TestTile): Promise<Buffer> {
Expand All @@ -40,6 +84,77 @@ export async function updateExpectedTile(test: TestTile, newTileData: Buffer, di
await fs.promises.writeFile(`${expectedFileName}.diff.png`, imgPng);
}

/**
* Compare and validate the raster test tile from server with pixel match
*/
async function validateRasterTile(
tileSet: ConfigTileSetRaster,
test: TestTile,
req: LambdaHttpRequest,
): Promise<LambdaHttpResponse | undefined> {
// Get the parse response tile to raw buffer
const response = await TileXyzRaster.tile(req, tileSet, test);
if (response.status !== 200) return new LambdaHttpResponse(500, response.statusDescription);
if (!Buffer.isBuffer(response._body)) throw new LambdaHttpResponse(500, 'Not a Buffer response content.');
const resImgBuffer = await Sharp(response._body).raw().toBuffer();

// Get test tile to compare
const testBuffer = await getTestBuffer(test);
test.buf = testBuffer;
const testImgBuffer = await Sharp(testBuffer).raw().toBuffer();

const outputBuffer = Buffer.alloc(testImgBuffer.length);
const missMatchedPixels = PixelMatch(testImgBuffer, resImgBuffer, outputBuffer, TileSize, TileSize);
if (missMatchedPixels) {
/** Uncomment this to overwite the expected files */
// await updateExpectedTile(test, response._body as Buffer, outputBuffer);
req.log.error({ missMatchedPixels, projection: test.tileMatrix.identifier, xyz: test.tile }, 'Health:MissMatch');
return new LambdaHttpResponse(500, 'TileSet does not match.');
}
return;
}

function assertLayer(tile: VectorTile, testFeature: TestFeature): boolean {
Wentao-Kuang marked this conversation as resolved.
Show resolved Hide resolved
const layer = tile.layers[testFeature.layer];
for (let i = 0; i < layer.length; i++) {
const feature = layer.feature(i);
if (feature.properties[testFeature.property] === testFeature.value) return true;
}
return false;
}

/**
* Check the existence of a feature property in side the vector tile
*/
function featureCheck(tile: VectorTile, testTile: TestTile): LambdaHttpResponse | undefined {
blacha marked this conversation as resolved.
Show resolved Hide resolved
if (testTile.testFeatures == null) return;
blacha marked this conversation as resolved.
Show resolved Hide resolved
for (const testFeature of testTile.testFeatures) {
if (!assertLayer(tile, testFeature))
blacha marked this conversation as resolved.
Show resolved Hide resolved
throw new LambdaHttpResponse(
500,
`Failed to validate test vector tile: ${testTile.tile.x}/${testTile.tile.y}/z${testTile.tile.z} for layer: ${testFeature.layer}.`,
);
}
return;
}

/**
* Health check the test vector tiles that contains all the expected features.
*/
blacha marked this conversation as resolved.
Show resolved Hide resolved
async function validateVectorTile(
tileSet: ConfigTileSetVector,
test: TestTile,
req: LambdaHttpRequest,
): Promise<LambdaHttpResponse | undefined> {
// Get the parse response tile to raw buffer
const response = await tileXyzVector.tile(req, tileSet, test);
if (response.status !== 200) return new LambdaHttpResponse(500, response.statusDescription);
Wentao-Kuang marked this conversation as resolved.
Show resolved Hide resolved
if (!Buffer.isBuffer(response._body)) throw new LambdaHttpResponse(500, 'Not a Buffer response content.');
const buffer = isGzip(response._body) ? gunzipSync(response._body) : response._body;
const tile = new VectorTile(new Protobuf(buffer));
return featureCheck(tile, test);
}

/**
* Health request get health TileSets and validate with test TileSets
* - Valid response from get heath tile request
Expand All @@ -49,27 +164,17 @@ export async function updateExpectedTile(test: TestTile, newTileData: Buffer, di
*/
export async function healthGet(req: LambdaHttpRequest): Promise<LambdaHttpResponse> {
const config = await ConfigLoader.load(req);
const tileSet = await config.TileSet.get(config.TileSet.id('health'));
if (tileSet == null) throw new LambdaHttpResponse(500, 'TileSet: "health" not found');
for (const test of TestTiles) {
// Get the parse response tile to raw buffer
const response = await TileXyzRaster.tile(req, tileSet as ConfigTileSetRaster, test);
if (response.status !== 200) return new LambdaHttpResponse(500, response.statusDescription);
if (!Buffer.isBuffer(response._body)) throw new LambdaHttpResponse(500, 'Not a Buffer response content.');
const resImgBuffer = await Sharp(response._body).raw().toBuffer();

// Get test tile to compare
const testBuffer = await getTestBuffer(test);
test.buf = testBuffer;
const testImgBuffer = await Sharp(testBuffer).raw().toBuffer();

const outputBuffer = Buffer.alloc(testImgBuffer.length);
const missMatchedPixels = PixelMatch(testImgBuffer, resImgBuffer, outputBuffer, TileSize, TileSize);
if (missMatchedPixels) {
/** Uncomment this to overwite the expected files */
// await updateExpectedTile(test, response._body as Buffer, outputBuffer);
req.log.error({ missMatchedPixels, projection: test.tileMatrix.identifier, xyz: test.tile }, 'Health:MissMatch');
return new LambdaHttpResponse(500, 'TileSet does not match.');
const tileSet = await config.TileSet.get(config.TileSet.id(test.tileSet));
if (tileSet == null) throw new LambdaHttpResponse(500, `TileSet: ${test.tileSet} not found`);
if (tileSet.type === TileSetType.Raster) {
const response = await validateRasterTile(tileSet, test, req);
if (response) return response;
} else if (tileSet.type === TileSetType.Vector) {
const response = await validateVectorTile(tileSet, test, req);
if (response) return response;
} else {
throw new LambdaHttpResponse(500, `Invalid TileSet type for tileSet ${test.tileSet}`);
}
}

Expand Down
Wentao-Kuang marked this conversation as resolved.
Show resolved Hide resolved
Binary file not shown.
Binary file not shown.
Loading