From 8170390ba576954d249d693cd72b61cdbe0e41b5 Mon Sep 17 00:00:00 2001 From: razbroc Date: Mon, 24 Aug 2026 15:31:55 +0300 Subject: [PATCH 1/5] fix: type the mapproxy caches section as a map of Caches IMapProxyJsonDocument.caches was typed as a single Cache rather than a map of them, so every caches[name] lookup resolved to any through the Cache type's permissive index signature, and the casts around those lookups were unchecked. No behaviour change. --- src/common/interfaces.ts | 2 +- src/layers/models/layersManager.ts | 1 - .../unit/layers/models/layersManager.spec.ts | 20 +++++++------------ 3 files changed, 8 insertions(+), 15 deletions(-) diff --git a/src/common/interfaces.ts b/src/common/interfaces.ts index 51195d9..3568747 100644 --- a/src/common/interfaces.ts +++ b/src/common/interfaces.ts @@ -82,7 +82,7 @@ export interface IRedisConfig { export interface IMapProxyJsonDocument { services: JsonObject; layers: IMapProxyLayer[]; - caches: IMapProxyCache; + caches: Record; grids: JsonObject; globals: IMapProxyGlobalConfig; } diff --git a/src/layers/models/layersManager.ts b/src/layers/models/layersManager.ts index c39c6ec..8a51719 100644 --- a/src/layers/models/layersManager.ts +++ b/src/layers/models/layersManager.ts @@ -66,7 +66,6 @@ class LayersManager { // our current only real cache layer, other caches cases are known as the source layers const cacheName = isSourceType(cacheType) && cacheType === SourceTypes.REDIS ? getRedisCacheName(layerName) : layerName; - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const currentSourceCache: IMapProxyCache | undefined = configJson.caches[cacheName]; if (currentSourceCache === undefined) { diff --git a/tests/unit/layers/models/layersManager.spec.ts b/tests/unit/layers/models/layersManager.spec.ts index b1cf06d..79621b3 100644 --- a/tests/unit/layers/models/layersManager.spec.ts +++ b/tests/unit/layers/models/layersManager.spec.ts @@ -4,7 +4,7 @@ import { container } from 'tsyringe'; import { jsLogger, type Logger } from '@map-colonies/js-logger'; import { BadRequestError, ConflictError, NotFoundError, NotImplementedError } from '@map-colonies/error-types'; import { lookup as mimeLookup, TilesMimeFormat } from '@map-colonies/types'; -import { ILayerPostRequest, IMapProxyCache, IMapProxyConfig, IRedisConfig } from '../../../../src/common/interfaces'; +import { ILayerPostRequest, IMapProxyCache, IMapProxyConfig, IRedisConfig, IS3Source } from '../../../../src/common/interfaces'; import { LayersManager } from '../../../../src/layers/models/layersManager'; import { mockLayerNameAlreadyExists } from '../../mock/mockLayerNameAlreadyExists'; import { mockLayerNameIsNotExists } from '../../mock/mockLayerNameIsNotExists'; @@ -247,8 +247,7 @@ describe('layersManager', () => { await expect(layersManager.addLayer(mockLayerNameIsNotExists)).toResolve(); const resultJson = await MockConfigProvider.getJson(); - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - expect(resultJson.caches[mockLayerNameIsNotExists.name].cache.use_http_get).toBe(true); + expect((resultJson.caches[mockLayerNameIsNotExists.name]?.cache as IS3Source).use_http_get).toBe(true); expect(updateJsonMock).toHaveBeenCalledTimes(1); }); @@ -267,8 +266,7 @@ describe('layersManager', () => { await expect(layersManager.addLayer(mockLayerNameIsNotExists)).toResolve(); const resultJson = await MockConfigProvider.getJson(); - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - expect(resultJson.caches[mockLayerNameIsNotExists.name].cache.use_http_get).toBe(false); + expect((resultJson.caches[mockLayerNameIsNotExists.name]?.cache as IS3Source).use_http_get).toBe(false); expect(updateJsonMock).toHaveBeenCalledTimes(1); }); }); @@ -368,10 +366,8 @@ describe('layersManager', () => { jest.spyOn(configManager, 'getConfig').mockResolvedValue(mockData()); //check data const data = await MockConfigProvider.getJson(); - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - expect(data.caches[mockLayerName].format).toBe(expectedTileMimeFormatPng); - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - expect(data.caches[mockRedisLayerName].format).toBe(expectedTileMimeFormatPng); + expect(data.caches[mockLayerName]?.format).toBe(expectedTileMimeFormatPng); + expect(data.caches[mockRedisLayerName]?.format).toBe(expectedTileMimeFormatPng); // action const action = layersManager.updateLayer(mockLayerName, mockUpdateLayerRequest); @@ -380,10 +376,8 @@ describe('layersManager', () => { expect.assertions(6); await expect(action).toResolve(); const result = await MockConfigProvider.getJson(); - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - expect(result.caches[mockLayerName].format).toBe(expectedTileMimeFormatJpeg); - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - expect(result.caches[mockRedisLayerName].format).toBe(expectedTileMimeFormatJpeg); + expect(result.caches[mockLayerName]?.format).toBe(expectedTileMimeFormatJpeg); + expect(result.caches[mockRedisLayerName]?.format).toBe(expectedTileMimeFormatJpeg); expect(updateJsonMock).toHaveBeenCalledTimes(1); }); From fd7d684ba3e23c7a70bcc92e25741094049f2aa2 Mon Sep 17 00:00:00 2001 From: razbroc Date: Mon, 24 Aug 2026 15:40:53 +0300 Subject: [PATCH 2/5] feat: return the whole Cache from GET /layer/{layerName}/{cacheType} The endpoint returned only the Cache Source, so a consumer needing the Cache's grids, format or sources had to fall back on the deprecated GET /layer/{name}, which has no Cache Type selection and cannot reach a Redis Cache at all. The Cache is now spread verbatim under its resolved name, with the Cache Source left in place at 'cache' so existing consumers are unaffected and the new fields are additive. Cache Type resolution is unchanged. readCacheType lifts the Cache Type out of an untrusted entry, so an entry that is not an object or holds no Cache Source is the same 400 as any other Cache Type mismatch instead of a 500. There is deliberately no notion of a malformed Cache: nothing else about the entry is inspected. --- src/common/interfaces.ts | 11 ++- src/common/utils.ts | 15 ++++ src/layers/controllers/layersController.ts | 4 +- src/layers/models/layersManager.ts | 21 ++--- .../integration/layers/layersManager.spec.ts | 88 ++++++++++++++++++- .../unit/layers/models/layersManager.spec.ts | 12 ++- tests/unit/mock/mockJson.json | 10 ++- 7 files changed, 133 insertions(+), 28 deletions(-) diff --git a/src/common/interfaces.ts b/src/common/interfaces.ts index 3568747..ceb2773 100644 --- a/src/common/interfaces.ts +++ b/src/common/interfaces.ts @@ -120,10 +120,13 @@ export interface ICacheName { cacheName: string; } -export interface ICacheObject { - cacheName: string; - cache: IRedisSource | IS3Source | IFSSource; -} +/** + * The response of GET /layer/{layerName}/{cacheType}: the Cache name, and the Cache spread + * verbatim over it. Only the Cache name and the Cache Source are declared, because they are + * all that is verified — every other key is whatever the configuration held, so a consumer + * must narrow rather than trust. + */ +export type IGetCacheResponse = ICacheName & Pick & Record; export interface IGpkgSource extends ICacheSource { filename: string; diff --git a/src/common/utils.ts b/src/common/utils.ts index b104dcf..49a5aa3 100644 --- a/src/common/utils.ts +++ b/src/common/utils.ts @@ -61,6 +61,21 @@ export function isLayerNameSuffixRedis(layerName: string): boolean { return layerName.endsWith('-redis'); } +/** + * Lift the Cache Type out of an untrusted configuration entry. + * + * This is the one place that reads a `caches` entry without trusting its type: production + * configurations hold entries that are not well formed Caches. Nothing else about the entry is + * inspected or asserted, so the codebase carries no notion of a malformed Cache. + * @param cache an entry of the configuration's `caches` section, as read + * @return string - the entry's Cache Type, or undefined if the entry does not state one + */ +export function readCacheType(cache: unknown): string | undefined { + const cacheSource = (cache as { cache?: unknown } | null | undefined)?.cache; + const cacheType = (cacheSource as { type?: unknown } | null | undefined)?.type; + return typeof cacheType === 'string' ? cacheType : undefined; +} + export function adjustTilesPath(tilesPath: string, cacheSource: SourceTypes): string { const fsConfig = container.resolve(SERVICES.FS); switch (cacheSource) { diff --git a/src/layers/controllers/layersController.ts b/src/layers/controllers/layersController.ts index ac1dc15..9f62bb1 100644 --- a/src/layers/controllers/layersController.ts +++ b/src/layers/controllers/layersController.ts @@ -3,12 +3,12 @@ import type { RequestHandler } from 'express'; import httpStatus from 'http-status-codes'; import { injectable, inject } from 'tsyringe'; import { SERVICES } from '../../common/constants'; -import type { ICacheName, ILayerPostRequest, IMapProxyCache } from '../../common/interfaces'; +import type { IGetCacheResponse, ILayerPostRequest, IMapProxyCache } from '../../common/interfaces'; import { LayersManager } from '../models/layersManager'; type CreateLayerHandler = RequestHandler; type GetLayerHandler = RequestHandler<{ name: string }, IMapProxyCache, IMapProxyCache>; -type GetCacheHandler = RequestHandler<{ layerName: string; cacheType: string }, ICacheName>; +type GetCacheHandler = RequestHandler<{ layerName: string; cacheType: string }, IGetCacheResponse>; type UpdateLayerHandler = RequestHandler<{ name: string }, ILayerPostRequest, ILayerPostRequest>; type DeleteLayerHandler = RequestHandler; @injectable() diff --git a/src/layers/models/layersManager.ts b/src/layers/models/layersManager.ts index 8a51719..766dcda 100644 --- a/src/layers/models/layersManager.ts +++ b/src/layers/models/layersManager.ts @@ -17,10 +17,7 @@ import type { ICacheProvider, ICacheSource, IRedisConfig, - ICacheObject, - IRedisSource, - IS3Source, - IFSSource, + IGetCacheResponse, } from '../../common/interfaces'; import { isLayerNameExists } from '../../common/validations/isLayerNameExists'; import { S3Source } from '../../common/cacheProviders/S3Source'; @@ -29,7 +26,7 @@ import { FSSource } from '../../common/cacheProviders/fsSource'; import { isSourceType, SourceTypes, sourceTypeValues } from '../../common/enums'; import { RedisSource } from '../../common/cacheProviders/redisSource'; import { ConfigsManager } from '../../configs/models/configsManager'; -import { getRedisCacheName, getRedisCacheOriginalName, isLayerNameSuffixRedis } from '../../common/utils'; +import { getRedisCacheName, getRedisCacheOriginalName, isLayerNameSuffixRedis, readCacheType } from '../../common/utils'; @injectable() class LayersManager { @@ -54,7 +51,7 @@ class LayersManager { } @withSpanAsyncV4 - public async getCacheByNameAndType(layerName: string, cacheType: string): Promise { + public async getCacheByNameAndType(layerName: string, cacheType: string): Promise { const configJson = await this.configProvider.getJson(); const requestedLayer = configJson.layers.find((layer) => layer.name === layerName); @@ -66,24 +63,22 @@ class LayersManager { // our current only real cache layer, other caches cases are known as the source layers const cacheName = isSourceType(cacheType) && cacheType === SourceTypes.REDIS ? getRedisCacheName(layerName) : layerName; - const currentSourceCache: IMapProxyCache | undefined = configJson.caches[cacheName]; + const requestedCache: IMapProxyCache | undefined = configJson.caches[cacheName]; - if (currentSourceCache === undefined) { + if (requestedCache === undefined) { const errorMsg = `cache not found for ${layerName} layer`; this.logger.warn({ msg: errorMsg, layerName, cacheType }); throw new NotFoundError(errorMsg); } - if (currentSourceCache.cache.type !== cacheType) { + if (readCacheType(requestedCache) !== cacheType) { const errorMsg = `${layerName} layer cache not found with requested cache type: ${cacheType}`; - this.logger.warn({ msg: errorMsg, layerName, cacheType }); + this.logger.warn({ msg: errorMsg, layerName, cacheType, requestedCache }); throw new BadRequestError(errorMsg); } - type AvailableSources = IRedisSource | IS3Source | IFSSource; - return { cacheName: cacheName, - cache: currentSourceCache.cache as AvailableSources, + ...requestedCache, }; } diff --git a/tests/integration/layers/layersManager.spec.ts b/tests/integration/layers/layersManager.spec.ts index c061fc6..f6d2a2d 100644 --- a/tests/integration/layers/layersManager.spec.ts +++ b/tests/integration/layers/layersManager.spec.ts @@ -1,7 +1,7 @@ import { promises as fsp } from 'node:fs'; import httpStatusCodes from 'http-status-codes'; import { container } from 'tsyringe'; -import { ICacheName, ILayerPostRequest, IMapProxyCache } from '../../../src/common/interfaces'; +import { ILayerPostRequest, IMapProxyCache } from '../../../src/common/interfaces'; import { mockLayerNameIsNotExists } from '../../unit/mock/mockLayerNameIsNotExists'; import { mockLayerNameAlreadyExists } from '../../unit/mock/mockLayerNameAlreadyExists'; import { init as configProviderInit, updateJsonMock } from '../../unit/mock/mockConfigProvider'; @@ -65,14 +65,53 @@ describe('layerManager', () => { }); describe('#getLayersCache', () => { - it('Happy Path - should return status 200 and the cacheName', async () => { + it('Happy Path - should return status 200 and the whole Cache of an s3 Cache', async () => { const response = await requestSender.getLayersCache('mockLayerNameExists', 's3'); expect(response.status).toBe(httpStatusCodes.OK); + expect(response).toSatisfyApiSpec(); + expect(response.body).toEqual({ + cacheName: 'mockLayerNameExists', + sources: [], + grids: ['epsg4326dir'], + format: 'image/png', + // eslint-disable-next-line @typescript-eslint/naming-convention + upscale_tiles: 18, + // eslint-disable-next-line @typescript-eslint/naming-convention + cache: { type: 's3', directory: '/path/to/s3/directory/tile', directory_layout: 'tms' }, + }); + }); + + it('Happy Path - should resolve a redis request to the -redis Cache and return it whole', async () => { + const response = await requestSender.getLayersCache('redisExists', 'redis'); + + expect(response.status).toBe(httpStatusCodes.OK); + expect(response).toSatisfyApiSpec(); + expect(response.body).toEqual({ + cacheName: 'redisExists-redis', + sources: ['redisExists'], + grids: ['epsg4326dir'], + format: 'image/png', + cache: { + host: 'raster-mapproxy-redis-master', + port: 6379, + username: 'mapcolonies', + password: 'mapcolonies', + prefix: 'mcrl:', + type: 'redis', + // eslint-disable-next-line @typescript-eslint/naming-convention + default_ttl: 86400, + }, + }); + }); + + it('Happy Path - should return mapproxy options this service does not model, verbatim', async () => { + const response = await requestSender.getLayersCache('NameIsAlreadyExists', 's3'); - const resource = response.body as ICacheName; + expect(response.status).toBe(httpStatusCodes.OK); expect(response).toSatisfyApiSpec(); - expect(resource.cacheName).toBe('mockLayerNameExists'); + expect(response.body).toHaveProperty('link_single_color_images', true); + expect(response.body).toHaveProperty('cache.region', 'us-east-1'); }); it('Sad Path - should fail with response status 404 Not Found and layer name is not exists', async () => { @@ -85,6 +124,47 @@ describe('layerManager', () => { expect(response.body).toEqual({ message: notFoundErrorMessage }); }); + it('Sad Path - should fail with response status 400 when the Cache is of another Cache Type', async () => { + const mockLayerName = 'mockLayerNameExists'; + const cacheType = 'file'; + const response = await requestSender.getLayersCache(mockLayerName, cacheType); + const badRequestMessage = `${mockLayerName} layer cache not found with requested cache type: ${cacheType}`; + + expect(response).toSatisfyApiSpec(); + expect(response.status).toBe(httpStatusCodes.BAD_REQUEST); + expect(response.body).toEqual({ message: badRequestMessage }); + }); + + it('Sad Path - should fail with response status 404 when the Layer has no Cache under the resolved name', async () => { + const mockLayerName = 'noCacheForLayer'; + const response = await requestSender.getLayersCache(mockLayerName, 's3'); + const notFoundErrorMessage = `cache not found for ${mockLayerName} layer`; + + expect(response).toSatisfyApiSpec(); + expect(response.status).toBe(httpStatusCodes.NOT_FOUND); + expect(response.body).toEqual({ message: notFoundErrorMessage }); + }); + + it('Sad Path - should fail with response status 400 when the configuration entry is not an object', async () => { + const mockLayerName = 'mock'; + const response = await requestSender.getLayersCache(mockLayerName, 's3'); + const badRequestMessage = `${mockLayerName} layer cache not found with requested cache type: s3`; + + expect(response).toSatisfyApiSpec(); + expect(response.status).toBe(httpStatusCodes.BAD_REQUEST); + expect(response.body).toEqual({ message: badRequestMessage }); + }); + + it('Sad Path - should fail with response status 400 when the configuration entry holds no Cache Source', async () => { + const mockLayerName = 'combined_layers'; + const response = await requestSender.getLayersCache(mockLayerName, 's3'); + const badRequestMessage = `${mockLayerName} layer cache not found with requested cache type: s3`; + + expect(response).toSatisfyApiSpec(); + expect(response.status).toBe(httpStatusCodes.BAD_REQUEST); + expect(response.body).toEqual({ message: badRequestMessage }); + }); + it('Sad Path - should fail with error not valid type format', async () => { const mockLayerName = 'mockLayerNameIsExists'; const cacheType = 'notValid'; diff --git a/tests/unit/layers/models/layersManager.spec.ts b/tests/unit/layers/models/layersManager.spec.ts index 79621b3..14b8677 100644 --- a/tests/unit/layers/models/layersManager.spec.ts +++ b/tests/unit/layers/models/layersManager.spec.ts @@ -105,9 +105,14 @@ describe('layersManager', () => { }); describe('#getCacheByNameAndType', () => { - it('should successfully return the cache name', async () => { + it('should successfully return the whole Cache and its name', async () => { const expectedCache = { cacheName: 'mockLayerNameExists', + sources: [], + grids: ['epsg4326dir'], + format: 'image/png', + // eslint-disable-next-line @typescript-eslint/naming-convention + upscale_tiles: 18, // eslint-disable-next-line @typescript-eslint/naming-convention cache: { directory: '/path/to/s3/directory/tile', directory_layout: 'tms', type: 's3' }, }; @@ -137,12 +142,13 @@ describe('layersManager', () => { // expectation; await expect(action).rejects.toThrow(new NotFoundError(`cache not found for ${layerName} layer`)); }); - it('should fail with not valid source type', async () => { + + it('should fail with bad request when the Cache Type cannot be confirmed', async () => { // action expect.assertions(1); const action = layersManager.getCacheByNameAndType('mockLayerNameExists', 'notValidType'); // expectation; - await expect(action).rejects.toThrow(new NotFoundError(`mockLayerNameExists layer cache not found with requested cache type: notValidType`)); + await expect(action).rejects.toThrow(new BadRequestError(`mockLayerNameExists layer cache not found with requested cache type: notValidType`)); }); }); diff --git a/tests/unit/mock/mockJson.json b/tests/unit/mock/mockJson.json index 701c912..d29a449 100644 --- a/tests/unit/mock/mockJson.json +++ b/tests/unit/mock/mockJson.json @@ -32,17 +32,18 @@ "grids": ["epsg4326dir"], "format": "image/png", "upscale_tiles": 18, + "link_single_color_images": true, "cache": { "type": "s3", "directory": "/path/to/s3/directory/tile", - "directory_layout": "tms" + "directory_layout": "tms", + "region": "us-east-1" } }, "redisExists-redis": { "sources": ["redisExists"], "grids": ["epsg4326dir"], "format": "image/png", - "upscale_tiles": 18, "cache": { "host": "raster-mapproxy-redis-master", "port": 6379, @@ -86,6 +87,11 @@ "title": "title", "sources": ["source"] }, + { + "name": "combined_layers", + "title": "title", + "sources": ["mock"] + }, { "name": "mock2", "title": "title", From 2d959b18aaa6e592bfcd8805437ed4af03b897cc Mon Sep 17 00:00:00 2001 From: razbroc Date: Mon, 24 Aug 2026 15:51:09 +0300 Subject: [PATCH 3/5] feat: loosen the Cache Source schemas and publish the geopackage schema Every Cache Source schema now requires only 'type' and permits additional properties, so a Cache Source missing an optional field no longer makes a valid response contract-invalid. getCacheResponse requires only cacheName and cache, documents sources, grids, format, upscale_tiles and minimize_meta_requests as optional, and permits additional properties at both levels: production redis Caches carry neither upscale_tiles nor minimize_meta_requests, so anything stricter would make real responses violate their own spec. geopackage was an accepted cacheType with no response schema. It is added to both the oneOf and the discriminator mapping - without the mapping entry a real geopackage response fails the contract regardless of tests. The response examples showed only the Cache Source and are updated to the whole Cache. The request schemas are separate objects and are untouched. --- openapi3.yaml | 101 ++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 93 insertions(+), 8 deletions(-) diff --git a/openapi3.yaml b/openapi3.yaml index 0af14d4..41d4704 100644 --- a/openapi3.yaml +++ b/openapi3.yaml @@ -241,6 +241,12 @@ paths: summary: file cache value: cacheName: example-layer + sources: [] + grids: + - epsg4326dir + format: image/png + upscale_tiles: 18 + minimize_meta_requests: true cache: type: file directory: >- @@ -250,16 +256,43 @@ paths: summary: s3 cache value: cacheName: example-layer + sources: [] + grids: + - epsg4326dir + format: image/png + upscale_tiles: 18 + minimize_meta_requests: true cache: type: s3 directory: >- /5eedfc75-861c-42fc-81a9-ab2c0b95c274/d8527f15-5377-4a28-b5ce-92891d897aec/ directory_layout: tms bucket_name: bucket-name + geopackage: + summary: geopackage cache + value: + cacheName: example-layer + sources: [] + grids: + - epsg4326dir + format: image/png + upscale_tiles: 18 + minimize_meta_requests: true + cache: + type: geopackage + filename: /path/to/tiles/directory/example-layer.gpkg + table_name: example-layer redis: - summary: redis cache + summary: >- + redis cache, resolved by the '-redis' suffix. It carries no + upscale_tiles and no minimize_meta_requests. value: cacheName: example-layer-redis + sources: + - example-layer + grids: + - epsg4326dir + format: image/png cache: host: mapproxy-redis-master port: 6379 @@ -355,10 +388,12 @@ components: - image/jpeg fileCache: type: object + description: >- + A file cache source. Only `type` is guaranteed; a cache source missing an + optional field is still a valid response. required: - type - - directory - - directory_layout + additionalProperties: true properties: type: type: string @@ -373,10 +408,12 @@ components: example: tms s3Cache: type: object + description: >- + An s3 cache source. Only `type` is guaranteed; a cache source missing an + optional field is still a valid response. required: - type - - directory - - directory_layout + additionalProperties: true properties: type: type: string @@ -396,11 +433,12 @@ components: example: bucket-name redisCache: type: object + description: >- + A redis cache source. Only `type` is guaranteed; a cache source missing an + optional field is still a valid response. required: - type - - host - - port - - default_ttl + additionalProperties: true properties: type: type: string @@ -423,25 +461,72 @@ components: default_ttl: type: integer example: 86400 + geopackageCache: + type: object + description: >- + A geopackage cache source. Only `type` is guaranteed; a cache source missing + an optional field is still a valid response. + required: + - type + additionalProperties: true + properties: + type: + type: string + enum: + - geopackage + filename: + type: string + example: /path/to/tiles/directory/amsterdam_5cm.gpkg + table_name: + type: string + example: amsterdam_5cm getCacheResponse: type: object + description: >- + The whole cache as written in the mapproxy configuration, alongside its name. + Only `cacheName` and `cache` are guaranteed: the cache is returned verbatim, so + mapproxy options this service does not model are present too, and options a cache + genuinely lacks are absent rather than null or defaulted. required: - cacheName - cache + additionalProperties: true properties: cacheName: type: string + description: >- + The resolved cache name, which for a redis request is the '-redis' suffixed + name rather than the requested layer name. + example: example-layer + sources: + type: array + items: + type: string + grids: + type: array + items: + type: string + format: + type: string + example: image/png + upscale_tiles: + type: number + example: 18 + minimize_meta_requests: + type: boolean cache: oneOf: - $ref: '#/components/schemas/fileCache' - $ref: '#/components/schemas/s3Cache' - $ref: '#/components/schemas/redisCache' + - $ref: '#/components/schemas/geopackageCache' discriminator: propertyName: type mapping: file: '#/components/schemas/fileCache' s3: '#/components/schemas/s3Cache' redis: '#/components/schemas/redisCache' + geopackage: '#/components/schemas/geopackageCache' getConfigResponse: type: object properties: From 084e4ea2cf3d5468f19da324e4fe8b77c5361a25 Mon Sep 17 00:00:00 2001 From: razbroc Date: Mon, 24 Aug 2026 16:33:00 +0300 Subject: [PATCH 4/5] fix: answer 404 rather than 400 when no Cache of the requested Cache Type exists The Cache Type mismatch answered 400 on master, with the message 'layer cache not found with requested cache type'. The status and the message disagreed, and the message was the honest half: when the Cache Type is in the enum but the Layer owns no Cache of that type, nothing about the request is malformed - the addressed Cache simply is not there. Every 'the addressed Cache is not there' case now answers alike: Layer absent, no Cache under the resolved name, and a Cache Type that cannot be confirmed. A Cache Type outside the enum still answers 400 from request validation, before the manager runs. The unreadable entry follows the mismatch to 404 rather than keeping a 400 of its own. Telling 'the Cache Type is confirmably something else' from 'the Cache Type could not be confirmed' would reintroduce the malformedness concept the design deliberately does not carry, and a 400 would blame the caller for the server's own corrupt configuration. The operator's signal stays the warn log carrying the offending entry. BEHAVIOUR CHANGE to a released endpoint: a consumer branching on 400 sees 404. --- src/layers/models/layersManager.ts | 4 +++- .../integration/layers/layersManager.spec.ts | 24 +++++++++---------- .../unit/layers/models/layersManager.spec.ts | 4 ++-- 3 files changed, 17 insertions(+), 15 deletions(-) diff --git a/src/layers/models/layersManager.ts b/src/layers/models/layersManager.ts index 766dcda..04fe1d3 100644 --- a/src/layers/models/layersManager.ts +++ b/src/layers/models/layersManager.ts @@ -70,10 +70,12 @@ class LayersManager { this.logger.warn({ msg: errorMsg, layerName, cacheType }); throw new NotFoundError(errorMsg); } + // Nothing about the request is malformed: the Layer and the Cache Type are both well formed + // and the Cache Type is in the enum. There is simply no Cache of that Cache Type to address. if (readCacheType(requestedCache) !== cacheType) { const errorMsg = `${layerName} layer cache not found with requested cache type: ${cacheType}`; this.logger.warn({ msg: errorMsg, layerName, cacheType, requestedCache }); - throw new BadRequestError(errorMsg); + throw new NotFoundError(errorMsg); } return { diff --git a/tests/integration/layers/layersManager.spec.ts b/tests/integration/layers/layersManager.spec.ts index f6d2a2d..d209a2f 100644 --- a/tests/integration/layers/layersManager.spec.ts +++ b/tests/integration/layers/layersManager.spec.ts @@ -124,15 +124,15 @@ describe('layerManager', () => { expect(response.body).toEqual({ message: notFoundErrorMessage }); }); - it('Sad Path - should fail with response status 400 when the Cache is of another Cache Type', async () => { + it('Sad Path - should fail with response status 404 when the Cache is of another Cache Type', async () => { const mockLayerName = 'mockLayerNameExists'; const cacheType = 'file'; const response = await requestSender.getLayersCache(mockLayerName, cacheType); - const badRequestMessage = `${mockLayerName} layer cache not found with requested cache type: ${cacheType}`; + const notFoundErrorMessage = `${mockLayerName} layer cache not found with requested cache type: ${cacheType}`; expect(response).toSatisfyApiSpec(); - expect(response.status).toBe(httpStatusCodes.BAD_REQUEST); - expect(response.body).toEqual({ message: badRequestMessage }); + expect(response.status).toBe(httpStatusCodes.NOT_FOUND); + expect(response.body).toEqual({ message: notFoundErrorMessage }); }); it('Sad Path - should fail with response status 404 when the Layer has no Cache under the resolved name', async () => { @@ -145,24 +145,24 @@ describe('layerManager', () => { expect(response.body).toEqual({ message: notFoundErrorMessage }); }); - it('Sad Path - should fail with response status 400 when the configuration entry is not an object', async () => { + it('Sad Path - should fail with response status 404 when the configuration entry is not an object', async () => { const mockLayerName = 'mock'; const response = await requestSender.getLayersCache(mockLayerName, 's3'); - const badRequestMessage = `${mockLayerName} layer cache not found with requested cache type: s3`; + const notFoundErrorMessage = `${mockLayerName} layer cache not found with requested cache type: s3`; expect(response).toSatisfyApiSpec(); - expect(response.status).toBe(httpStatusCodes.BAD_REQUEST); - expect(response.body).toEqual({ message: badRequestMessage }); + expect(response.status).toBe(httpStatusCodes.NOT_FOUND); + expect(response.body).toEqual({ message: notFoundErrorMessage }); }); - it('Sad Path - should fail with response status 400 when the configuration entry holds no Cache Source', async () => { + it('Sad Path - should fail with response status 404 when the configuration entry holds no Cache Source', async () => { const mockLayerName = 'combined_layers'; const response = await requestSender.getLayersCache(mockLayerName, 's3'); - const badRequestMessage = `${mockLayerName} layer cache not found with requested cache type: s3`; + const notFoundErrorMessage = `${mockLayerName} layer cache not found with requested cache type: s3`; expect(response).toSatisfyApiSpec(); - expect(response.status).toBe(httpStatusCodes.BAD_REQUEST); - expect(response.body).toEqual({ message: badRequestMessage }); + expect(response.status).toBe(httpStatusCodes.NOT_FOUND); + expect(response.body).toEqual({ message: notFoundErrorMessage }); }); it('Sad Path - should fail with error not valid type format', async () => { diff --git a/tests/unit/layers/models/layersManager.spec.ts b/tests/unit/layers/models/layersManager.spec.ts index 14b8677..384c7d1 100644 --- a/tests/unit/layers/models/layersManager.spec.ts +++ b/tests/unit/layers/models/layersManager.spec.ts @@ -143,12 +143,12 @@ describe('layersManager', () => { await expect(action).rejects.toThrow(new NotFoundError(`cache not found for ${layerName} layer`)); }); - it('should fail with bad request when the Cache Type cannot be confirmed', async () => { + it('should fail with not found when the Cache Type cannot be confirmed', async () => { // action expect.assertions(1); const action = layersManager.getCacheByNameAndType('mockLayerNameExists', 'notValidType'); // expectation; - await expect(action).rejects.toThrow(new BadRequestError(`mockLayerNameExists layer cache not found with requested cache type: notValidType`)); + await expect(action).rejects.toThrow(new NotFoundError(`mockLayerNameExists layer cache not found with requested cache type: notValidType`)); }); }); From e6f912f3b313e48a3c3ad080da8b076f33c79979 Mon Sep 17 00:00:00 2001 From: razbroc Date: Mon, 24 Aug 2026 17:34:40 +0300 Subject: [PATCH 5/5] refactor: remove unused readCacheType function and simplify cache type check in LayersManager --- src/common/interfaces.ts | 8 +------- src/common/utils.ts | 15 --------------- src/layers/models/layersManager.ts | 7 +++---- 3 files changed, 4 insertions(+), 26 deletions(-) diff --git a/src/common/interfaces.ts b/src/common/interfaces.ts index ceb2773..877e900 100644 --- a/src/common/interfaces.ts +++ b/src/common/interfaces.ts @@ -120,12 +120,6 @@ export interface ICacheName { cacheName: string; } -/** - * The response of GET /layer/{layerName}/{cacheType}: the Cache name, and the Cache spread - * verbatim over it. Only the Cache name and the Cache Source are declared, because they are - * all that is verified — every other key is whatever the configuration held, so a consumer - * must narrow rather than trust. - */ export type IGetCacheResponse = ICacheName & Pick & Record; export interface IGpkgSource extends ICacheSource { @@ -145,7 +139,7 @@ export interface IMapProxyCache { grids: string[]; format: string; upscale_tiles?: number; - cache: ICacheSource; + cache?: ICacheSource; minimize_meta_requests?: boolean; } diff --git a/src/common/utils.ts b/src/common/utils.ts index 49a5aa3..b104dcf 100644 --- a/src/common/utils.ts +++ b/src/common/utils.ts @@ -61,21 +61,6 @@ export function isLayerNameSuffixRedis(layerName: string): boolean { return layerName.endsWith('-redis'); } -/** - * Lift the Cache Type out of an untrusted configuration entry. - * - * This is the one place that reads a `caches` entry without trusting its type: production - * configurations hold entries that are not well formed Caches. Nothing else about the entry is - * inspected or asserted, so the codebase carries no notion of a malformed Cache. - * @param cache an entry of the configuration's `caches` section, as read - * @return string - the entry's Cache Type, or undefined if the entry does not state one - */ -export function readCacheType(cache: unknown): string | undefined { - const cacheSource = (cache as { cache?: unknown } | null | undefined)?.cache; - const cacheType = (cacheSource as { type?: unknown } | null | undefined)?.type; - return typeof cacheType === 'string' ? cacheType : undefined; -} - export function adjustTilesPath(tilesPath: string, cacheSource: SourceTypes): string { const fsConfig = container.resolve(SERVICES.FS); switch (cacheSource) { diff --git a/src/layers/models/layersManager.ts b/src/layers/models/layersManager.ts index 04fe1d3..362cdf4 100644 --- a/src/layers/models/layersManager.ts +++ b/src/layers/models/layersManager.ts @@ -26,7 +26,7 @@ import { FSSource } from '../../common/cacheProviders/fsSource'; import { isSourceType, SourceTypes, sourceTypeValues } from '../../common/enums'; import { RedisSource } from '../../common/cacheProviders/redisSource'; import { ConfigsManager } from '../../configs/models/configsManager'; -import { getRedisCacheName, getRedisCacheOriginalName, isLayerNameSuffixRedis, readCacheType } from '../../common/utils'; +import { getRedisCacheName, getRedisCacheOriginalName, isLayerNameSuffixRedis } from '../../common/utils'; @injectable() class LayersManager { @@ -70,9 +70,8 @@ class LayersManager { this.logger.warn({ msg: errorMsg, layerName, cacheType }); throw new NotFoundError(errorMsg); } - // Nothing about the request is malformed: the Layer and the Cache Type are both well formed - // and the Cache Type is in the enum. There is simply no Cache of that Cache Type to address. - if (readCacheType(requestedCache) !== cacheType) { + + if (requestedCache.cache?.type !== cacheType) { const errorMsg = `${layerName} layer cache not found with requested cache type: ${cacheType}`; this.logger.warn({ msg: errorMsg, layerName, cacheType, requestedCache }); throw new NotFoundError(errorMsg);