Skip to content
Open
52 changes: 52 additions & 0 deletions openapi3.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -469,7 +469,59 @@ paths:
security:
- x-api-key: []
x-user-id: []
/config/control/table:
get:
operationId: configGetControlTable
tags:
- Control
summary: 'Get Control Grid table'
responses:
200:
description: 'OK'
content:
application/json:
schema:
type: object
description: 'An object with dynamic keys following the pattern `${latLon.min_x},${latLon.min_y},${latLon.zone}`. Each key represents a coordinate and zone identifier.'
additionalProperties:
type: object
properties:
tile_name:
type: string
example: 'BRN'
zone:
type: string
example: '33'
min_x:
type: string
example: '360000'
min_y:
type: string
example: 5820000
ext_min_x:
type: number
example: 360000
ext_min_y:
type: number
example: 5820000
ext_max_x:
type: number
example: 370000
ext_max_y:
type: number
example: 5830000

400:
'$ref': '#/components/responses/BadRequest'
401:
'$ref': '#/components/responses/Unauthorized'
403:
'$ref': '#/components/responses/Forbidden'
500:
'$ref': '#/components/responses/InternalError'
security:
- x-api-key: []
x-user-id: []
components:
responses:
BadRequest:
Expand Down
45 changes: 45 additions & 0 deletions src/config/controllers/configController.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/* eslint-disable @typescript-eslint/naming-convention */
import { Logger } from '@map-colonies/js-logger';
import { BoundCounter, Meter } from '@opentelemetry/api-metrics';
import { RequestHandler } from 'express';
import httpStatus from 'http-status-codes';
import { injectable, inject } from 'tsyringe';
import { SERVICES } from '../../common/constants';
import { ConfigManager } from '../models/configManager';
import { ConvertCamelToSnakeCase } from '../../common/utils';
import { LatLon } from '../../latLon/models/latLon';

type GetTilesHandler = RequestHandler<
undefined,
| Record<string, ConvertCamelToSnakeCase<LatLon>>
| {
type: string;
message: string;
},
undefined,
undefined
>;

@injectable()
export class ConfigController {
private readonly createdResourceCounter: BoundCounter;

public constructor(
@inject(SERVICES.LOGGER) private readonly logger: Logger,
@inject(ConfigManager) private readonly manager: ConfigManager,
@inject(SERVICES.METER) private readonly meter: Meter
) {
this.createdResourceCounter = meter.createCounter('config_created_resource');
}

public getControlTable: GetTilesHandler = async (_, res, next) => {
try {
const response = await this.manager.getControlTable();
return res.status(httpStatus.OK).json(response);
} catch (error: unknown) /* istanbul ignore next */ {
// Ignore next in code coverage as we don't expect an error to be thrown but it might happen.
this.logger.error({ msg: 'ConfigController.getControlTable error while trying to return control table', error });
next(error);
}
};
}
13 changes: 13 additions & 0 deletions src/config/models/configManager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/* eslint-disable @typescript-eslint/naming-convention */
import { inject, injectable } from 'tsyringe';
import { LatLonDAL, latLonDalSymbol } from '../../latLon/DAL/latLonDAL';

@injectable()
export class ConfigManager {
public constructor(@inject(latLonDalSymbol) private readonly latLonDAL: LatLonDAL) {}

public async getControlTable(): ReturnType<LatLonDAL['getLatLonTable']> {
const response = await this.latLonDAL.getLatLonTable();
return response;
}
}
16 changes: 16 additions & 0 deletions src/config/routes/configRouter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { Router } from 'express';
import { FactoryFunction } from 'tsyringe';
import { ConfigController } from '../controllers/configController';

const configRouterFactory: FactoryFunction<Router> = (dependencyContainer) => {
const router = Router();
const controller = dependencyContainer.resolve(ConfigController);

router.get('/control/table', controller.getControlTable);

return router;
};

export const CONFIG_ROUTER_SYMBOL = Symbol('configRouterFactory');

export { configRouterFactory };
2 changes: 2 additions & 0 deletions src/containerConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { s3ClientFactory } from './common/s3';
import { S3_REPOSITORY_SYMBOL, s3RepositoryFactory } from './common/s3/s3Repository';
import { healthCheckFactory } from './common/utils';
import { MGRS_ROUTER_SYMBOL, mgrsRouterFactory } from './mgrs/routes/mgrsRouter';
import { CONFIG_ROUTER_SYMBOL, configRouterFactory } from './config/routes/configRouter';

export interface RegisterOptions {
override?: InjectionObject<unknown>[];
Expand Down Expand Up @@ -164,6 +165,7 @@ export const registerExternalValues = async (options?: RegisterOptions): Promise
}
},
},
{ token: CONFIG_ROUTER_SYMBOL, provider: { useFactory: configRouterFactory } },
];
const container = await registerDependencies(dependencies, options?.override, options?.useChild);
return container;
Expand Down
27 changes: 16 additions & 11 deletions src/latLon/DAL/latLonDAL.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ let latLonDALInstance: LatLonDAL | null = null;

@injectable()
export class LatLonDAL {
private readonly latLonMap: Map<string, LatLon>;
private latLonTable: Record<string, LatLon>;
private onGoingUpdate: boolean;
private dataLoad:
| {
Expand All @@ -26,17 +26,18 @@ export class LatLonDAL {
}
| undefined;
private dataLoadError: boolean;
private latLonTableTemp: Record<string, LatLon> | null = null;

public constructor(
@inject(SERVICES.LOGGER) private readonly logger: Logger,
@inject(S3_REPOSITORY_SYMBOL) private readonly latLonRepository: S3Repository
) {
this.latLonMap = new Map<string, LatLon>();
this.latLonTable = {};
this.onGoingUpdate = true;
this.dataLoad = undefined;
this.dataLoadError = false;

this.init().catch((error: Error) => {
this.update().catch((error: Error) => {
this.logger.error({ msg: 'Failed to initialize lat-lon data', error });
this.dataLoadError = true;
});
Expand All @@ -52,7 +53,7 @@ export class LatLonDAL {
}
/* istanbul ignore end */

public async init(): Promise<void> {
public async update(): Promise<void> {
try {
const dataLoadPromise = new Promise((resolve, reject) => {
this.dataLoadError = false;
Expand Down Expand Up @@ -87,27 +88,31 @@ export class LatLonDAL {
throw new InternalServerError('Lat-lon to tile data currently not available');
}
await this.dataLoad?.promise;
return this.latLonMap.get(`${x},${y},${zone}`);
return this.latLonTable[`${x},${y},${zone}`];
}

private clearLatLonMap(): void {
this.logger.debug('Clearing latLon data');
this.latLonMap.clear();
public async getLatLonTable(): Promise<Record<string, LatLon>> {
await this.dataLoad?.promise;
return this.latLonTable;
}

private async loadLatLonData(): Promise<void> {
this.logger.debug('Loading latLon data');

this.clearLatLonMap();
this.latLonTableTemp = {};

const latLonDataPath = await this.latLonRepository.downloadFile('latLonConvertionTable');

const { items: latLonData } = JSON.parse(await fs.promises.readFile(latLonDataPath, 'utf8')) as { items: LatLon[] };

latLonData.forEach((latLon) => {
this.latLonMap.set(`${latLon.min_x},${latLon.min_y},${latLon.zone}`, latLon);
this.latLonTableTemp![`${latLon.min_x},${latLon.min_y},${latLon.zone}`] = latLon;
});

this.latLonTable = this.latLonTableTemp;
this.latLonTableTemp = null;
Object.freeze(this.latLonTable);

try {
await fs.promises.unlink(latLonDataPath);
} catch (error) {
Expand Down Expand Up @@ -146,7 +151,7 @@ export const cronLoadTileLatLonDataFactory: FactoryFunction<cron.ScheduledTask>
scheduledTask = cron.schedule(cronPattern, () => {
if (!latLonDAL.getOnGoingUpdate()) {
logger.info('cronLoadTileLatLonData: starting update');
latLonDAL.init().catch((error: Error) => {
latLonDAL.update().catch((error: Error) => {
logger.error({ msg: 'cronLoadTileLatLonData: update failed', error });
});
} else {
Expand Down
5 changes: 3 additions & 2 deletions src/serverBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@ import { ITEM_ROUTER_SYMBOL } from './control/item/routes/itemRouter';
import { ROUTE_ROUTER_SYMBOL } from './control/route/routes/routeRouter';
import { LAT_LON_ROUTER_SYMBOL } from './latLon/routes/latLonRouter';
import { GEOTEXT_SEARCH_ROUTER_SYMBOL } from './location/routes/locationRouter';
import { cronLoadTileLatLonDataSymbol } from './latLon/DAL/latLonDAL';
import { FeedbackApiMiddlewareManager } from './common/middlewares/feedbackApi.middleware';
import { MGRS_ROUTER_SYMBOL } from './mgrs/routes/mgrsRouter';
import { CONFIG_ROUTER_SYMBOL } from './config/routes/configRouter';

@injectable()
export class ServerBuilder {
Expand All @@ -31,7 +31,7 @@ export class ServerBuilder {
@inject(ROUTE_ROUTER_SYMBOL) private readonly routeRouter: Router,
@inject(LAT_LON_ROUTER_SYMBOL) private readonly latLonRouter: Router,
@inject(GEOTEXT_SEARCH_ROUTER_SYMBOL) private readonly geotextRouter: Router,
@inject(cronLoadTileLatLonDataSymbol) private readonly cronLoadTileLatLonData: void,
@inject(CONFIG_ROUTER_SYMBOL) private readonly configRouter: Router,
@inject(FeedbackApiMiddlewareManager) private readonly feedbackApiMiddleware: FeedbackApiMiddlewareManager,
@inject(MGRS_ROUTER_SYMBOL) private readonly mgrsRouter: Router
) {
Expand Down Expand Up @@ -68,6 +68,7 @@ export class ServerBuilder {

this.serverInstance.use('/search', router);
this.serverInstance.use('/lookup', this.latLonRouter);
this.serverInstance.use('/config', this.configRouter);
}

private buildControlRoutes(): Router {
Expand Down
73 changes: 73 additions & 0 deletions tests/integration/config/config.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/* eslint-disable @typescript-eslint/naming-convention */
import 'jest-openapi';
import { DependencyContainer } from 'tsyringe';
import { Application } from 'express';
import { CleanupRegistry } from '@map-colonies/cleanup-registry';
import jsLogger from '@map-colonies/js-logger';
import { trace } from '@opentelemetry/api';
import httpStatusCodes from 'http-status-codes';
import { getApp } from '../../../src/app';
import { SERVICES } from '../../../src/common/constants';
import { cronLoadTileLatLonDataSymbol, LatLonDAL, latLonDalSymbol, latLonSignletonFactory } from '../../../src/latLon/DAL/latLonDAL';
import { LatLon } from '../../../src/latLon/models/latLon';
import { ConvertCamelToSnakeCase } from '../../../src/common/utils';
import { ConfigRequestSender } from './helpers/requestSender';

describe('/config/control/table', function () {
let requestSender: ConfigRequestSender;
let app: { app: Application; container: DependencyContainer };

beforeEach(async function () {
app = await getApp({
override: [
{ token: SERVICES.LOGGER, provider: { useValue: jsLogger({ enabled: false }) } },
{ token: SERVICES.TRACER, provider: { useValue: trace.getTracer('testTracer') } },
{ token: cronLoadTileLatLonDataSymbol, provider: { useValue: {} } },
{ token: SERVICES.ELASTIC_CLIENTS, provider: { useValue: {} } },
{ token: latLonDalSymbol, provider: { useFactory: latLonSignletonFactory } },
],
useChild: true,
});

requestSender = new ConfigRequestSender(app.app);
});

afterAll(async function () {
const cleanupRegistry = app.container.resolve<CleanupRegistry>(SERVICES.CLEANUP_REGISTRY);
await cleanupRegistry.trigger();
app.container.reset();

jest.clearAllTimers();
});

describe('Happy Path', function () {
it('should return 200 status code and Control Grid table', async function () {
const response = await requestSender.getControlTable();

expect(response.status).toBe(httpStatusCodes.OK);
expect(response).toSatisfyApiSpec();
expect(response.body).toEqual<Record<string, ConvertCamelToSnakeCase<LatLon>>>({
'360000,5820000,33': {
tile_name: 'BRN',
zone: '33',
min_x: '360000',
min_y: '5820000',
ext_min_x: 360000,
ext_min_y: 5820000,
ext_max_x: 370000,
ext_max_y: 5830000,
},
'480000,5880000,32': {
tile_name: 'BMN',
zone: '32',
min_x: '480000',
min_y: '5880000',
ext_min_x: 480000,
ext_min_y: 5880000,
ext_max_x: 490000,
ext_max_y: 5890000,
},
});
});
});
});
14 changes: 14 additions & 0 deletions tests/integration/config/helpers/requestSender.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import * as supertest from 'supertest';

export class ConfigRequestSender {
public constructor(private readonly app: Express.Application) {}

public async getControlTable(): Promise<supertest.Response> {
return supertest
.agent(this.app)
.get('/config/control/table')
.set('Content-Type', 'application/json')
.set('x-api-key', 'abc123')
.set('x-user-id', 'abc123');
}
}
Loading