diff --git a/.gitignore b/.gitignore index 2c3cc61..416fb56 100644 --- a/.gitignore +++ b/.gitignore @@ -111,4 +111,6 @@ dist jest_html_reporters.html reports +config/default.json + config/local*.json diff --git a/.vscode/launch.json b/.vscode/launch.json deleted file mode 100644 index 85986a3..0000000 --- a/.vscode/launch.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - // Use IntelliSense to learn about possible attributes. - // Hover to view descriptions of existing attributes. - // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 - "version": "0.2.0", - "configurations": [ - { - "type": "node", - "request": "launch", - "name": "Server", - "preLaunchTask": "npm: build", - "sourceMaps": true, - "smartStep": true, - "outputCapture": "std", - "internalConsoleOptions": "openOnSessionStart", - "cwd": "${workspaceFolder}/dist", - "skipFiles": ["${workspaceFolder}/node_modules/**/*.js", "/**/*.js"], - "outFiles": ["${workspaceFolder}/dist/**/*.js", "!**/node_modules/**"], - "program": "${workspaceFolder}/src/index.ts", - "env": { - "NODE_ENV": "development", - "SERVER_PORT": "8080", - "RESPONSE_COMPRESSION_ENABLED": "true", - "REQUEST_PAYLOAD_LIMIT": "1mb", - "LOG_PRETTY_PRINT_ENABLED": "true", - "TELEMETRY_TRACING_ENABLED": "false", - "TELEMETRY_METRICS_ENABLED": "false", - "TELEMETRY_TRACING_URL": "http://localhost:55681/v1/trace", - "TELEMETRY_METRICS_URL": "http://localhost:55681/v1/metrics" - // "OPENAPI_FILE_PATH": "./openapi3.yaml" - } - } - ] -} diff --git a/config/default.json b/config/default.json index 02f9345..9c29e9e 100644 --- a/config/default.json +++ b/config/default.json @@ -20,17 +20,23 @@ } }, "server": { - "port": 8080, - "request": { - "payload": { - "limit": "1mb" - } - }, - "response": { - "compression": { - "enabled": true, - "options": null - } + "port": 8080 + }, + "db": { + "type": "postgres", + "host": "localhost", + "port": 5432, + "username": "myuser", + "password": "mypassword", + "database": "mydatabase", + "schema": "public", + "synchronize": true, + "logging": false + }, + "response": { + "compression": { + "enabled": true, + "options": null } } } diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..dbe30ac --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,18 @@ +services: + postgres: + image: postgis/postgis:17-3.5-alpine + platform: linux/amd64 + container_name: my-postgres + restart: unless-stopped + environment: + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_DB: ${POSTGRES_DB} + ports: + - '5432:5432' + volumes: + - postgres_data:/var/lib/postgresql/data + - /docker-entrypoint-initdb.d + +volumes: + postgres_data: diff --git a/launch.json b/launch.json new file mode 100644 index 0000000..8e8d5c4 --- /dev/null +++ b/launch.json @@ -0,0 +1,15 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "type": "node", + "request": "launch", + "name": "Debug Vitest", + "autoAttachChildProcesses": true, + "program": "${workspaceRoot}/node_modules/vitest/vitest.mjs", + "args": ["run"], + "smartStep": true, + "console": "integratedTerminal" + } + ] +} diff --git a/openapi3.yaml b/openapi3.yaml index b1030d6..ab09edb 100644 --- a/openapi3.yaml +++ b/openapi3.yaml @@ -6,27 +6,7 @@ info: license: name: MIT url: https://opensource.org/licenses/MIT - paths: - /anotherResource: - get: - operationId: getAnotherResource - tags: - - anotherResource - summary: gets the resource - responses: - 200: - description: OK - content: - application/json: - schema: - $ref: '#/components/schemas/anotherResource' - 400: - description: Bad Request - content: - application/json: - schema: - $ref: '#/components/schemas/error' /products: get: operationId: getProducts @@ -46,10 +26,60 @@ paths: - rasterized_vector - tiles3d - QMesh - summary: gets the resource + - name: description + in: query + schema: + type: string + - name: bounding_polygon + in: query + # style: deepObject + # explode: true + schema: + type: object + properties: + type: + type: string + enum: + - Polygon + coordinates: + type: array + items: + type: array + items: + type: array + minItems: 2 + maxItems: 2 + items: + type: number + - name: consumption_link + in: query + schema: + type: string + - name: resolution_best + in: query + schema: + type: number + - name: consumption_protocol + in: query + schema: + type: string + enum: + - WMS + - WMTS + - XYZ + - 3D Tiles + - name: max_zoom + in: query + schema: + type: number + - name: min_zoom + in: query + schema: + type: number + summary: get the products responses: 200: - description: A JSON array of products + description: Success content: application/json: schema: @@ -60,6 +90,8 @@ paths: application/json: schema: $ref: '#/components/schemas/error' + 500: + $ref: '#/components/responses/InternalServerError' post: operationId: createProduct tags: @@ -84,6 +116,84 @@ paths: application/json: schema: $ref: '#/components/schemas/error' + 500: + $ref: '#/components/responses/InternalServerError' + + /products/{id}: + put: + operationId: updateProduct + tags: + - products + summary: update product + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateProduct' + responses: + 200: + description: updated + content: + application/json: + schema: + $ref: '#/components/schemas/Product' + 400: + description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/error' + 404: + description: Product not found + content: + application/json: + schema: + $ref: '#/components/schemas/error' + 500: + $ref: '#/components/responses/InternalServerError' + + delete: + operationId: deleteProduct + tags: + - products + summary: delete product + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + responses: + 200: + description: Product deleted + content: + application/json: + schema: + $ref: '#/components/schemas/DeletedProductResponse' + 400: + description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/error' + 404: + description: Product not found + content: + application/json: + schema: + $ref: '#/components/schemas/error' + 500: + $ref: '#/components/responses/InternalServerError' + security: - {} components: @@ -110,11 +220,20 @@ components: type: number error: type: object - required: - - message properties: message: type: string + data: + type: object + errors: + type: array + items: + type: object + properties: + field: + type: string + message: + type: string Product: type: object properties: @@ -124,29 +243,26 @@ components: name: type: string maxLength: 48 - description: type: string - + nullable: true bounding_polygon: $ref: '#/components/schemas/GeoJsonPolygon' - consumption_link: type: string nullable: true - resolution_best: type: number + nullable: true format: double - min_zoom: type: integer + nullable: true minimum: 0 - max_zoom: type: integer + nullable: true minimum: 0 - type: type: string enum: @@ -154,7 +270,6 @@ components: - rasterized_vector - tiles3d - QMesh - consumption_protocol: type: string enum: @@ -166,13 +281,41 @@ components: type: array items: $ref: '#/components/schemas/Product' - anotherResource: + UpdateProduct: type: object - required: - - kind - - isAlive properties: - kind: + name: + type: string + maxLength: 48 + description: + type: string + nullable: true + bounding_polygon: + $ref: '#/components/schemas/GeoJsonPolygon' + consumption_link: + type: string + nullable: true + type: type: string - isAlive: - type: boolean + enum: [raster, rasterized_vector, tiles3d, QMesh] + consumption_protocol: + type: string + enum: [WMS, WMTS, XYZ, '3D Tiles'] + resolution_best: + type: number + min_zoom: + type: integer + max_zoom: + type: integer + DeletedProductResponse: + type: object + properties: + data: + $ref: '#/components/schemas/Product' + responses: + InternalServerError: + description: Unexpected internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/error' diff --git a/package-lock.json b/package-lock.json index fa69e8f..c708bf6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "ISC", "dependencies": { "@godaddy/terminus": "^4.12.1", + "@map-colonies/cleanup-registry": "^1.3.0", "@map-colonies/config": "^4.0.1", "@map-colonies/error-express-handler": "^4.0.0", "@map-colonies/express-access-log-middleware": "^4.1.0", @@ -31,6 +32,7 @@ "reflect-metadata": "^0.2.2", "tsyringe": "^4.8.0", "typeorm": "^0.3.28", + "typeorm-transactional": "^0.5.0", "zod": "^4.3.6" }, "devDependencies": { @@ -2167,6 +2169,16 @@ "integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==", "license": "MIT" }, + "node_modules/@map-colonies/cleanup-registry": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@map-colonies/cleanup-registry/-/cleanup-registry-1.3.0.tgz", + "integrity": "sha512-s3LK9A41h6q2HfbQthKymUiUNz19mFMr83Aukosk3BUeywNCoO/c5Yz13nOYiR8JqM4VNPheAh89T8zeOW7ZlQ==", + "license": "ISC", + "dependencies": { + "nanoid": "^3.3.4", + "tiny-typed-emitter": "^2.1.0" + } + }, "node_modules/@map-colonies/commitlint-config": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@map-colonies/commitlint-config/-/commitlint-config-2.0.0.tgz", @@ -5918,6 +5930,15 @@ "assertion-error": "^2.0.1" } }, + "node_modules/@types/cls-hooked": { + "version": "4.3.9", + "resolved": "https://registry.npmjs.org/@types/cls-hooked/-/cls-hooked-4.3.9.tgz", + "integrity": "sha512-CMtHMz6Q/dkfcHarq9nioXH8BDPP+v5xvd+N90lBQ2bdmu06UvnLDqxTKoOJzz4SzIwb/x9i4UXGAAcnUDuIvg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/compression": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/@types/compression/-/compression-1.8.1.tgz", @@ -7330,6 +7351,18 @@ "js-tokens": "^10.0.0" } }, + "node_modules/async-hook-jl": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/async-hook-jl/-/async-hook-jl-1.7.6.tgz", + "integrity": "sha512-gFaHkFfSxTjvoxDMYqDuGHlcRyUuamF8s+ZTtJdDzqjws4mCt7v0vuV79/E2Wr2/riMQgtG4/yUtXWs1gZ7JMg==", + "license": "MIT", + "dependencies": { + "stack-chain": "^1.3.7" + }, + "engines": { + "node": "^4.7 || >=6.9 || >=7.3" + } + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -8075,6 +8108,29 @@ "node": ">=8" } }, + "node_modules/cls-hooked": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/cls-hooked/-/cls-hooked-4.2.2.tgz", + "integrity": "sha512-J4Xj5f5wq/4jAvcdgoGsL3G103BtWpZrMo8NEinRltN+xpTZdI+M38pyQqhuFU/P792xkMFvnKSf+Lm81U1bxw==", + "license": "BSD-2-Clause", + "dependencies": { + "async-hook-jl": "^1.7.6", + "emitter-listener": "^1.0.1", + "semver": "^5.4.1" + }, + "engines": { + "node": "^4.7 || >=6.9 || >=7.3 || >=8.2.1" + } + }, + "node_modules/cls-hooked/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "license": "ISC", + "bin": { + "semver": "bin/semver" + } + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -8886,6 +8942,15 @@ "dev": true, "license": "ISC" }, + "node_modules/emitter-listener": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/emitter-listener/-/emitter-listener-1.1.2.tgz", + "integrity": "sha512-Bt1sBAGFHY9DKY+4/2cV6izcKJUf5T7/gkdmkxzX/qv9CcGH8xSwVRW5mtX03SWJtRTWSOpzCuWN9rBFYZepZQ==", + "license": "BSD-2-Clause", + "dependencies": { + "shimmer": "^1.2.0" + } + }, "node_modules/emittery": { "version": "0.13.1", "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", @@ -13008,7 +13073,6 @@ "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "dev": true, "funding": [ { "type": "github", @@ -15619,6 +15683,12 @@ "node": ">=8" } }, + "node_modules/shimmer": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/shimmer/-/shimmer-1.2.1.tgz", + "integrity": "sha512-sQTKC1Re/rM6XyFM6fIAGHRPVGvyXfgzIDvzoq608vM+jeyVD0Tu1E6Np0Kc2zAIFWIj963V2800iF/9LPieQw==", + "license": "BSD-2-Clause" + }, "node_modules/should": { "version": "13.2.3", "resolved": "https://registry.npmjs.org/should/-/should-13.2.3.tgz", @@ -15923,6 +15993,12 @@ "node": ">=12.0.0" } }, + "node_modules/stack-chain": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/stack-chain/-/stack-chain-1.3.7.tgz", + "integrity": "sha512-D8cWtWVdIe/jBA7v5p5Hwl5yOSOrmZPWDPe2KxQ5UAGD+nxbxU0lKXA4h85Ta6+qgdKVL3vUxsbIZjc1kBG7ug==", + "license": "MIT" + }, "node_modules/stack-utils": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", @@ -16535,6 +16611,12 @@ "safe-buffer": "~5.1.0" } }, + "node_modules/tiny-typed-emitter": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/tiny-typed-emitter/-/tiny-typed-emitter-2.1.0.tgz", + "integrity": "sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==", + "license": "MIT" + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -17018,6 +17100,24 @@ } } }, + "node_modules/typeorm-transactional": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/typeorm-transactional/-/typeorm-transactional-0.5.0.tgz", + "integrity": "sha512-53/CwnXpOIJnWU3oVCNbhHB95FwciKSGbY+m/Hw4e2dBM2c4toiOHwf4pmk83Ne7guznmDgVr/5IUfbp+JTPCg==", + "license": "MIT", + "dependencies": { + "@types/cls-hooked": "^4.3.3", + "cls-hooked": "^4.2.2", + "semver": "^7.5.1" + }, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "reflect-metadata": ">= 0.1.12", + "typeorm": ">= 0.2.8" + } + }, "node_modules/typeorm/node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", diff --git a/package.json b/package.json index 834e3ab..ba5ee58 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "license": "ISC", "dependencies": { "@godaddy/terminus": "^4.12.1", + "@map-colonies/cleanup-registry": "^1.3.0", "@map-colonies/config": "^4.0.1", "@map-colonies/error-express-handler": "^4.0.0", "@map-colonies/express-access-log-middleware": "^4.1.0", @@ -55,6 +56,7 @@ "reflect-metadata": "^0.2.2", "tsyringe": "^4.8.0", "typeorm": "^0.3.28", + "typeorm-transactional": "^0.5.0", "zod": "^4.3.6" }, "devDependencies": { diff --git a/src/anotherResource/controllers/anotherResourceController.ts b/src/anotherResource/controllers/anotherResourceController.ts deleted file mode 100644 index 56f3879..0000000 --- a/src/anotherResource/controllers/anotherResourceController.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { Logger } from '@map-colonies/js-logger'; -import { type Registry, Counter } from 'prom-client'; -import httpStatus from 'http-status-codes'; -import { injectable, inject } from 'tsyringe'; -import type { TypedRequestHandlers } from '@openapi'; -import { SERVICES } from '@common/constants'; -import { AnotherResourceManager } from '../models/anotherResourceManager'; - -@injectable() -export class AnotherResourceController { - private readonly getResourceCounter: Counter; - - public constructor( - @inject(SERVICES.LOGGER) private readonly logger: Logger, - @inject(AnotherResourceManager) private readonly manager: AnotherResourceManager, - @inject(SERVICES.METRICS) private readonly metricsRegistry: Registry - ) { - this.getResourceCounter = new Counter({ - name: 'get_resource', - help: 'number of get resource requests', - registers: [this.metricsRegistry], - }); - } - - public getResource: TypedRequestHandlers['getAnotherResource'] = (req, res) => { - this.getResourceCounter.inc(1); - return res.status(httpStatus.OK).json(this.manager.getResource()); - }; -} diff --git a/src/anotherResource/models/anotherResourceManager.ts b/src/anotherResource/models/anotherResourceManager.ts deleted file mode 100644 index f54fcde..0000000 --- a/src/anotherResource/models/anotherResourceManager.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { Logger } from '@map-colonies/js-logger'; -import { inject, injectable } from 'tsyringe'; -import { components } from '@src/openapi'; -import { SERVICES } from '@common/constants'; - -const resourceInstance: IAnotherResourceModel = { - kind: 'avi', - isAlive: false, -}; - -export type IAnotherResourceModel = components['schemas']['anotherResource']; - -@injectable() -export class AnotherResourceManager { - public constructor(@inject(SERVICES.LOGGER) private readonly logger: Logger) {} - public getResource(): IAnotherResourceModel { - this.logger.info('logging'); - return resourceInstance; - } -} diff --git a/src/anotherResource/routes/anotherResourceRouter.ts b/src/anotherResource/routes/anotherResourceRouter.ts deleted file mode 100644 index e892485..0000000 --- a/src/anotherResource/routes/anotherResourceRouter.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { Router } from 'express'; -import type { FactoryFunction } from 'tsyringe'; -import { AnotherResourceController } from '../controllers/anotherResourceController'; - -const anotherResourceRouterFactory: FactoryFunction = (dependencyContainer) => { - const router = Router(); - const controller = dependencyContainer.resolve(AnotherResourceController); - - // router.get('/', controller.getResource); - - return router; -}; - -export const ANOTHER_RESOURCE_ROUTER_SYMBOL = Symbol('anotherResourceRouterFactory'); - -export { anotherResourceRouterFactory }; diff --git a/src/app.ts b/src/app.ts index fa1ff30..05b7448 100644 --- a/src/app.ts +++ b/src/app.ts @@ -2,24 +2,10 @@ import type { Application } from 'express'; import type { DependencyContainer } from 'tsyringe'; import { registerExternalValues, type RegisterOptions } from './containerConfig'; import { ServerBuilder } from './serverBuilder'; -// import { productRoutes } from './products/routes'; -import { AppDataSource } from './common/db/data-source'; async function getApp(registerOptions?: RegisterOptions): Promise<[Application, DependencyContainer]> { const container = await registerExternalValues(registerOptions); - - try { - if (!AppDataSource.isInitialized) { - await AppDataSource.initialize(); - console.log('DB connected'); - } - } catch (err) { - console.error('DB connection error:', err); - throw err; - } - const app = container.resolve(ServerBuilder).build(); - return [app, container]; } diff --git a/src/common/constants.ts b/src/common/constants.ts index 6e7c100..7f1bc87 100644 --- a/src/common/constants.ts +++ b/src/common/constants.ts @@ -12,5 +12,6 @@ export const SERVICES = { CONFIG: Symbol('Config'), TRACER: Symbol('Tracer'), METRICS: Symbol('METRICS'), + CLEANUP_REGISTRY: Symbol('CleanupRegistry'), } satisfies Record; /* eslint-enable @typescript-eslint/naming-convention */ diff --git a/src/common/db/data-source.ts b/src/common/db/data-source.ts index b23659b..3244505 100644 --- a/src/common/db/data-source.ts +++ b/src/common/db/data-source.ts @@ -1,16 +1,51 @@ import 'reflect-metadata'; -import { DataSource } from 'typeorm'; +import { hostname } from 'os'; +import { readFileSync } from 'fs'; +import { HealthCheck } from '@godaddy/terminus'; +import { DependencyContainer, FactoryFunction } from 'tsyringe'; +import { DataSource, DataSourceOptions } from 'typeorm'; import { ProductEntity } from '@src/products/models/entity.products'; +import { DbConfig } from '../interfaces'; +import { promiseTimeout } from '../utils/promiseTimeout'; +import { ConfigType } from '../config'; +import { SERVICES } from '../constants'; -export const AppDataSource = new DataSource({ - type: 'postgres', - host: 'localhost', - port: 5432, - username: 'myuser', - password: 'mypassword', - database: 'mydatabase', - schema: 'public', - synchronize: true, - logging: false, - entities: [ProductEntity], -}); +let connectionSingleton: DataSource | undefined; + +const DB_TIMEOUT = 5000; + +export const DATA_SOURCE_PROVIDER = Symbol('dataSourceProvider'); + +export const createDataSourceOptions = (dbConfig: DbConfig): DataSourceOptions => { + const { enableSslAuth, sslPaths, ...dataSourceOptions } = dbConfig; + // eslint-disable-next-line @typescript-eslint/naming-convention + dataSourceOptions.extra = { application_name: `${hostname()}-${process.env.NODE_ENV ?? 'unknown_env'}` }; + if (enableSslAuth && dataSourceOptions.type === 'postgres') { + dataSourceOptions.password = undefined; + dataSourceOptions.ssl = { key: readFileSync(sslPaths.key), cert: readFileSync(sslPaths.cert), ca: readFileSync(sslPaths.ca) }; + } + return { ...dataSourceOptions, entities: [ProductEntity, '**/models/*.js'] }; +}; + +export const getCachedDataSource = (dbConfig: DbConfig): DataSource => { + if (connectionSingleton === undefined) { + connectionSingleton = new DataSource(createDataSourceOptions(dbConfig)); + } + return connectionSingleton; +}; + +export const getDbHealthCheckFunction = (connection: DataSource): HealthCheck => { + return async (): Promise => { + const check = connection.query('SELECT 1').then(() => { + return; + }); + return promiseTimeout(DB_TIMEOUT, check); + }; +}; + +export const dataSourceFactory: FactoryFunction = (container: DependencyContainer): DataSource => { + const config = container.resolve(SERVICES.CONFIG); + const dbConfig = config.get('db') as DbConfig; + const dataSource = getCachedDataSource(dbConfig); + return dataSource; +}; diff --git a/src/common/dependencyRegistration.ts b/src/common/dependencyRegistration.ts index 69fb39d..03d3dd0 100644 --- a/src/common/dependencyRegistration.ts +++ b/src/common/dependencyRegistration.ts @@ -1,4 +1,4 @@ -import type { ClassProvider, FactoryProvider, InjectionToken, ValueProvider, DependencyContainer } from 'tsyringe'; +import type { ClassProvider, FactoryProvider, InjectionToken, ValueProvider, DependencyContainer, RegistrationOptions } from 'tsyringe'; import { container as defaultContainer } from 'tsyringe'; import type { constructor } from 'tsyringe/dist/typings/types'; @@ -7,22 +7,20 @@ export type Providers = ValueProvider | FactoryProvider | ClassProvider export interface InjectionObject { token: InjectionToken; provider: Providers; + options?: RegistrationOptions; + postInjectionHook?: (container: DependencyContainer) => void | Promise; } -export const registerDependencies = ( +export const registerDependencies = async ( dependencies: InjectionObject[], override?: InjectionObject[], useChild = false -): DependencyContainer => { +): Promise => { const container = useChild ? defaultContainer.createChildContainer() : defaultContainer; - dependencies.forEach((injectionObj) => { - const inject = override?.find((overrideObj) => overrideObj.token === injectionObj.token) === undefined; - if (inject) { - container.register(injectionObj.token, injectionObj.provider as constructor); - } - }); - override?.forEach((injectionObj) => { - container.register(injectionObj.token, injectionObj.provider as constructor); - }); + for (const dep of dependencies) { + const injectionObj = override?.find((overrideObj) => overrideObj.token === dep.token) ?? dep; + container.register(injectionObj.token, injectionObj.provider as constructor, injectionObj.options); + await dep.postInjectionHook?.(container); + } return container; }; diff --git a/src/common/errors.ts b/src/common/errors.ts new file mode 100644 index 0000000..740506a --- /dev/null +++ b/src/common/errors.ts @@ -0,0 +1,24 @@ +import { HttpError } from '@map-colonies/error-express-handler'; +import { StatusCodes } from 'http-status-codes'; + +abstract class BaseHttpError extends Error implements HttpError { + public constructor( + message: string, + public readonly statusCode: number + ) { + super(message); + Object.setPrototypeOf(this, new.target.prototype); + } +} + +export class EmeptyResponse extends BaseHttpError { + public constructor(message: string) { + super(message, StatusCodes.OK); + } +} + +export class ProductNotFound extends BaseHttpError { + public constructor(message: string) { + super(message, StatusCodes.NOT_FOUND); + } +} diff --git a/src/common/interfaces.ts b/src/common/interfaces.ts index 455054c..8fc9882 100644 --- a/src/common/interfaces.ts +++ b/src/common/interfaces.ts @@ -1,4 +1,18 @@ +import { DataSourceOptions } from 'typeorm'; + +export type DbConfig = { + enableSslAuth: boolean; + sslPaths: { ca: string; cert: string; key: string }; +} & DataSourceOptions; + export interface IConfig { get: (setting: string) => T; has: (setting: string) => boolean; } + +export interface OpenApiConfig { + filePath: string; + basePath: string; + jsonPath: string; + uiPath: string; +} diff --git a/src/common/tracing.ts b/src/common/tracing.ts index bd05fd0..26f6200 100644 --- a/src/common/tracing.ts +++ b/src/common/tracing.ts @@ -28,3 +28,4 @@ export function getTracing(): Tracing { } return tracing; } +getTracing; diff --git a/src/common/utils/promiseTimeout.ts b/src/common/utils/promiseTimeout.ts new file mode 100644 index 0000000..7f6255c --- /dev/null +++ b/src/common/utils/promiseTimeout.ts @@ -0,0 +1,14 @@ +export class TimeoutError extends Error {} + +export const promiseTimeout = async (ms: number, promise: Promise): Promise => { + // Create a promise that rejects in milliseconds + const timeout = new Promise((_, reject) => { + const id = setTimeout(() => { + clearTimeout(id); + reject(new TimeoutError(`Timed out in + ${ms} + ms.`)); + }, ms); + }); + + // Returns a race between our timeout and the passed in promise + return Promise.race([promise, timeout]); +}; diff --git a/src/containerConfig.ts b/src/containerConfig.ts index 995aa9d..f2fd842 100644 --- a/src/containerConfig.ts +++ b/src/containerConfig.ts @@ -1,19 +1,25 @@ import { getOtelMixin } from '@map-colonies/tracing-utils'; import { trace } from '@opentelemetry/api'; import { Registry } from 'prom-client'; +import { DataSource, Repository } from 'typeorm'; +import { instancePerContainerCachingFactory } from 'tsyringe'; import type { DependencyContainer } from 'tsyringe/dist/typings/types'; -import { jsLogger } from '@map-colonies/js-logger'; +import { jsLogger, Logger } from '@map-colonies/js-logger'; import { type InjectionObject, registerDependencies } from '@common/dependencyRegistration'; import { SERVICES, SERVICE_NAME } from '@common/constants'; import { getTracing } from '@common/tracing'; +import { addTransactionalDataSource, initializeTransactionalContext, StorageDriver } from 'typeorm-transactional'; import { PRODUCT_ROUTER_SYMBOL, productRouterFactory } from './products/routes/products'; -import { anotherResourceRouterFactory, ANOTHER_RESOURCE_ROUTER_SYMBOL } from './anotherResource/routes/anotherResourceRouter'; -import { getConfig } from './common/config'; -import { AppDataSource } from './common/db/data-source'; +import { getConfig, ConfigType } from './common/config'; +import { CleanupRegistry } from '@map-colonies/cleanup-registry'; import { ProductEntity } from './products/models/entity.products'; import { ProductsController } from './products/controllers/products'; -import { ProductService } from './products/service/products.service'; -import { PRODUCT_CONTROLLER_SYMBOL, PRODUCT_REPOSITORY_SYMBOL, PRODUCT_SERVICE_SYMBOL } from './products/tokens'; +import { ProductManager } from './products/models/products'; +import { PRODUCT_CONTROLLER_SYMBOL } from './products/controllers/products'; +import { PRODUCT_REPOSITORY_SYMBOL } from './products/models/entity.products'; +import { PRODUCT_SERVICE_SYMBOL } from './products/models/products'; +import { DATA_SOURCE_PROVIDER } from './common/db/data-source'; +import { dataSourceFactory } from './common/db/data-source'; export interface RegisterOptions { override?: InjectionObject[]; @@ -21,22 +27,77 @@ export interface RegisterOptions { } export const registerExternalValues = async (options?: RegisterOptions): Promise => { + const cleanupRegistry = new CleanupRegistry(); const configInstance = getConfig(); const loggerConfig = configInstance.get('telemetry.logger'); const logger = await jsLogger({ ...loggerConfig, prettyPrint: loggerConfig.prettyPrint, mixin: getOtelMixin() }); - const tracer = trace.getTracer(SERVICE_NAME); const metricsRegistry = new Registry(); + configInstance.initializeMetrics(metricsRegistry); const dependencies: InjectionObject[] = [ { token: SERVICES.CONFIG, provider: { useValue: configInstance } }, { token: SERVICES.LOGGER, provider: { useValue: logger } }, - { token: SERVICES.TRACER, provider: { useValue: tracer } }, - { token: SERVICES.METRICS, provider: { useValue: metricsRegistry } }, - { token: ANOTHER_RESOURCE_ROUTER_SYMBOL, provider: { useFactory: anotherResourceRouterFactory } }, - { token: PRODUCT_REPOSITORY_SYMBOL, provider: { useFactory: () => AppDataSource.getRepository(ProductEntity) } }, - { token: PRODUCT_SERVICE_SYMBOL, provider: { useClass: ProductService } }, + { + token: SERVICES.CLEANUP_REGISTRY, + provider: { useValue: cleanupRegistry }, + postInjectionHook: (container): void => { + const logger = container.resolve(SERVICES.LOGGER); + const cleanupRegistryLogger = logger.child({ subComponent: 'cleanupRegistry' }); + cleanupRegistry.on('itemFailed', (id, error, msg) => cleanupRegistryLogger.error({ msg, itemId: id, err: error })); + cleanupRegistry.on('itemCompleted', (id) => cleanupRegistryLogger.info({ itemId: id, msg: 'cleanup finished for item' })); + cleanupRegistry.on('finished', (status) => cleanupRegistryLogger.info({ msg: `cleanup registry finished cleanup`, status })); + }, + }, + { + token: SERVICES.TRACER, + provider: { + useFactory: instancePerContainerCachingFactory((container) => { + const cleanupRegistry = container.resolve(SERVICES.CLEANUP_REGISTRY); + cleanupRegistry.register({ id: SERVICES.TRACER, func: getTracing().stop.bind(getTracing()) }); + const tracer = trace.getTracer(SERVICE_NAME); + return tracer; + }), + }, + }, + { + token: SERVICES.METRICS, + provider: { + useFactory: instancePerContainerCachingFactory((container) => { + const metricsRegistry = new Registry(); + const config = container.resolve(SERVICES.CONFIG); + config.initializeMetrics(metricsRegistry); + return metricsRegistry; + }), + }, + }, + { + token: DATA_SOURCE_PROVIDER, + provider: { + useFactory: instancePerContainerCachingFactory(dataSourceFactory), + }, + postInjectionHook: async (deps: DependencyContainer): Promise => { + const dataSource = deps.resolve(DATA_SOURCE_PROVIDER); + if (!dataSource.isInitialized) { + await dataSource.initialize(); + initializeTransactionalContext({ storageDriver: StorageDriver.AUTO }); + addTransactionalDataSource(dataSource); + cleanupRegistry.register({ id: DATA_SOURCE_PROVIDER, func: dataSource.destroy.bind(dataSource) }); + } + }, + }, + { + token: PRODUCT_REPOSITORY_SYMBOL, + provider: { + useFactory(container): Repository { + const dataSource = container.resolve(DATA_SOURCE_PROVIDER); + return dataSource.getRepository(ProductEntity); + }, + }, + }, + + { token: PRODUCT_SERVICE_SYMBOL, provider: { useClass: ProductManager } }, { token: PRODUCT_CONTROLLER_SYMBOL, provider: { useClass: ProductsController } }, { token: PRODUCT_ROUTER_SYMBOL, provider: { useFactory: productRouterFactory } }, { diff --git a/src/index.ts b/src/index.ts index 9e5ccf0..b7fedba 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,3 @@ -// this import must be called before the first import of tsyringe import 'reflect-metadata'; import { createServer } from 'node:http'; import { createTerminus } from '@godaddy/terminus'; @@ -6,15 +5,18 @@ import type { Logger } from '@map-colonies/js-logger'; import { SERVICES } from '@common/constants'; import type { ConfigType } from '@common/config'; import { getApp } from './app'; -import { AppDataSource } from './common/db/data-source'; void getApp() .then(([app, container]) => { const logger = container.resolve(SERVICES.LOGGER); const config = container.resolve(SERVICES.CONFIG); - const port = process.env.PORT || 8080; + const port = config.get('server.port'); const stubHealthCheck = async (): Promise => Promise.resolve(); - const server = createTerminus(createServer(app), { healthChecks: { '/liveness': stubHealthCheck }, onSignal: container.resolve('onSignal') }); + // Write real liveness. + const server = createTerminus(createServer(app), { + healthChecks: { '/liveness': stubHealthCheck }, + onSignal: container.resolve('onSignal'), + }); server.listen(port, () => { logger.info(`app started on port ${port}`); diff --git a/src/openapi.d.ts b/src/openapi.d.ts index 0a736a1..fcb38a0 100644 --- a/src/openapi.d.ts +++ b/src/openapi.d.ts @@ -4,36 +4,37 @@ import type { TypedRequestHandlers as ImportedTypedRequestHandlers } from '@map-colonies/openapi-helpers/typedRequestHandler'; export type paths = { - '/anotherResource': { + '/products': { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** gets the resource */ - get: operations['getAnotherResource']; + /** get the products */ + get: operations['getProducts']; put?: never; - post?: never; + /** creates a new record of type product */ + post: operations['createProduct']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/products': { + '/products/{id}': { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** gets the resource */ - get: operations['getProducts']; - put?: never; - /** creates a new record of type product */ - post: operations['createProduct']; - delete?: never; + get?: never; + /** update product */ + put: operations['updateProduct']; + post?: never; + /** delete product */ + delete: operations['deleteProduct']; options?: never; head?: never; patch?: never; @@ -49,31 +50,58 @@ export type components = { coordinates: number[][][]; }; error: { - message: string; + message?: string; + data?: Record; + errors?: { + field?: string; + message?: string; + }[]; }; Product: { /** Format: uuid */ id?: string; name?: string; - description?: string; + description?: string | null; bounding_polygon?: components['schemas']['GeoJsonPolygon']; consumption_link?: string | null; /** Format: double */ - resolution_best?: number; - min_zoom?: number; - max_zoom?: number; + resolution_best?: number | null; + min_zoom?: number | null; + max_zoom?: number | null; /** @enum {string} */ type?: 'raster' | 'rasterized_vector' | 'tiles3d' | 'QMesh'; /** @enum {string} */ consumption_protocol?: 'WMS' | 'WMTS' | 'XYZ' | '3D Tiles'; }; Products: components['schemas']['Product'][]; - anotherResource: { - kind: string; - isAlive: boolean; + UpdateProduct: { + name?: string; + description?: string | null; + bounding_polygon?: components['schemas']['GeoJsonPolygon']; + consumption_link?: string | null; + /** @enum {string} */ + type?: 'raster' | 'rasterized_vector' | 'tiles3d' | 'QMesh'; + /** @enum {string} */ + consumption_protocol?: 'WMS' | 'WMTS' | 'XYZ' | '3D Tiles'; + resolution_best?: number; + min_zoom?: number; + max_zoom?: number; + }; + DeletedProductResponse: { + data?: components['schemas']['Product']; + }; + }; + responses: { + /** @description Unexpected internal server error */ + InternalServerError: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['error']; + }; }; }; - responses: never; parameters: never; requestBodies: never; headers: never; @@ -81,22 +109,36 @@ export type components = { }; export type $defs = Record; export interface operations { - getAnotherResource: { + getProducts: { parameters: { - query?: never; + query?: { + name?: string; + type?: 'raster' | 'rasterized_vector' | 'tiles3d' | 'QMesh'; + description?: string; + bounding_polygon?: { + /** @enum {string} */ + type?: 'Polygon'; + coordinates?: number[][][]; + }; + consumption_link?: string; + resolution_best?: number; + consumption_protocol?: 'WMS' | 'WMTS' | 'XYZ' | '3D Tiles'; + max_zoom?: number; + min_zoom?: number; + }; header?: never; path?: never; cookie?: never; }; requestBody?: never; responses: { - /** @description OK */ + /** @description Success */ 200: { headers: { [name: string]: unknown; }; content: { - 'application/json': components['schemas']['anotherResource']; + 'application/json': components['schemas']['Products']; }; }; /** @description Bad Request */ @@ -108,27 +150,29 @@ export interface operations { 'application/json': components['schemas']['error']; }; }; + 500: components['responses']['InternalServerError']; }; }; - getProducts: { + createProduct: { parameters: { - query?: { - name?: string; - type?: 'raster' | 'rasterized_vector' | 'tiles3d' | 'QMesh'; - }; + query?: never; header?: never; path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + 'application/json': components['schemas']['Product']; + }; + }; responses: { - /** @description A JSON array of products */ - 200: { + /** @description created */ + 201: { headers: { [name: string]: unknown; }; content: { - 'application/json': components['schemas']['Products']; + 'application/json': components['schemas']['Product']; }; }; /** @description Bad Request */ @@ -140,23 +184,26 @@ export interface operations { 'application/json': components['schemas']['error']; }; }; + 500: components['responses']['InternalServerError']; }; }; - createProduct: { + updateProduct: { parameters: { query?: never; header?: never; - path?: never; + path: { + id: string; + }; cookie?: never; }; requestBody: { content: { - 'application/json': components['schemas']['Product']; + 'application/json': components['schemas']['UpdateProduct']; }; }; responses: { - /** @description created */ - 201: { + /** @description updated */ + 200: { headers: { [name: string]: unknown; }; @@ -173,6 +220,57 @@ export interface operations { 'application/json': components['schemas']['error']; }; }; + /** @description Product not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['error']; + }; + }; + 500: components['responses']['InternalServerError']; + }; + }; + deleteProduct: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Product deleted */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['DeletedProductResponse']; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['error']; + }; + }; + /** @description Product not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['error']; + }; + }; + 500: components['responses']['InternalServerError']; }; }; } diff --git a/src/products/controllers/products.ts b/src/products/controllers/products.ts index 9688cf1..8782374 100644 --- a/src/products/controllers/products.ts +++ b/src/products/controllers/products.ts @@ -4,45 +4,30 @@ import { injectable, inject } from 'tsyringe'; import { type Registry, Counter } from 'prom-client'; import type { TypedRequestHandlers } from '@openapi'; import { SERVICES } from '@common/constants'; -import { ProductService } from '../service/products.service'; import { ProductManager } from '../models/products'; -import { PRODUCT_SERVICE_SYMBOL } from '../tokens'; import { getProductsQuerySchema } from '../schema/products.schema'; -import { ZodError } from 'zod'; -import { createProductSchema } from '../schema/products.schema'; +import { createProductSchema, deleteProductSchema, updateProductSchema } from '../schema/products.schema'; import { QueryFailedError } from 'typeorm'; +export const PRODUCT_CONTROLLER_SYMBOL = Symbol('ProductController'); + @injectable() export class ProductsController { - private readonly createdResourceCounter: Counter; - public constructor( - @inject(SERVICES.LOGGER) private readonly logger: Logger, @inject(ProductManager) private readonly manager: ProductManager, - @inject(SERVICES.METRICS) private readonly metricsRegistry: Registry, - @inject(PRODUCT_SERVICE_SYMBOL) private readonly productService: ProductService - ) { - const existingMetric = this.metricsRegistry.getSingleMetric('created_resource'); - - this.createdResourceCounter = - (existingMetric as Counter) ?? - new Counter({ - name: 'created_resource', - help: 'number of created resources', - registers: [this.metricsRegistry], - }); - } + @inject(SERVICES.LOGGER) private readonly logger: Logger + ) {} - public getProducts: TypedRequestHandlers['getProducts'] = async (req, res, next) => { + public getProducts: TypedRequestHandlers['GET /products'] = async (req, res, next) => { try { const hasFilters = Object.keys(req.query ?? {}).length > 0; - if (!hasFilters) { - const allProducts = await this.productService.getAllProducts(); + const allProducts = await this.manager.getAllProducts(); return res.json(allProducts); } + const parsed = getProductsQuerySchema.parse(req.query); - const productsByQyery = await this.productService.getFilteredProducts(parsed); + const productsByQyery = await this.manager.getFilteredProducts(parsed); return res.json(productsByQyery); } catch (error) { return next(error); @@ -50,40 +35,67 @@ export class ProductsController { }; public createProducts: TypedRequestHandlers['POST /products'] = async (req, res, next) => { - const createdResource = this.manager.createProduct(req.body); try { const parsedBody = createProductSchema.parse(req.body); - const createProduct = await this.productService.createProduct({ - name: parsedBody.name, - bounding_polygon: parsedBody.bounding_polygon, - type: parsedBody.type, - consumption_protocol: parsedBody.consumption_protocol, - consumption_link: parsedBody.consumption_link ?? null, - description: parsedBody.description ?? null, - resolution_best: parsedBody.resolution_best ?? null, - min_zoom: parsedBody.min_zoom ?? null, - max_zoom: parsedBody.max_zoom ?? null, - }); + const createdProduct = await this.manager.createProduct(parsedBody); + res.status(201).json({ message: 'New product created', - // data: createProduct, + id: createdProduct.id, }); } catch (error) { - if (error instanceof ZodError) { - return res.status(400).json({}); - } else if (error instanceof QueryFailedError) { + if (error instanceof QueryFailedError) { if (error.driverError.code == '23514' && error.driverError.constraint == 'check_polygon') { const field = 'bounding_polygon'; return res.status(400).json({ message: 'Invalid polygon, please insert valid polygon', }); } - return res.status(400).json({ message: 'Invalid request', }); } + next(error); + } + }; + public updateProduct: TypedRequestHandlers['PUT /products/{id}'] = async (req, res, next) => { + try { + const parsedBody = updateProductSchema.parse(req.body); + const id = req.params.id; + const updateProduct = await this.manager.updateProduct(id as string, parsedBody); + res.status(200).json({ + message: `Producet updated`, + id: id, + }); + } catch (error) { + if (error instanceof QueryFailedError) { + { + if (error.driverError.code == '23514' && error.driverError.constraint == 'check_polygon') { + return res.status(400).json({ + message: 'bounding_polygon broken', + }); + } + return res.status(400).json({ + message: 'Invalid request', + }); + } + } + next(error); + } + }; + + public deleteProduct: TypedRequestHandlers['DELETE /products/{id}'] = async (req, res, next) => { + try { + const parsedBody = deleteProductSchema.parse(req.params); + const id = parsedBody.id; + const deletedProduct = await this.manager.deleteProduct(id as string); + + return res.json({ + message: `Product ${id} was deleted successfully`, + data: deletedProduct, + }); + } catch (error) { next(error); } }; diff --git a/src/products/models/entity.products.ts b/src/products/models/entity.products.ts index 18e57c3..b62799f 100644 --- a/src/products/models/entity.products.ts +++ b/src/products/models/entity.products.ts @@ -1,22 +1,26 @@ import { components } from '@src/openapi'; import { Entity, Column, PrimaryGeneratedColumn, Check } from 'typeorm'; -import { ProductModel } from './products'; type GeoJsonPolygon = components['schemas']['GeoJsonPolygon']; +export type ProductModel = components['schemas']['Product']; +export type ProductsModel = components['schemas']['Products']; +export const PRODUCT_REPOSITORY_SYMBOL = Symbol('ProductRepository'); -export enum ProductType { - raster = 'raster', - rasterized_vector = 'rasterized_vector', - tiles3d = 'tiles3d', - QMesh = 'QMesh', -} +export const ProductType = { + raster: 'raster', + rasterized_vector: 'rasterized_vector', + tiles3d: 'tiles3d', + QMesh: 'QMesh', +} as const satisfies Record; +export type ProductType = (typeof ProductType)[keyof typeof ProductType]; -export enum ConsumptionProtocol { - WMS = 'WMS', - WMTS = 'WMTS', - XYZ = 'XYZ', - TILES_3D = '3D Tiles', -} +export const ConsumptionProtocol = { + WMS: 'WMS', + WMTS: 'WMTS', + XYZ: 'XYZ', + TILES_3D: '3D Tiles', +} as const satisfies Record; +export type ConsumptionProtocol = (typeof ConsumptionProtocol)[keyof typeof ConsumptionProtocol]; @Entity({ name: 'products' }) @Check(`check_polygon`, `ST_IsValid(bounding_polygon)`) @@ -31,7 +35,7 @@ export class ProductEntity implements ProductModel { name!: string; @Column({ type: 'text' }) - description!: string; + description!: string | null; @Column({ type: 'geometry', @@ -45,22 +49,24 @@ export class ProductEntity implements ProductModel { @Column({ type: 'enum', + nullable: true, enum: ProductType, }) type!: ProductType; @Column({ type: 'enum', + nullable: true, enum: ConsumptionProtocol, }) consumption_protocol!: ConsumptionProtocol; @Column({ type: 'float' }) - resolution_best!: number; + resolution_best!: number | null; @Column({ type: 'integer' }) - min_zoom!: number; + min_zoom!: number | null; @Column({ type: 'integer' }) - max_zoom!: number; + max_zoom!: number | null; } diff --git a/src/products/models/products.ts b/src/products/models/products.ts index 6c2a1df..b401d3f 100644 --- a/src/products/models/products.ts +++ b/src/products/models/products.ts @@ -1,43 +1,116 @@ +import { And, ILike, MoreThan, LessThan, MoreThanOrEqual, LessThanOrEqual, FindOperator, Repository } from 'typeorm'; import type { Logger } from '@map-colonies/js-logger'; -import { inject, injectable } from 'tsyringe'; import type { components } from '@openapi'; import { SERVICES } from '@common/constants'; +import { ProductEntity } from './entity.products.js'; +import type { GetProductsQuery } from '../schema/products.schema.js'; +import { inject, injectable } from 'tsyringe'; +import { PRODUCT_REPOSITORY_SYMBOL } from './entity.products.js'; +import type { Tracer } from '@opentelemetry/api'; +import { ProductsModel } from './entity.products.js'; +import { EmeptyResponse, ProductNotFound } from '@src/common/errors.js'; -const resourceInstance: ProductModel = { - id: '1', - name: 'ronin', - description: 'can you do a logistics run?', -}; -const resourceInstances: ProductsModel = [ - { - id: '1', - name: 'ronin', - description: 'can you do a logistics run?', - }, -]; -function generateRandomId(): number { - const rangeOfIds = 100; - return Math.floor(Math.random() * rangeOfIds); +//buildOperators get array of TypeORM query operators and combines them into single operator, +// return undefined if emepty. +function buildOperators(ops: FindOperator[]): FindOperator | undefined { + if (ops.length === 0) return undefined; + if (ops.length === 1) return ops[0]; + return And(...ops); } -export type ProductModel = components['schemas']['Product']; -export type ProductsModel = components['schemas']['Products']; +function buildNumericOperator(gt?: number, lt?: number, gte?: number, lte?: number): FindOperator | undefined { + const operatorsCollection: FindOperator[] = []; + if (gt !== undefined) operatorsCollection.push(MoreThan(gt)); + if (lt !== undefined) operatorsCollection.push(LessThan(lt)); + if (gte !== undefined) operatorsCollection.push(MoreThanOrEqual(gte)); + if (lte !== undefined) operatorsCollection.push(LessThanOrEqual(lte)); + const result = buildOperators(operatorsCollection); + return result; +} @injectable() export class ProductManager { - public constructor(@inject(SERVICES.LOGGER) private readonly logger: Logger) {} + public constructor( + @inject(SERVICES.TRACER) private readonly tracer: Tracer, + @inject(PRODUCT_REPOSITORY_SYMBOL) private readonly repository: Repository, + @inject(SERVICES.LOGGER) private readonly logger: Logger + ) {} + + public async getAllProducts(): Promise { + const allProducts = await this.repository.find(); + + if (allProducts.length === 0) { + throw new EmeptyResponse('There is no products.'); + } + return allProducts; + } + + public async getFilteredProducts(filters: GetProductsQuery) { + const where: Partial> = {}; - public getProducts(): ProductsModel { - this.logger.info({ msg: 'getting resource', count: resourceInstances.length }); + if (filters.name) where.name = `${filters.name}`; + if (filters.type) where.type = filters.type; + if (filters.consumption_protocol) where.consumption_protocol = filters.consumption_protocol; - return resourceInstances; + const resolution = buildNumericOperator( + filters.resolution_best_gt, + filters.resolution_best_lt, + filters.resolution_best_gte, + filters.resolution_best_lte + ); + if (resolution) where.resolution_best = resolution; + + const minZoomOps = buildNumericOperator(filters.min_zoom_gt, filters.min_zoom_lt, filters.min_zoom_gte, filters.min_zoom_lte); + if (minZoomOps) where.min_zoom = minZoomOps; + + const maxZoomsOps = buildNumericOperator(filters.max_zoom_gt, filters.max_zoom_lt, filters.max_zoom_gte, filters.max_zoom_lte); + if (maxZoomsOps) where.max_zoom = maxZoomsOps; + + return await this.repository.find({ where }); } - public createProduct(resource: ProductModel): ProductModel { - const resourceId = String(generateRandomId()); + public async createProduct(data: Partial) { + const productToCreate = { + ...data, + consumption_link: data.consumption_link ?? null, + description: data.description ?? null, + resolution_best: data.resolution_best ?? null, + min_zoom: data.min_zoom ?? null, + max_zoom: data.max_zoom ?? null, + }; + const product = this.repository.create(productToCreate); + const savedProduct = await this.repository.save(product); + return savedProduct; + } - this.logger.info({ msg: 'creating resource', resourceId }); + public async updateProduct(id: string, data: Partial) { + const productToUpdate = { + ...(data.name !== undefined && { name: data.name }), + ...(data.bounding_polygon !== undefined && { bounding_polygon: data.bounding_polygon }), + ...(data.type !== undefined && { type: data.type }), + ...(data.consumption_protocol !== undefined && { consumption_protocol: data.consumption_protocol }), + ...(data.consumption_link !== undefined && { consumption_link: data.consumption_link }), + ...(data.description !== undefined && { description: data.description }), + ...(data.resolution_best !== undefined && { resolution_best: data.resolution_best }), + ...(data.min_zoom !== undefined && { min_zoom: data.min_zoom }), + ...(data.max_zoom !== undefined && { max_zoom: data.max_zoom }), + }; - return { ...resource, id: resourceId }; + const result = await this.repository.update(id, productToUpdate); + if (result.affected === 0) throw new ProductNotFound(`Cant update is: ${id}, id was not found`); + return result; + } + + public async deleteProduct(id: string) { + const productRemove = await this.repository.findOneBy({ + id: id, + }); + if (!productRemove) { + throw new ProductNotFound(`Cant delete ${id}, was not found`); + } + await this.repository.remove(productRemove); + return productRemove; } } + +export const PRODUCT_SERVICE_SYMBOL = Symbol('ProductService'); diff --git a/src/products/routes/products.ts b/src/products/routes/products.ts index d67775a..00f8d84 100644 --- a/src/products/routes/products.ts +++ b/src/products/routes/products.ts @@ -5,14 +5,10 @@ import { ProductsController } from '../controllers/products'; export const productRouterFactory: FactoryFunction = (dependencyContainer) => { const router = Router(); const controller = dependencyContainer.resolve(ProductsController); - router.get('/', controller.getProducts); - router.post('/', controller.createProducts); - - // router.put("/:id", controller.updateProducts) - - // router.delete("/:id", controller.deleteProducts) + router.put('/:id', controller.updateProduct); + router.delete('/:id', controller.deleteProduct); return router; }; diff --git a/src/products/schema/products.schema.ts b/src/products/schema/products.schema.ts index 61e6f09..f637bab 100644 --- a/src/products/schema/products.schema.ts +++ b/src/products/schema/products.schema.ts @@ -13,14 +13,29 @@ export const expectedSchema = { consumption_link: 'string | null (optional)', }; -export const createProductSchema = z.object({ +export const deletingProductSchema = z.object({ + id: z.string().uuid(), name: z.string().trim().min(1).max(48), description: z.string().trim().max(5000), bounding_polygon: z.object({ type: z.literal('Polygon'), coordinates: z.array(z.array(z.tuple([z.number(), z.number()]))).min(1), }), + consumption_link: z.string().nullable().optional(), + type: z.nativeEnum(ProductType), + consumption_protocol: z.nativeEnum(ConsumptionProtocol), + resolution_best: z.number(), + min_zoom: z.number().int(), + max_zoom: z.number().int(), +}); +export const createProductSchema = z.object({ + name: z.string().trim().min(1).max(48), + description: z.string().trim().max(5000), + bounding_polygon: z.object({ + type: z.literal('Polygon'), + coordinates: z.array(z.array(z.tuple([z.number(), z.number()]))).min(1), + }), consumption_link: z.string().nullable().optional(), type: z.nativeEnum(ProductType), consumption_protocol: z.nativeEnum(ConsumptionProtocol), @@ -31,18 +46,23 @@ export const createProductSchema = z.object({ export const updateProductSchema = createProductSchema.partial(); +export const deleteProductSchema = deletingProductSchema.partial(); + export const getProductsQuerySchema = z.object({ name: z.string().optional(), type: z.nativeEnum(ProductType).optional(), consumption_protocol: z.nativeEnum(ConsumptionProtocol).optional(), + resolution_best_gt: z.coerce.number().optional(), resolution_best_lt: z.coerce.number().optional(), resolution_best_gte: z.coerce.number().optional(), resolution_best_lte: z.coerce.number().optional(), + min_zoom_gt: z.coerce.number().int().optional(), min_zoom_lt: z.coerce.number().int().optional(), min_zoom_gte: z.coerce.number().int().optional(), min_zoom_lte: z.coerce.number().int().optional(), + max_zoom_gt: z.coerce.number().int().optional(), max_zoom_lt: z.coerce.number().int().optional(), max_zoom_gte: z.coerce.number().int().optional(), diff --git a/src/products/service/products.service.ts b/src/products/service/products.service.ts deleted file mode 100644 index 2e76ba0..0000000 --- a/src/products/service/products.service.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { And, ILike, MoreThan, LessThan, MoreThanOrEqual, LessThanOrEqual, FindOperator } from 'typeorm'; -import { AppDataSource } from '@src/common/db/data-source.js'; -import { ProductEntity } from '../models/entity.products.js'; -import type { GetProductsQuery } from '../schema/products.schema.js'; -import { inject, injectable } from 'tsyringe'; -import { PRODUCT_ROUTER_SYMBOL } from '../routes/products.js'; -import { PRODUCT_REPOSITORY_SYMBOL } from '../tokens.js'; - -function buildOperators(ops: FindOperator[]): FindOperator | undefined { - if (ops.length === 0) return undefined; - if (ops.length === 1) return ops[0]; - return And(...ops); -} - -@injectable() -export class ProductService { - public constructor( - @inject(PRODUCT_REPOSITORY_SYMBOL) - private readonly productService: ProductService - ) {} - - public async createProduct(data: Partial) { - const repo = AppDataSource.getRepository(ProductEntity); - const product = repo.create(data); - const savedProduct = await repo.save(product); - return savedProduct; - } - - public async getAllProducts() { - const repo = AppDataSource.getRepository(ProductEntity); - return repo.find(); - } - - public async getFilteredProducts(filters: GetProductsQuery) { - const repo = AppDataSource.getRepository(ProductEntity); - const where: Partial> = {}; - - if (filters.name) where.name = ILike(`%${filters.name}%`); - if (filters.type) where.type = filters.type; - if (filters.consumption_protocol) where.consumption_protocol = filters.consumption_protocol; - - const resolutionOps: FindOperator[] = []; - if (filters.resolution_best_gt !== undefined) resolutionOps.push(MoreThan(filters.resolution_best_gt)); - if (filters.resolution_best_lt !== undefined) resolutionOps.push(LessThan(filters.resolution_best_lt)); - if (filters.resolution_best_gte !== undefined) resolutionOps.push(MoreThanOrEqual(filters.resolution_best_gte)); - if (filters.resolution_best_lte !== undefined) resolutionOps.push(LessThanOrEqual(filters.resolution_best_lte)); - const resolution = buildOperators(resolutionOps); - if (resolution) where.resolution_best = resolution; - - const minZoomOps: FindOperator[] = []; - if (filters.min_zoom_gt !== undefined) minZoomOps.push(MoreThan(filters.min_zoom_gt)); - if (filters.min_zoom_lt !== undefined) minZoomOps.push(LessThan(filters.min_zoom_lt)); - if (filters.min_zoom_gte !== undefined) minZoomOps.push(MoreThanOrEqual(filters.min_zoom_gte)); - if (filters.min_zoom_lte !== undefined) minZoomOps.push(LessThanOrEqual(filters.min_zoom_lte)); - const minZoom = buildOperators(minZoomOps); - if (minZoom) where.min_zoom = minZoom; - - const maxZoomOps: FindOperator[] = []; - if (filters.max_zoom_gt !== undefined) maxZoomOps.push(MoreThan(filters.max_zoom_gt)); - if (filters.max_zoom_lt !== undefined) maxZoomOps.push(LessThan(filters.max_zoom_lt)); - if (filters.max_zoom_gte !== undefined) maxZoomOps.push(MoreThanOrEqual(filters.max_zoom_gte)); - if (filters.max_zoom_lte !== undefined) maxZoomOps.push(LessThanOrEqual(filters.max_zoom_lte)); - const maxZoom = buildOperators(maxZoomOps); - if (maxZoom) where.max_zoom = maxZoom; - - return repo.find({ where }); - } - - public async updateProduct(id: string, data: Partial) { - const repo = AppDataSource.getRepository(ProductEntity); - const result = await repo.update(id, data); - if (result.affected === 0) throw new Error(`Product with ID ${id} not found`); - return result; - } -} diff --git a/src/products/tokens.ts b/src/products/tokens.ts deleted file mode 100644 index 2f4b26d..0000000 --- a/src/products/tokens.ts +++ /dev/null @@ -1,3 +0,0 @@ -export const PRODUCT_REPOSITORY_SYMBOL = Symbol('ProductRepository'); -export const PRODUCT_SERVICE_SYMBOL = Symbol('ProductService'); -export const PRODUCT_CONTROLLER_SYMBOL = Symbol('ProductController'); diff --git a/src/serverBuilder.ts b/src/serverBuilder.ts index 1af4294..eaaf830 100644 --- a/src/serverBuilder.ts +++ b/src/serverBuilder.ts @@ -12,8 +12,7 @@ import { Registry } from 'prom-client'; import type { ConfigType } from '@common/config'; import { SERVICES } from '@common/constants'; import { PRODUCT_ROUTER_SYMBOL } from './products/routes/products'; -import { ANOTHER_RESOURCE_ROUTER_SYMBOL } from './anotherResource/routes/anotherResourceRouter'; -import { PRODUCT_SERVICE_SYMBOL } from './products/tokens'; + @injectable() export class ServerBuilder { private readonly serverInstance: express.Application; @@ -21,8 +20,7 @@ export class ServerBuilder { @inject(SERVICES.CONFIG) private readonly config: ConfigType, @inject(SERVICES.LOGGER) private readonly logger: Logger, @inject(SERVICES.METRICS) private readonly metricsRegistry: Registry, - @inject(PRODUCT_ROUTER_SYMBOL) private readonly productsRouter: Router, - @inject(ANOTHER_RESOURCE_ROUTER_SYMBOL) private readonly anotherResourceRouter: Router + @inject(PRODUCT_ROUTER_SYMBOL) private readonly productsRouter: Router ) { this.serverInstance = express(); } @@ -46,7 +44,6 @@ export class ServerBuilder { private buildRoutes(): void { this.serverInstance.use('/products', this.productsRouter); - this.serverInstance.use('/anotherResource', this.anotherResourceRouter); this.buildDocsRoutes(); } diff --git a/tests/integration/anotherResource/anotherResourceName.spec.ts b/tests/integration/anotherResource/anotherResourceName.spec.ts deleted file mode 100644 index 2a4b49d..0000000 --- a/tests/integration/anotherResource/anotherResourceName.spec.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { jsLogger } from '@map-colonies/js-logger'; -import { describe, beforeEach, it, expect, beforeAll } from 'vitest'; -import { trace } from '@opentelemetry/api'; -import httpStatusCodes from 'http-status-codes'; -import { createRequestSender, type RequestSender } from '@map-colonies/openapi-helpers/requestSender'; -import type { paths, operations } from '@openapi'; -import { getApp } from '@src/app'; -import { SERVICES } from '@src/common/constants'; -import { initConfig } from '@src/common/config'; - -describe('anotherResourceName', function () { - let requestSender: RequestSender; - - beforeAll(async function () { - await initConfig(true); - }); - - beforeEach(async function () { - const [app] = await getApp({ - override: [ - { token: SERVICES.LOGGER, provider: { useValue: await jsLogger({ enabled: false }) } }, - { token: SERVICES.TRACER, provider: { useValue: trace.getTracer('testTracer') } }, - ], - useChild: true, - }); - requestSender = await createRequestSender('openapi3.yaml', app); - }); - - describe('Happy Path', function () { - it('should return 200 status code and the resource', async function () { - const response = await requestSender.getAnotherResource(); - - expect(response.status).toBe(httpStatusCodes.OK); - expect(response).toSatisfyApiSpec(); - - const resource = response.body as paths['/anotherResource']['get']['responses'][200]['content']['application/json']; - - expect(resource.kind).toBe('avi'); - expect(resource.isAlive).toBe(false); - }); - }); - - describe('Bad Path', function () { - // All requests with status code of 400 - it('should in theory test 400 status code', function () { - expect(true).toBe(true); - }); - }); - - describe('Sad Path', function () { - // All requests with status code 4XX-5XX - it('should in theory test 500 status code', function () { - expect(true).toBe(true); - }); - }); -}); diff --git a/tests/integration/resourceName/resourceName.spec.ts b/tests/integration/resourceName/resourceName.spec.ts index a317713..c630aa4 100644 --- a/tests/integration/resourceName/resourceName.spec.ts +++ b/tests/integration/resourceName/resourceName.spec.ts @@ -28,24 +28,40 @@ describe('resourceName', function () { describe('Happy Path', function () { it('should return 200 status code and the resource', async function () { - const response = await requestSender.getResourceName(); + const response = await requestSender.getProducts(); expect(response.status).toBe(httpStatusCodes.OK); - const resource = response.body as paths['/resourceName']['get']['responses'][200]['content']['application/json']; + const resource = response.body as paths['/products']['get']['responses'][200]['content']['application/json']; expect(response).toSatisfyApiSpec(); - expect(resource.id).toBe(1); - expect(resource.name).toBe('ronin'); - expect(resource.description).toBe('can you do a logistics run?'); + expect(resource[0].id).toBe('237f8676-08a0-4617-83b8-81ad661e80a3'); + expect(resource[0].name).toBe('sss'); + expect(resource[0].description).toBe('valid description'); }); it('should return 200 status code and create the resource', async function () { - const response = await requestSender.createResource({ + const response = await requestSender.createProduct({ requestBody: { - description: 'aaa', - id: 1, - name: 'aaa', + name: 'sssss3', + description: 'valid description', + bounding_polygon: { + type: 'Polygon', + coordinates: [ + [ + [34.78, 32.08], + [34.79, 32.08], + [34.79, 32.09], + [34.78, 32.08], + ], + ], + }, + consumption_link: null, + type: 'raster', + consumption_protocol: 'WMS', + resolution_best: 1, + min_zoom: 1, + max_zoom: 10, }, }); diff --git a/tests/unit/anotherResource/models/anotherResourceManager.spec.ts b/tests/unit/anotherResource/models/anotherResourceManager.spec.ts deleted file mode 100644 index c086bb4..0000000 --- a/tests/unit/anotherResource/models/anotherResourceManager.spec.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { jsLogger } from '@map-colonies/js-logger'; -import { describe, beforeEach, it, expect } from 'vitest'; -import { AnotherResourceManager } from '@src/anotherResource/models/anotherResourceManager'; - -let anotherResourceManager: AnotherResourceManager; - -describe('ResourceNameManager', () => { - beforeEach(async function () { - anotherResourceManager = new AnotherResourceManager(await jsLogger({ enabled: false })); - }); - - describe('#getResource', () => { - it('should return resource of kind avi', function () { - // action - const resource = anotherResourceManager.getResource(); - - // expectation - expect(resource.kind).toBe('avi'); - expect(resource.isAlive).toBe(false); - }); - }); -}); diff --git a/tests/unit/resourceName/models/resourceNameModel.spec.ts b/tests/unit/resourceName/models/resourceNameModel.spec.ts index 2037217..747bfbf 100644 --- a/tests/unit/resourceName/models/resourceNameModel.spec.ts +++ b/tests/unit/resourceName/models/resourceNameModel.spec.ts @@ -1,21 +1,21 @@ import { jsLogger } from '@map-colonies/js-logger'; import { describe, beforeEach, it, expect } from 'vitest'; -import { ResourceNameManager } from '@src/products/models/products'; +import { ProductManager } from '@src/products/models/products'; -let resourceNameManager: ResourceNameManager; +let resourceNameManager: ProductManager; -describe('ResourceNameManager', () => { +describe('ProductManager', () => { beforeEach(async function () { - resourceNameManager = new ResourceNameManager(await jsLogger({ enabled: false })); + resourceNameManager = new ProductManager(await jsLogger({ enabled: false })); }); describe('#getResource', () => { it('should return the resource of id 1', function () { // action - const resource = resourceNameManager.getResource(); + const resource = ProductManager.getAllProducts(); // expectation - expect(resource.id).toBe(1); + expect(resource[0].id).toBe(1); expect(resource.name).toBe('ronin'); expect(resource.description).toBe('can you do a logistics run?'); }); @@ -24,7 +24,7 @@ describe('ResourceNameManager', () => { describe('#createResource', () => { it('should return the resource of id 1', function () { // action - const resource = resourceNameManager.createResource({ description: 'meow', id: 1, name: 'cat' }); + const resource = resourceNameManager.createProduct({ description: 'meow', id: 1, name: 'cat' }); // expectation expect(resource.id).toBeLessThanOrEqual(100);