diff --git a/openapi3.yaml b/openapi3.yaml index 1056fa7..1701e4d 100644 --- a/openapi3.yaml +++ b/openapi3.yaml @@ -7,6 +7,25 @@ info: name: MIT url: https://opensource.org/licenses/MIT paths: + /storage: + get: + tags: + - storage + summary: Get free and total storage size for exporting (in bytes) + operationId: getStorage + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/StorageResponse' + '500': + description: Internal Server Error + content: + application/json: + schema: + $ref: '#/components/schemas/internalError' /create: post: tags: @@ -151,6 +170,17 @@ components: schema: $ref: '#/components/schemas/exportFromFeatures' schemas: + StorageResponse: + type: object + description: Response for storage request (in bytes) + required: + - free + - size + properties: + free: + type: number + size: + type: number CommonResponse: type: object required: diff --git a/src/containerConfig.ts b/src/containerConfig.ts index 296319e..078004c 100644 --- a/src/containerConfig.ts +++ b/src/containerConfig.ts @@ -10,6 +10,7 @@ import { createPackageRouterFactory, CREATE_PACKAGE_ROUTER_SYMBOL } from './crea import { InjectionObject, registerDependencies } from './common/dependencyRegistration'; import { tasksRouterFactory, TASKS_ROUTER_SYMBOL } from './tasks/routes/tasksRouter'; import { PollingManager, POLLING_MANGER_SYMBOL } from './pollingManager'; +import { storageRouterFactory, STORAGE_ROUTER_SYMBOL } from './storage/routes/storageRouter'; export interface RegisterOptions { override?: InjectionObject[]; @@ -33,6 +34,7 @@ export const registerExternalValues = (options?: RegisterOptions): DependencyCon { token: SERVICES.LOGGER, provider: { useValue: logger } }, { token: SERVICES.TRACER, provider: { useValue: tracer } }, { token: SERVICES.METER, provider: { useValue: meter } }, + { token: STORAGE_ROUTER_SYMBOL, provider: { useFactory: storageRouterFactory } }, { token: CREATE_PACKAGE_ROUTER_SYMBOL, provider: { useFactory: createPackageRouterFactory } }, { token: TASKS_ROUTER_SYMBOL, provider: { useFactory: tasksRouterFactory } }, { token: POLLING_MANGER_SYMBOL, provider: { useClass: PollingManager } }, diff --git a/src/serverBuilder.ts b/src/serverBuilder.ts index 542ad14..128b458 100644 --- a/src/serverBuilder.ts +++ b/src/serverBuilder.ts @@ -9,6 +9,7 @@ import { Logger } from '@map-colonies/js-logger'; import httpLogger from '@map-colonies/express-access-log-middleware'; import { SERVICES } from './common/constants'; import { IConfig } from './common/interfaces'; +import { STORAGE_ROUTER_SYMBOL } from './storage/routes/storageRouter'; import { CREATE_PACKAGE_ROUTER_SYMBOL } from './createPackage/routes/createPackageRouter'; import { TASKS_ROUTER_SYMBOL } from './tasks/routes/tasksRouter'; @@ -19,6 +20,7 @@ export class ServerBuilder { public constructor( @inject(SERVICES.CONFIG) private readonly config: IConfig, @inject(SERVICES.LOGGER) private readonly logger: Logger, + @inject(STORAGE_ROUTER_SYMBOL) private readonly createStorageRouter: Router, @inject(CREATE_PACKAGE_ROUTER_SYMBOL) private readonly createPackageRouter: Router, @inject(TASKS_ROUTER_SYMBOL) private readonly tasksRouter: Router ) { @@ -40,6 +42,7 @@ export class ServerBuilder { } private buildRoutes(): void { + this.serverInstance.use('/storage', this.createStorageRouter); this.serverInstance.use('/create', this.createPackageRouter); this.serverInstance.use('/', this.tasksRouter); this.buildDocsRoutes(); diff --git a/src/storage/controllers/storageController.ts b/src/storage/controllers/storageController.ts new file mode 100644 index 0000000..1784b97 --- /dev/null +++ b/src/storage/controllers/storageController.ts @@ -0,0 +1,33 @@ +import { Logger } from '@map-colonies/js-logger'; +import { RequestHandler } from 'express'; +import { InvalidPathError, NoMatchError } from 'check-disk-space'; +import httpStatus from 'http-status-codes'; +import { injectable, inject } from 'tsyringe'; +import { HttpError } from '@map-colonies/error-types'; +import httpStatusCodes from 'http-status-codes'; +import { SERVICES } from '../../common/constants'; +import { StorageManager } from '../models/storageManager'; +import { IStorageStatusResponse } from '../../common/interfaces'; + +type GetStorageHandler = RequestHandler<{ jobId: string }, IStorageStatusResponse, string>; + +@injectable() +export class StorageController { + public constructor( + @inject(SERVICES.LOGGER) private readonly logger: Logger, + @inject(StorageManager) private readonly storageManager: StorageManager + ) {} + + public getStorage: GetStorageHandler = async (req, res, next) => { + try { + const storageStatus = await this.storageManager.getStorage(); + return res.status(httpStatus.OK).json(storageStatus); + } catch (err) { + let error = err; + if (err instanceof InvalidPathError || err instanceof NoMatchError) { + error = new HttpError(err, httpStatusCodes.INTERNAL_SERVER_ERROR, 'Bad path configuration for GPKG location'); + } + next(error); + } + }; +} diff --git a/src/storage/models/storageManager.ts b/src/storage/models/storageManager.ts new file mode 100644 index 0000000..6ef9706 --- /dev/null +++ b/src/storage/models/storageManager.ts @@ -0,0 +1,25 @@ +import { Logger } from '@map-colonies/js-logger'; +import { inject, injectable } from 'tsyringe'; +import config from 'config'; +import { SERVICES } from '../../common/constants'; +import { IStorageStatusResponse } from '../../common/interfaces'; +import { getStorageStatus } from '../../common/utils'; + +@injectable() +export class StorageManager { + private readonly gpkgsLocation: string; + + public constructor(@inject(SERVICES.LOGGER) private readonly logger: Logger) { + this.gpkgsLocation = config.get('gpkgsLocation'); + } + + public async getStorage(): Promise { + const storageStatus: IStorageStatusResponse = await getStorageStatus(this.gpkgsLocation); + this.logger.debug({ storageStatus, msg: `Current storage free and total space for gpkgs location` }); + + return { + free: storageStatus.free, + size: storageStatus.size, + }; + } +} diff --git a/src/storage/routes/storageRouter.ts b/src/storage/routes/storageRouter.ts new file mode 100644 index 0000000..670a192 --- /dev/null +++ b/src/storage/routes/storageRouter.ts @@ -0,0 +1,16 @@ +import { Router } from 'express'; +import { FactoryFunction } from 'tsyringe'; +import { StorageController } from '../controllers/storageController'; + +const storageRouterFactory: FactoryFunction = (dependencyContainer) => { + const router = Router(); + const controller = dependencyContainer.resolve(StorageController); + + router.get('/', controller.getStorage); + + return router; +}; + +export const STORAGE_ROUTER_SYMBOL = Symbol('storageFactory'); + +export { storageRouterFactory }; diff --git a/tests/integration/storage/helpers/storageSender.ts b/tests/integration/storage/helpers/storageSender.ts new file mode 100644 index 0000000..6bed78f --- /dev/null +++ b/tests/integration/storage/helpers/storageSender.ts @@ -0,0 +1,9 @@ +import * as supertest from 'supertest'; + +export class StorageSender { + public constructor(private readonly app: Express.Application) {} + + public async getStorage(): Promise { + return supertest.agent(this.app).get(`/storage`).set('Content-Type', 'application/json').send(); + } +} diff --git a/tests/integration/storage/storage.spec.ts b/tests/integration/storage/storage.spec.ts new file mode 100644 index 0000000..ae8814a --- /dev/null +++ b/tests/integration/storage/storage.spec.ts @@ -0,0 +1,67 @@ +import httpStatusCodes from 'http-status-codes'; +import { InvalidPathError, NoMatchError } from 'check-disk-space'; +import { getApp } from '../../../src/app'; +import { getContainerConfig, resetContainer } from '../testContainerConfig'; +import { IStorageStatusResponse } from '../../../src/common/interfaces'; +import * as utils from '../../../src/common/utils'; +import { StorageSender } from './helpers/storageSender'; + +describe('storage', function () { + let requestSender: StorageSender; + let getStorageSpy: jest.SpyInstance; + + beforeEach(function () { + const app = getApp({ + override: [...getContainerConfig()], + useChild: true, + }); + requestSender = new StorageSender(app); + getStorageSpy = jest.spyOn(utils, 'getStorageStatus'); + }); + + afterEach(function () { + resetContainer(); + jest.resetAllMocks(); + }); + + describe('Happy Path', function () { + it('should return 200 status code and the storage details', async function () { + const storageStatusResponse: IStorageStatusResponse = { + free: 1000, + size: 1000, + }; + + getStorageSpy.mockResolvedValue(storageStatusResponse); + + const resposne = await requestSender.getStorage(); + expect(resposne).toSatisfyApiSpec(); + expect(JSON.stringify(resposne.body)).toBe(JSON.stringify(storageStatusResponse)); + expect(getStorageSpy).toHaveBeenCalledTimes(1); + expect(resposne.status).toBe(httpStatusCodes.OK); + }); + }); + + describe('Bad Path', function () { + it('should return 500 status code because of invalid path', async function () { + getStorageSpy.mockImplementation(() => { + throw new InvalidPathError(); + }); + + const resposne = await requestSender.getStorage(); + expect(resposne).toSatisfyApiSpec(); + expect(getStorageSpy).toHaveBeenCalledTimes(1); + expect(resposne.status).toBe(httpStatusCodes.INTERNAL_SERVER_ERROR); + }); + + it('should return 500 status code because of no match on path', async function () { + getStorageSpy.mockImplementation(() => { + throw new NoMatchError(); + }); + + const resposne = await requestSender.getStorage(); + expect(resposne).toSatisfyApiSpec(); + expect(getStorageSpy).toHaveBeenCalledTimes(1); + expect(resposne.status).toBe(httpStatusCodes.INTERNAL_SERVER_ERROR); + }); + }); +}); diff --git a/tests/unit/storage/models/storageModel.spec.ts b/tests/unit/storage/models/storageModel.spec.ts new file mode 100644 index 0000000..609d667 --- /dev/null +++ b/tests/unit/storage/models/storageModel.spec.ts @@ -0,0 +1,40 @@ +import jsLogger from '@map-colonies/js-logger'; +import { StorageManager } from '../../../../src/storage/models/storageManager'; +import { IStorageStatusResponse } from '../../../../src/common/interfaces'; +import { registerDefaultConfig } from '../../../mocks/config'; +import * as utils from '../../../../src/common/utils'; + +let storageManager: StorageManager; + +describe('Storage', () => { + beforeEach(() => { + const logger = jsLogger({ enabled: false }); + registerDefaultConfig(); + storageManager = new StorageManager(logger); + }); + + afterEach(() => { + jest.resetAllMocks(); + jest.restoreAllMocks(); + }); + + describe('GetStorage', () => { + describe('#getStorage', () => { + it('should return storage status', async () => { + const storageStatusResponse: IStorageStatusResponse = { + free: 1000, + size: 1000, + }; + + const getStorageSpy = jest.spyOn(utils, 'getStorageStatus'); + getStorageSpy.mockResolvedValue(storageStatusResponse); + + const storageStatus = await storageManager.getStorage(); + + expect(storageStatus.free).toBe(1000); + expect(storageStatus.size).toBe(1000); + expect(getStorageSpy).toHaveBeenCalledTimes(1); + }); + }); + }); +});