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: diff --git a/src/common/interfaces.ts b/src/common/interfaces.ts index 51195d9..877e900 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; } @@ -120,10 +120,7 @@ export interface ICacheName { cacheName: string; } -export interface ICacheObject { - cacheName: string; - cache: IRedisSource | IS3Source | IFSSource; -} +export type IGetCacheResponse = ICacheName & Pick & Record; export interface IGpkgSource extends ICacheSource { filename: string; @@ -142,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/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 c39c6ec..362cdf4 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'; @@ -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,25 +63,23 @@ 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]; + 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 (requestedCache.cache?.type !== cacheType) { const errorMsg = `${layerName} layer cache not found with requested cache type: ${cacheType}`; - this.logger.warn({ msg: errorMsg, layerName, cacheType }); - throw new BadRequestError(errorMsg); + this.logger.warn({ msg: errorMsg, layerName, cacheType, requestedCache }); + throw new NotFoundError(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..d209a2f 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 404 when the Cache is of another Cache Type', async () => { + const mockLayerName = 'mockLayerNameExists'; + const cacheType = 'file'; + const response = await requestSender.getLayersCache(mockLayerName, cacheType); + const notFoundErrorMessage = `${mockLayerName} layer cache not found with requested cache type: ${cacheType}`; + + expect(response).toSatisfyApiSpec(); + 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 () => { + 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 404 when the configuration entry is not an object', async () => { + const mockLayerName = 'mock'; + const response = await requestSender.getLayersCache(mockLayerName, 's3'); + const notFoundErrorMessage = `${mockLayerName} layer cache not found with requested cache type: s3`; + + expect(response).toSatisfyApiSpec(); + expect(response.status).toBe(httpStatusCodes.NOT_FOUND); + expect(response.body).toEqual({ message: notFoundErrorMessage }); + }); + + 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 notFoundErrorMessage = `${mockLayerName} layer cache not found with requested cache type: s3`; + + expect(response).toSatisfyApiSpec(); + 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 () => { 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 b1cf06d..384c7d1 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'; @@ -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,7 +142,8 @@ 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 not found when the Cache Type cannot be confirmed', async () => { // action expect.assertions(1); const action = layersManager.getCacheByNameAndType('mockLayerNameExists', 'notValidType'); @@ -247,8 +253,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 +272,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 +372,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 +382,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); }); 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",