@basemaps/lambda-tiler 8.12.2 → 8.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -19,9 +19,9 @@ export class ConfigLoader {
19
19
  const cb = await config.ConfigBundle.get('cb_latest');
20
20
  if (cb == null)
21
21
  return config;
22
- req?.timer.start('config:load');
23
- return CachedConfig.get(fsa.toUrl(cb.path)).then((cfg) => {
24
- req?.timer.end('config:load');
22
+ const configUrl = fsa.toUrl(cb.path);
23
+ return CachedConfig.get(configUrl).then((cfg) => {
24
+ req?.set('config', configUrl.href);
25
25
  if (cfg == null)
26
26
  throw new LambdaHttpResponse(500, 'Unable to find latest configuration');
27
27
  if (cfg.assets == null)
@@ -39,9 +39,15 @@ export class ConfigLoader {
39
39
  return rawLocation;
40
40
  }
41
41
  static async load(req) {
42
+ req.timer.start('config:load');
43
+ const config = await this._load(req);
44
+ req.timer.end('config:load');
45
+ return config;
46
+ }
47
+ static async _load(req) {
42
48
  const rawLocation = req.query.get('config');
43
49
  if (rawLocation == null)
44
- return this.getDefaultConfig();
50
+ return this.getDefaultConfig(req);
45
51
  const configLocation = isBase58(rawLocation) ? Buffer.from(base58.decode(rawLocation)).toString() : rawLocation;
46
52
  const configUrl = fsa.toUrl(configLocation);
47
53
  if (configUrl == null)
@@ -52,10 +58,8 @@ export class ConfigLoader {
52
58
  if (!SafeBuckets.has(configUrl.hostname)) {
53
59
  throw new LambdaHttpResponse(400, `Bucket: "${configUrl.hostname}" is not a allowed bucket location`);
54
60
  }
55
- req.set('config', configUrl.href);
56
- req.timer.start('config:load');
57
61
  return CachedConfig.get(configUrl).then((f) => {
58
- req.timer.end('config:load');
62
+ req.set('config', configUrl.href);
59
63
  if (f == null)
60
64
  throw new LambdaHttpResponse(400, `Invalid config location at ${configLocation}`);
61
65
  return f;
@@ -1 +1 @@
1
- {"version":3,"file":"config.loader.js","sourceRoot":"","sources":["../../src/util/config.loader.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"config.loader.js","sourceRoot":"","sources":["../../src/util/config.loader.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAA0B,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AAC5E,OAAO,EAAE,GAAG,EAAE,gBAAgB,EAAE,MAAM,kBAAkB,CAAC;AACzD,OAAO,EAAE,kBAAkB,EAAE,MAAM,gBAAgB,CAAC;AAGpD,OAAO,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAC;AAEjD,+CAA+C;AAC/C,MAAM,WAAW,GAAG,IAAI,GAAG,CAAC;IAC1B,wBAAwB;IACxB,yBAAyB;IACzB,eAAe;IACf,uBAAuB;IACvB,uBAAuB;CACxB,CAAC,CAAC;AAEH,MAAM,aAAa,GAAG,IAAI,GAAG,CAAC,CAAC,IAAI,GAAG,CAAC,UAAU,CAAC,CAAC,QAAQ,EAAE,IAAI,GAAG,CAAC,mBAAmB,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC;AAErG,MAAM,OAAO,YAAY;IACvB,0BAA0B;IAC1B,MAAM,CAAC,KAAK,CAAC,gBAAgB,CAAC,GAAuB;QACnD,MAAM,MAAM,GAAG,gBAAgB,EAAE,CAAC;QAElC,gGAAgG;QAChG,MAAM,EAAE,GAAG,MAAM,MAAM,CAAC,YAAY,CAAC,GAAG,CAAC,WAAW,CAAC,CAAC;QACtD,IAAI,EAAE,IAAI,IAAI;YAAE,OAAO,MAAM,CAAC;QAE9B,MAAM,SAAS,GAAG,GAAG,CAAC,KAAK,CAAC,EAAE,CAAC,IAAI,CAAC,CAAC;QACrC,OAAO,YAAY,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC,IAAI,CAAC,CAAC,GAAG,EAAE,EAAE;YAC9C,GAAG,EAAE,GAAG,CAAC,QAAQ,EAAE,SAAS,CAAC,IAAI,CAAC,CAAC;YAEnC,IAAI,GAAG,IAAI,IAAI;gBAAE,MAAM,IAAI,kBAAkB,CAAC,GAAG,EAAE,qCAAqC,CAAC,CAAC;YAC1F,IAAI,GAAG,CAAC,MAAM,IAAI,IAAI;gBAAE,GAAG,CAAC,MAAM,GAAG,EAAE,CAAC,MAAM,CAAC;YAC/C,OAAO,GAAG,CAAC;QACb,CAAC,CAAC,CAAC;IACL,CAAC;IAED,+EAA+E;IAC/E,MAAM,CAAC,OAAO,CAAC,GAAsB;QACnC,MAAM,WAAW,GAAG,GAAG,CAAC,KAAK,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC;QAC5C,IAAI,WAAW,IAAI,IAAI;YAAE,OAAO,IAAI,CAAC;QACrC,IAAI,WAAW,CAAC,QAAQ,CAAC,GAAG,CAAC;YAAE,OAAO,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC,WAAW,CAAC,CAAC,CAAC;QAC9E,OAAO,WAAW,CAAC;IACrB,CAAC;IAED,MAAM,CAAC,KAAK,CAAC,IAAI,CAAC,GAAsB;QACtC,GAAG,CAAC,KAAK,CAAC,KAAK,CAAC,aAAa,CAAC,CAAC;QAC/B,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;QACrC,GAAG,CAAC,KAAK,CAAC,GAAG,CAAC,aAAa,CAAC,CAAC;QAC7B,OAAO,MAAM,CAAC;IAChB,CAAC;IAED,MAAM,CAAC,KAAK,CAAC,KAAK,CAAC,GAAsB;QACvC,MAAM,WAAW,GAAG,GAAG,CAAC,KAAK,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC;QAC5C,IAAI,WAAW,IAAI,IAAI;YAAE,OAAO,IAAI,CAAC,gBAAgB,CAAC,GAAG,CAAC,CAAC;QAE3D,MAAM,cAAc,GAAG,QAAQ,CAAC,WAAW,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,WAAW,CAAC,CAAC,CAAC,QAAQ,EAAE,CAAC,CAAC,CAAC,WAAW,CAAC;QAChH,MAAM,SAAS,GAAG,GAAG,CAAC,KAAK,CAAC,cAAc,CAAC,CAAC;QAE5C,IAAI,SAAS,IAAI,IAAI;YAAE,MAAM,IAAI,kBAAkB,CAAC,GAAG,EAAE,yBAAyB,CAAC,CAAC;QACpF,IAAI,CAAC,aAAa,CAAC,GAAG,CAAC,SAAS,CAAC,QAAQ,CAAC,EAAE,CAAC;YAC3C,MAAM,IAAI,kBAAkB,CAAC,GAAG,EAAE,2CAA2C,SAAS,CAAC,QAAQ,EAAE,CAAC,CAAC;QACrG,CAAC;QACD,IAAI,CAAC,WAAW,CAAC,GAAG,CAAC,SAAS,CAAC,QAAQ,CAAC,EAAE,CAAC;YACzC,MAAM,IAAI,kBAAkB,CAAC,GAAG,EAAE,YAAY,SAAS,CAAC,QAAQ,oCAAoC,CAAC,CAAC;QACxG,CAAC;QAED,OAAO,YAAY,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE;YAC5C,GAAG,CAAC,GAAG,CAAC,QAAQ,EAAE,SAAS,CAAC,IAAI,CAAC,CAAC;YAElC,IAAI,CAAC,IAAI,IAAI;gBAAE,MAAM,IAAI,kBAAkB,CAAC,GAAG,EAAE,8BAA8B,cAAc,EAAE,CAAC,CAAC;YACjG,OAAO,CAAC,CAAC;QACX,CAAC,CAAC,CAAC;IACL,CAAC;CACF"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@basemaps/lambda-tiler",
3
- "version": "8.12.2",
3
+ "version": "8.13.1",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/linz/basemaps.git",
@@ -23,11 +23,11 @@
23
23
  "license": "MIT",
24
24
  "dependencies": {
25
25
  "@basemaps/config": "^8.13.1",
26
- "@basemaps/config-loader": "^8.12.2",
26
+ "@basemaps/config-loader": "^8.12.4",
27
27
  "@basemaps/geo": "^8.9.0",
28
- "@basemaps/shared": "^8.9.6",
28
+ "@basemaps/shared": "^8.9.8",
29
29
  "@basemaps/tiler": "^8.12.2",
30
- "@basemaps/tiler-sharp": "^8.11.4",
30
+ "@basemaps/tiler-sharp": "^8.11.5",
31
31
  "@linzjs/geojson": "^8.0.0",
32
32
  "@linzjs/lambda": "^4.2.0",
33
33
  "@mapbox/vector-tile": "^2.0.3",
@@ -56,5 +56,5 @@
56
56
  "@types/pixelmatch": "^5.0.0",
57
57
  "pretty-json-log": "^1.0.0"
58
58
  },
59
- "gitHead": "8e7b083ad11f25ed8eb61a7d2382efe48d208bb5"
59
+ "gitHead": "3745d3ad58beacc0134127d7b82a882a7a2a20c8"
60
60
  }
@@ -1,7 +1,7 @@
1
- import { ConfigProviderMemory } from '@basemaps/config';
1
+ import { ConfigImagery, ConfigProviderMemory, ConfigTileSetRaster } from '@basemaps/config';
2
2
  import { initConfigFromUrls } from '@basemaps/config-loader';
3
3
  import { Tile, TileMatrixSet, TileMatrixSets } from '@basemaps/geo';
4
- import { fsa, LogConfig, setDefaultConfig } from '@basemaps/shared';
4
+ import { fsa, FsaLocalCache, LogConfig, setDefaultConfig } from '@basemaps/shared';
5
5
  import { LambdaHttpRequest, LambdaUrlRequest, UrlEvent } from '@linzjs/lambda';
6
6
  import { Context } from 'aws-lambda';
7
7
  import { extname } from 'path';
@@ -25,6 +25,9 @@ if (sourceRaw == null || tilePath == null) {
25
25
  `);
26
26
  process.exit(1);
27
27
  }
28
+
29
+ fsa.middleware.push(FsaLocalCache);
30
+
28
31
  const source = fsa.toUrl(sourceRaw);
29
32
  const tile = fromPath(tilePath);
30
33
  let tileMatrix: TileMatrixSet | null = null;
@@ -43,14 +46,27 @@ function fromPath(s: string): Tile & { extension: string } {
43
46
  return tile;
44
47
  }
45
48
 
49
+ async function loadConfig(
50
+ url: URL,
51
+ ): Promise<{ tileSet: ConfigTileSetRaster; imagery: ConfigImagery[]; cfg: ConfigProviderMemory }> {
52
+ if (url.pathname.endsWith('.json') || url.pathname.endsWith('.json.gz')) {
53
+ const cfg = ConfigProviderMemory.fromJson(await fsa.readJson(url), url);
54
+ LogConfig.get().info({ url: url.href }, 'ConfigLoaded');
55
+ const imagery = [...cfg.objects.values()].filter((f) => f.id.startsWith('im_')) as ConfigImagery[];
56
+ return { tileSet: cfg.imageryToTileSetByName(imagery[0]), imagery, cfg };
57
+ }
58
+
59
+ const cfg = new ConfigProviderMemory();
60
+ const { imagery } = await initConfigFromUrls(cfg, [url]);
61
+ return { tileSet: cfg.imageryToTileSetByName(imagery[0]), imagery, cfg };
62
+ }
63
+
46
64
  async function main(): Promise<void> {
47
65
  const log = LogConfig.get();
48
- log.level = 'trace';
49
- const provider = new ConfigProviderMemory();
50
- setDefaultConfig(provider);
51
- const { imagery } = await initConfigFromUrls(provider, [source]);
66
+ log.level = process.argv.includes('--verbose') ? 'trace' : 'debug';
52
67
 
53
- const tileSet = provider.imageryToTileSetByName(imagery[0]);
68
+ const { tileSet, imagery, cfg } = await loadConfig(source);
69
+ setDefaultConfig(cfg);
54
70
 
55
71
  log.info({ tileSet: tileSet.name, layers: tileSet.layers.length }, 'TileSet:Loaded');
56
72
 
package/src/index.ts CHANGED
@@ -48,11 +48,12 @@ handler.router.hook('request', (req) => {
48
48
  randomTrace(req);
49
49
 
50
50
  req.set('name', 'LambdaTiler');
51
+
52
+ req.log.debug({ ...req.logContext }, 'Lambda:Start');
51
53
  });
52
54
 
53
55
  handler.router.hook('response', (req, res) => {
54
56
  req.set('fetchCount', FsaLog.count);
55
- req.set('fetches', FsaLog.requests);
56
57
  req.set('cacheSize', FsaCache.size);
57
58
  // Force access-control-allow-origin to everything
58
59
  res.header('access-control-allow-origin', '*');
@@ -90,6 +91,7 @@ handler.router.get('/v1/fonts/:fontStack/:range.pbf', fontGet);
90
91
 
91
92
  // StyleJSON
92
93
  handler.router.get('/v1/styles/:styleName.json', styleJsonGet);
94
+
93
95
  /** @deprecated 2022-07-22 all styles should be being served from /v1/styles/:styleName.json */
94
96
  handler.router.get('/v1/tiles/:tileSet/:tileMatrix/style/:styleName.json', styleJsonGet);
95
97
 
@@ -2,30 +2,17 @@ import assert from 'node:assert';
2
2
  import { afterEach, before, beforeEach, describe, it } from 'node:test';
3
3
 
4
4
  import { ConfigProviderMemory } from '@basemaps/config';
5
- import { LogConfig } from '@basemaps/shared';
6
- import { LambdaAlbRequest, LambdaHttpRequest, LambdaHttpResponse } from '@linzjs/lambda';
5
+ import { LambdaHttpResponse } from '@linzjs/lambda';
7
6
  import { VectorTile, VectorTileFeature, VectorTileLayer } from '@mapbox/vector-tile';
8
- import { ALBEventRequestContext, Context } from 'aws-lambda';
9
7
  import sinon from 'sinon';
10
8
 
11
9
  import { FakeData } from '../../__tests__/config.data.js';
10
+ import { mockRequest } from '../../__tests__/xyz.util.js';
12
11
  import { ConfigLoader } from '../../util/config.loader.js';
13
12
  import { getTestBuffer, healthGet, TestFeature, TestTiles, VectorTileProvider } from '../health.js';
14
13
  import { TileXyzRaster } from '../tile.xyz.raster.js';
15
14
  import { tileXyzVector } from '../tile.xyz.vector.js';
16
15
 
17
- const ctx: LambdaHttpRequest = new LambdaAlbRequest(
18
- {
19
- requestContext: null as unknown as ALBEventRequestContext,
20
- httpMethod: 'get',
21
- path: '/v1/tiles/health',
22
- body: null,
23
- isBase64Encoded: false,
24
- },
25
- {} as Context,
26
- LogConfig.get(),
27
- );
28
-
29
16
  // Function to create a vector tile with specific properties for testing
30
17
  function createTestTile(testFeatures: TestFeature[]): VectorTile {
31
18
  const layers: Record<string, VectorTileLayer> = {};
@@ -65,14 +52,14 @@ describe('/v1/health', () => {
65
52
  sandbox.restore();
66
53
  });
67
54
 
68
- it('Should return bad response', () => {
55
+ it('Should return bad response', async () => {
69
56
  // Given ... a bad get tile response
70
57
  const BadResponse = new LambdaHttpResponse(500, 'Can not get Tile Set.');
71
58
  sandbox.stub(TileXyzRaster, 'tile').resolves(BadResponse);
72
59
  sandbox.stub(tileXyzVector, 'tile').resolves(BadResponse);
73
60
 
74
61
  // When ...Then ...
75
- assert.rejects(() => healthGet(ctx), BadResponse);
62
+ await assert.rejects(() => healthGet(mockRequest('/v1/tiles/health')), BadResponse);
76
63
  });
77
64
 
78
65
  const Response1 = new LambdaHttpResponse(200, 'ok');
@@ -99,20 +86,20 @@ describe('/v1/health', () => {
99
86
  callbackVectorTile.onCall(1).resolves(testVectorTile2);
100
87
 
101
88
  // When ...
102
- const res = await healthGet(ctx);
89
+ const res = await healthGet(mockRequest('/v1/tiles/health'));
103
90
 
104
91
  // Then ...
105
92
  assert.equal(res.status, 200);
106
93
  assert.equal(res.statusDescription, 'ok');
107
94
  });
108
95
 
109
- it('Should return mis-match tile response', () => {
96
+ it('Should return mis-match tile response', async () => {
110
97
  // Given ... a bad get tile response for second get tile
111
98
  const callback = sandbox.stub(TileXyzRaster, 'tile');
112
99
  callback.onCall(0).resolves(Response2);
113
100
 
114
101
  // When ... Then ...
115
102
  const BadResponse = new LambdaHttpResponse(500, 'TileSet does not match.');
116
- assert.rejects(() => healthGet(ctx), BadResponse);
103
+ await assert.rejects(() => healthGet(mockRequest('/v1/tiles/health')), BadResponse);
117
104
  });
118
105
  });
@@ -195,32 +195,16 @@ describe('/v1/styles', () => {
195
195
  assert.equal(res.header('content-type'), 'application/json');
196
196
  assert.equal(res.header('cache-control'), 'no-store');
197
197
 
198
- const body = Buffer.from(res.body ?? '', 'base64').toString();
199
- fakeStyle.sources['basemaps_vector'] = {
200
- type: 'vector',
201
- url: `${host}/vector?api=${Api.key}`,
202
- };
203
- fakeStyle.sources['basemaps_raster'] = {
204
- type: 'raster',
205
- tiles: [`${host}/raster?api=${Api.key}`],
206
- };
207
- fakeStyle.sources['basemaps_raster_encode'] = {
208
- type: 'raster',
209
- tiles: [`${host}/raster/{z}/{x}/{y}.webp?api=${Api.key}`],
210
- };
211
-
212
- fakeStyle.sources['basemaps_terrain'] = {
213
- type: 'raster-dem',
214
- tiles: [`${host}/elevation/{z}/{x}/{y}.png?pipeline=terrain-rgb&api=${Api.key}`],
215
- };
198
+ const targetStyle = JSON.parse(Buffer.from(res.body ?? '', 'base64').toString()) as StyleJson;
216
199
 
217
- fakeStyle.sprite = `${host}/sprite`;
218
- fakeStyle.glyphs = `${host}/glyphs`;
219
-
220
- assert.deepEqual(JSON.parse(body), fakeStyle);
200
+ assert.deepEqual(targetStyle.layers, fakeStyle.layers);
201
+ assert.deepEqual(Object.keys(targetStyle.sources), Object.keys(fakeStyle.sources));
202
+ assert.equal(targetStyle.glyphs, `${host}${fakeStyle.glyphs}`);
203
+ assert.equal(targetStyle.sprite, `${host}${fakeStyle.sprite}`);
221
204
  });
222
205
 
223
206
  it('should serve style json with excluded layers', async () => {
207
+ console.log(fakeRecord.style.layers.map((m) => m.id));
224
208
  config.put(fakeRecord);
225
209
  const request = mockUrlRequest(
226
210
  '/v1/tiles/topographic/Google/style/topographic.json',
@@ -233,25 +217,18 @@ describe('/v1/styles', () => {
233
217
  assert.equal(res.header('content-type'), 'application/json');
234
218
  assert.equal(res.header('cache-control'), 'no-store');
235
219
 
236
- const body = Buffer.from(res.body ?? '', 'base64').toString();
237
- fakeStyle.sources['basemaps_vector'] = {
238
- type: 'vector',
239
- url: `${host}/vector?api=${Api.key}`,
240
- };
241
- fakeStyle.sources['basemaps_raster'] = {
242
- type: 'raster',
243
- tiles: [`${host}/raster?api=${Api.key}`],
244
- };
245
- fakeStyle.sources['basemaps_raster_encode'] = {
246
- type: 'raster',
247
- tiles: [`${host}/raster/{z}/{x}/{y}.webp?api=${Api.key}`],
248
- };
249
-
250
- fakeStyle.sprite = `${host}/sprite`;
251
- fakeStyle.glyphs = `${host}/glyphs`;
252
- fakeStyle.layers = [fakeStyle.layers[2]];
220
+ const targetStyle = JSON.parse(Buffer.from(res.body ?? '', 'base64').toString()) as StyleJson;
221
+ assert.deepEqual(
222
+ targetStyle.layers.map((m) => m.id),
223
+ ['Background3'],
224
+ );
225
+ // original record should be preserved
226
+ assert.deepEqual(
227
+ fakeRecord.style.layers.map((m) => m.id),
228
+ ['Background1', 'Background2', 'Background3'],
229
+ );
253
230
 
254
- assert.deepEqual(JSON.parse(body), fakeStyle);
231
+ assert.deepEqual(Object.keys(targetStyle.sources), Object.keys(fakeStyle.sources));
255
232
  });
256
233
 
257
234
  it('should create raster styles', async () => {
@@ -371,6 +348,7 @@ describe('/v1/styles', () => {
371
348
  },
372
349
  id: 'Background1',
373
350
  type: 'background',
351
+ source: 'basemaps_vector',
374
352
  minzoom: 0,
375
353
  },
376
354
  ],
@@ -399,6 +377,63 @@ describe('/v1/styles', () => {
399
377
  ]);
400
378
  });
401
379
 
380
+ describe('style.merge', () => {
381
+ it('should merge terrain for all style config', async () => {
382
+ const request = mockUrlRequest('/v1/styles/aerial,topolite.json', '', Api.header);
383
+ config.put(fakeVectorRecord);
384
+ config.put(fakeAerialRecord);
385
+
386
+ const res = await handler.router.handle(request);
387
+ assert.equal(res.status, 200, res.statusDescription);
388
+
389
+ const body = JSON.parse(Buffer.from(res.body, 'base64').toString()) as StyleJson;
390
+ const aerialSource = body.sources['basemaps_raster'] as unknown as SourceRaster;
391
+
392
+ assert.equal(aerialSource.type, 'raster');
393
+ assert.equal(body.layers[0].source, 'basemaps_raster');
394
+ assert.equal(body.layers[1].source, 'basemaps_vector');
395
+ });
396
+
397
+ it('should fail merge terrain for duplicate layers', async () => {
398
+ const request = mockUrlRequest('/v1/styles/topolite,topolite.json', '', Api.header);
399
+ config.put(fakeVectorRecord);
400
+
401
+ const res = await handler.router.handle(request);
402
+
403
+ assert.equal(res.status, 400, res.statusDescription);
404
+ assert.equal(res.statusDescription.includes('duplicate layerIds'), true);
405
+ });
406
+
407
+ it('should fail merge terrain for large requests', async () => {
408
+ const layers = 'topolite,'.repeat(10).slice(0, -1);
409
+ const request = mockUrlRequest(`/v1/styles/${layers}.json`, '', Api.header);
410
+ config.put(fakeVectorRecord);
411
+
412
+ const res = await handler.router.handle(request);
413
+ assert.equal(res.status, 400, res.statusDescription);
414
+ assert.equal(res.statusDescription.includes('Too many styles'), true);
415
+ });
416
+
417
+ it('should skip merging empty style names', async () => {
418
+ const layers = ','.repeat(10);
419
+ const request = mockUrlRequest(`/v1/styles/${layers}topolite.json`, '', Api.header);
420
+ config.put(fakeVectorRecord);
421
+
422
+ const res = await handler.router.handle(request);
423
+ assert.equal(res.status, 200, res.statusDescription);
424
+ console.log(request.url);
425
+ });
426
+
427
+ it('should fail merge if one layer is missing', async () => {
428
+ const request = mockUrlRequest('/v1/styles/topolite,missing-layer.json', '', Api.header);
429
+ config.put(fakeVectorRecord);
430
+
431
+ const res = await handler.router.handle(request);
432
+
433
+ assert.equal(res.status, 404, res.statusDescription);
434
+ });
435
+ });
436
+
402
437
  const fakeAerialStyleConfig = {
403
438
  id: 'test',
404
439
  name: 'test',
@@ -420,8 +455,9 @@ describe('/v1/styles', () => {
420
455
  paint: {
421
456
  'background-color': 'rgba(206, 229, 242, 1)',
422
457
  },
423
- id: 'Background1',
424
- type: 'background',
458
+ id: 'aerial',
459
+ source: 'basemaps_raster',
460
+ type: 'raster',
425
461
  minzoom: 0,
426
462
  },
427
463
  ],
@@ -298,9 +298,51 @@ export interface StyleGet {
298
298
  };
299
299
  }
300
300
 
301
+ /**
302
+ * Join styles together
303
+ *
304
+ * Returns a new style json with sources and layers merged together
305
+ *
306
+ * @throws if there are any duplicate layerIds between the styles
307
+ * @param styles styles to merge together, the first style in the array will be used as the base style and urls will be merged into this style
308
+ * @returns
309
+ */
310
+ function mergeStyles(styles: StyleJson[]): StyleJson {
311
+ const target = structuredClone(styles[0]);
312
+ if (styles.length === 1) return target;
313
+
314
+ const layerId = new Map<string, string>();
315
+ for (const l of target.layers) layerId.set(l.id, target.id);
316
+
317
+ for (const st of styles.slice(1)) {
318
+ for (const newLayers of st.layers) {
319
+ if (layerId.has(newLayers.id)) {
320
+ const prev = layerId.get(newLayers.id);
321
+ throw new LambdaHttpResponse(
322
+ 400,
323
+ `Cannot merge styles with duplicate layerIds! styles: "${prev}" "${st.id}" layer: ${newLayers.id}`,
324
+ );
325
+ }
326
+ layerId.set(newLayers.id, st.id);
327
+ }
328
+
329
+ if (target.glyphs == null) target.glyphs = st.glyphs;
330
+ if (target.sprite == null) target.sprite = st.sprite;
331
+ if (target.sky == null) target.sky = st.sky;
332
+
333
+ Object.assign(target.sources, st.sources);
334
+ target.layers.push(...st.layers);
335
+ target.name = target.name + '_' + st.name;
336
+ target.id = target.id + '_' + st.id;
337
+ }
338
+
339
+ return target;
340
+ }
341
+
301
342
  export async function styleJsonGet(req: LambdaHttpRequest<StyleGet>): Promise<LambdaHttpResponse> {
302
343
  const apiKey = Validate.apiKey(req);
303
- const styleName = req.params.styleName;
344
+ const styleNames = req.params.styleName.split(',').filter((f) => f.trim().length > 0);
345
+ if (styleNames.length > 9) throw new LambdaHttpResponse(400, 'Too many styles requested, max 10');
304
346
 
305
347
  const tileMatrix = TileMatrixSets.find(req.query.get('tileMatrix') ?? GoogleTms.identifier);
306
348
  if (tileMatrix == null) return new LambdaHttpResponse(400, 'Invalid tile matrix');
@@ -311,23 +353,32 @@ export async function styleJsonGet(req: LambdaHttpRequest<StyleGet>): Promise<La
311
353
  if (excluded.size > 0) req.set('excludedLayers', [...excluded]);
312
354
 
313
355
  /**
314
- * Configuration options used for the landing page:
315
- * "terrain" - force add a terrain layer
316
- * "labels" - merge the labels style with the current style
356
+ * Force add a terrain layer
317
357
  *
318
- * TODO: (2024-08) this is not a very scalable way of configuring styles, it would be good to provide a styleJSON merge
358
+ * @deprecated 2026-02: use "/aerial,terrain-v2.json"
319
359
  */
320
360
  const terrain = req.query.get('terrain') ?? undefined;
361
+ /**
362
+ * Merge the labels style with the current style
363
+ *
364
+ * @deprecated 2026-02: use "/aerial,labels-v2.json"
365
+ */
321
366
  const labels = Boolean(req.query.get('labels') ?? false);
322
- req.set('styleConfig', { terrain, labels });
367
+ req.set('styleConfig', { terrain, labels, styles: styleNames });
323
368
 
324
369
  // Get style Config from db
325
370
  const config = await ConfigLoader.load(req);
326
- const styleConfig = await config.Style.get(styleName);
327
- const styleSource =
328
- styleConfig?.style ?? (await generateStyleFromTileSet(req, config, styleName, tileMatrix, apiKey));
329
371
 
330
- const targetStyle = structuredClone(styleSource);
372
+ const styles = await Promise.all(
373
+ styleNames.map(async (styleName) => {
374
+ const styleConfig = await config.Style.get(styleName);
375
+ if (styleConfig?.style != null) return styleConfig.style;
376
+ return generateStyleFromTileSet(req, config, styleName, tileMatrix, apiKey);
377
+ }),
378
+ );
379
+
380
+ const targetStyle = mergeStyles(styles);
381
+
331
382
  // Ensure elevation for style json config
332
383
  // TODO: We should remove this after adding terrain source into style configs. PR-916
333
384
  await ensureTerrain(req, tileMatrix, apiKey, targetStyle);
@@ -25,10 +25,10 @@ export class ConfigLoader {
25
25
  const cb = await config.ConfigBundle.get('cb_latest');
26
26
  if (cb == null) return config;
27
27
 
28
- req?.timer.start('config:load');
28
+ const configUrl = fsa.toUrl(cb.path);
29
+ return CachedConfig.get(configUrl).then((cfg) => {
30
+ req?.set('config', configUrl.href);
29
31
 
30
- return CachedConfig.get(fsa.toUrl(cb.path)).then((cfg) => {
31
- req?.timer.end('config:load');
32
32
  if (cfg == null) throw new LambdaHttpResponse(500, 'Unable to find latest configuration');
33
33
  if (cfg.assets == null) cfg.assets = cb.assets;
34
34
  return cfg;
@@ -44,8 +44,15 @@ export class ConfigLoader {
44
44
  }
45
45
 
46
46
  static async load(req: LambdaHttpRequest): Promise<BasemapsConfigProvider> {
47
+ req.timer.start('config:load');
48
+ const config = await this._load(req);
49
+ req.timer.end('config:load');
50
+ return config;
51
+ }
52
+
53
+ static async _load(req: LambdaHttpRequest): Promise<BasemapsConfigProvider> {
47
54
  const rawLocation = req.query.get('config');
48
- if (rawLocation == null) return this.getDefaultConfig();
55
+ if (rawLocation == null) return this.getDefaultConfig(req);
49
56
 
50
57
  const configLocation = isBase58(rawLocation) ? Buffer.from(base58.decode(rawLocation)).toString() : rawLocation;
51
58
  const configUrl = fsa.toUrl(configLocation);
@@ -58,10 +65,9 @@ export class ConfigLoader {
58
65
  throw new LambdaHttpResponse(400, `Bucket: "${configUrl.hostname}" is not a allowed bucket location`);
59
66
  }
60
67
 
61
- req.set('config', configUrl.href);
62
- req.timer.start('config:load');
63
68
  return CachedConfig.get(configUrl).then((f) => {
64
- req.timer.end('config:load');
69
+ req.set('config', configUrl.href);
70
+
65
71
  if (f == null) throw new LambdaHttpResponse(400, `Invalid config location at ${configLocation}`);
66
72
  return f;
67
73
  });