From 0d792423854236bc2de3b90fb1858b4065a99b8e Mon Sep 17 00:00:00 2001 From: Asif Date: Tue, 28 Apr 2026 18:55:16 +0300 Subject: [PATCH 01/10] feat: add update and delete product --- openapi3.yaml | 125 ++++++++++++++-- src/app.ts | 2 +- src/containerConfig.ts | 6 +- src/openapi.d.ts | 133 +++++++++++++++++- src/products/controllers/products.ts | 120 ++++++++++++---- src/products/models/entity.products.ts | 11 +- .../{service => models}/products.service.ts | 75 +++++++--- src/products/models/products.ts | 12 +- src/products/routes/products.ts | 4 +- src/products/schema/products.schema.ts | 19 ++- src/serverBuilder.ts | 7 +- 11 files changed, 431 insertions(+), 83 deletions(-) rename src/products/{service => models}/products.service.ts (57%) diff --git a/openapi3.yaml b/openapi3.yaml index b1030d6..c57b3f2 100644 --- a/openapi3.yaml +++ b/openapi3.yaml @@ -84,6 +84,75 @@ paths: application/json: schema: $ref: '#/components/schemas/error' + /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' + 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' security: - {} components: @@ -110,11 +179,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 +202,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 +229,6 @@ components: - rasterized_vector - tiles3d - QMesh - consumption_protocol: type: string enum: @@ -166,6 +240,37 @@ components: type: array items: $ref: '#/components/schemas/Product' + UpdateProduct: + type: object + properties: + 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 + 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' anotherResource: type: object required: diff --git a/src/app.ts b/src/app.ts index fa1ff30..b04f140 100644 --- a/src/app.ts +++ b/src/app.ts @@ -11,7 +11,7 @@ async function getApp(registerOptions?: RegisterOptions): Promise<[Application, try { if (!AppDataSource.isInitialized) { await AppDataSource.initialize(); - console.log('DB connected'); + console.info('DB connected'); } } catch (err) { console.error('DB connection error:', err); diff --git a/src/containerConfig.ts b/src/containerConfig.ts index 995aa9d..730bd2e 100644 --- a/src/containerConfig.ts +++ b/src/containerConfig.ts @@ -7,12 +7,11 @@ import { type InjectionObject, registerDependencies } from '@common/dependencyRe import { SERVICES, SERVICE_NAME } from '@common/constants'; import { getTracing } from '@common/tracing'; 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 { ProductEntity } from './products/models/entity.products'; import { ProductsController } from './products/controllers/products'; -import { ProductService } from './products/service/products.service'; +import { ProductManager } from './products/models/products.service'; import { PRODUCT_CONTROLLER_SYMBOL, PRODUCT_REPOSITORY_SYMBOL, PRODUCT_SERVICE_SYMBOL } from './products/tokens'; export interface RegisterOptions { @@ -33,10 +32,9 @@ export const registerExternalValues = async (options?: RegisterOptions): Promise { 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: PRODUCT_SERVICE_SYMBOL, provider: { useClass: ProductManager } }, { token: PRODUCT_CONTROLLER_SYMBOL, provider: { useClass: ProductsController } }, { token: PRODUCT_ROUTER_SYMBOL, provider: { useFactory: productRouterFactory } }, { diff --git a/src/openapi.d.ts b/src/openapi.d.ts index 0a736a1..a987bee 100644 --- a/src/openapi.d.ts +++ b/src/openapi.d.ts @@ -39,6 +39,24 @@ export type paths = { patch?: never; trace?: never; }; + '/products/{id}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** update product */ + put: operations['updateProduct']; + post?: never; + /** delete product */ + delete: operations['deleteProduct']; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; }; export type webhooks = Record; export type components = { @@ -49,25 +67,46 @@ 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'][]; + 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']; + }; anotherResource: { kind: string; isAlive: boolean; @@ -175,5 +214,89 @@ export interface operations { }; }; }; + updateProduct: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['UpdateProduct']; + }; + }; + responses: { + /** @description updated */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['Product']; + }; + }; + /** @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']; + }; + }; + }; + }; + 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']; + }; + }; + }; + }; } export type TypedRequestHandlers = ImportedTypedRequestHandlers; diff --git a/src/products/controllers/products.ts b/src/products/controllers/products.ts index 9688cf1..84784d7 100644 --- a/src/products/controllers/products.ts +++ b/src/products/controllers/products.ts @@ -4,31 +4,29 @@ 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 { ProductManager } from '../models/products.service'; import { getProductsQuerySchema } from '../schema/products.schema'; import { ZodError } from 'zod'; -import { createProductSchema } from '../schema/products.schema'; -import { QueryFailedError } from 'typeorm'; +import { createProductSchema, deleteProductSchema, updateProductSchema } from '../schema/products.schema'; +import { QueryFailedError, UpdateDateColumn } from 'typeorm'; +import { parse } from 'dotenv'; @injectable() export class ProductsController { - private readonly createdResourceCounter: Counter; - + private readonly createdProductCounter: 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 + @inject(SERVICES.METRICS) private readonly metricsRegistry: Registry + // @inject(PRODUCT_SERVICE_SYMBOL) private readonly ProductManager: ProductManager ) { const existingMetric = this.metricsRegistry.getSingleMetric('created_resource'); - this.createdResourceCounter = + this.createdProductCounter = (existingMetric as Counter) ?? new Counter({ - name: 'created_resource', - help: 'number of created resources', + name: 'created_product', + help: 'number of created products', registers: [this.metricsRegistry], }); } @@ -38,11 +36,16 @@ export class ProductsController { const hasFilters = Object.keys(req.query ?? {}).length > 0; if (!hasFilters) { - const allProducts = await this.productService.getAllProducts(); + const allProducts = await this.manager.getAllProducts(); + if (allProducts.length === 0) { + return res.json({ + message: 'There is no products', + }); + } 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,27 +53,23 @@ 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({}); + return res.status(400).json({ + message: 'Validation failed', + errors: error.issues.map((issue) => ({ + field: issue.path.join('.'), + message: issue.message, + })), + }); } else if (error instanceof QueryFailedError) { if (error.driverError.code == '23514' && error.driverError.constraint == 'check_polygon') { const field = 'bounding_polygon'; @@ -87,4 +86,69 @@ export class ProductsController { 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 Error && error.message.includes('not found')) { + return res.status(404).json({ + message: error.message, + }); + } else if (error instanceof ZodError) { + return res.status(400).json({ + message: 'Validation error', + errors: error.issues.map((issue) => { + const field = issue.path.join('.'); + return { + field: field, + message: issue.message, + }; + }), + }); + } else 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.body); + const id = parsedBody.id; + const deletedProduct = await this.manager.deleteProduct(id as string); + console.log('Check'); + + return res.json({ + data: deletedProduct, + }); + } catch (error) { + if (error instanceof ZodError) { + return res.status(400).json({ + message: 'Validation failed', + errors: error.issues.map((issue) => ({ + field: issue.path.join('.'), + message: issue.message, + })), + }); + } + next(error); + } + }; } diff --git a/src/products/models/entity.products.ts b/src/products/models/entity.products.ts index 18e57c3..1b00f99 100644 --- a/src/products/models/entity.products.ts +++ b/src/products/models/entity.products.ts @@ -1,7 +1,6 @@ import { components } from '@src/openapi'; import { Entity, Column, PrimaryGeneratedColumn, Check } from 'typeorm'; -import { ProductModel } from './products'; - +import { ProductModel } from './products.service'; type GeoJsonPolygon = components['schemas']['GeoJsonPolygon']; export enum ProductType { @@ -31,7 +30,7 @@ export class ProductEntity implements ProductModel { name!: string; @Column({ type: 'text' }) - description!: string; + description!: string | null; @Column({ type: 'geometry', @@ -56,11 +55,11 @@ export class ProductEntity implements ProductModel { 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/service/products.service.ts b/src/products/models/products.service.ts similarity index 57% rename from src/products/service/products.service.ts rename to src/products/models/products.service.ts index 2e76ba0..fa97a0a 100644 --- a/src/products/service/products.service.ts +++ b/src/products/models/products.service.ts @@ -1,10 +1,14 @@ import { And, ILike, MoreThan, LessThan, MoreThanOrEqual, LessThanOrEqual, FindOperator } from 'typeorm'; +import type { Logger } from '@map-colonies/js-logger'; +import type { components } from '@openapi'; +import { SERVICES } from '@common/constants'; import { AppDataSource } from '@src/common/db/data-source.js'; -import { ProductEntity } from '../models/entity.products.js'; +import { ProductEntity } from './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'; +// import { PRODUCT_ROUTER_SYMBOL } from '../routes/products.js'; +// import { PRODUCT_REPOSITORY_SYMBOL } from '../tokens.js'; +import { createProductSchema } from '../schema/products.schema'; function buildOperators(ops: FindOperator[]): FindOperator | undefined { if (ops.length === 0) return undefined; @@ -12,19 +16,12 @@ function buildOperators(ops: FindOperator[]): FindOperator | undefined return And(...ops); } -@injectable() -export class ProductService { - public constructor( - @inject(PRODUCT_REPOSITORY_SYMBOL) - private readonly productService: ProductService - ) {} +export type ProductModel = components['schemas']['Product']; +export type ProductsModel = components['schemas']['Products']; - public async createProduct(data: Partial) { - const repo = AppDataSource.getRepository(ProductEntity); - const product = repo.create(data); - const savedProduct = await repo.save(product); - return savedProduct; - } +@injectable() +export class ProductManager { + public constructor(@inject(SERVICES.LOGGER) private readonly logger: Logger) {} public async getAllProducts() { const repo = AppDataSource.getRepository(ProductEntity); @@ -66,10 +63,54 @@ export class ProductService { return repo.find({ where }); } + public async createProduct(data: Partial) { + const productToCreate = { + name: data.name, + bounding_polygon: data.bounding_polygon, + type: data.type, + consumption_protocol: data.consumption_protocol, + 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 repo = AppDataSource.getRepository(ProductEntity); + const product = repo.create(productToCreate); + const savedProduct = await repo.save(product); + return savedProduct; + } + 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 }), + }; + 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`); + const result = await repo.update(id, productToUpdate); + if (result.affected === 0) throw new Error(`ID: ${id} not found`); + return result; } + + public async deleteProduct(id: string) { + const repo = AppDataSource.getRepository(ProductEntity); + const productRemove = await repo.findOneBy({ + id: id, + }); + if (!productRemove) { + throw Error('Product not found'); + } + await repo.remove(productRemove); + return productRemove; + } } diff --git a/src/products/models/products.ts b/src/products/models/products.ts index 6c2a1df..a4c5eb7 100644 --- a/src/products/models/products.ts +++ b/src/products/models/products.ts @@ -2,19 +2,21 @@ import type { Logger } from '@map-colonies/js-logger'; import { inject, injectable } from 'tsyringe'; import type { components } from '@openapi'; import { SERVICES } from '@common/constants'; +import { PRODUCT_REPOSITORY_SYMBOL } from '../tokens'; -const resourceInstance: ProductModel = { +const productInstance: ProductModel = { id: '1', name: 'ronin', description: 'can you do a logistics run?', }; -const resourceInstances: ProductsModel = [ +const productInstances: ProductsModel = [ { id: '1', name: 'ronin', description: 'can you do a logistics run?', }, ]; + function generateRandomId(): number { const rangeOfIds = 100; return Math.floor(Math.random() * rangeOfIds); @@ -28,9 +30,9 @@ export class ProductManager { public constructor(@inject(SERVICES.LOGGER) private readonly logger: Logger) {} public getProducts(): ProductsModel { - this.logger.info({ msg: 'getting resource', count: resourceInstances.length }); + this.logger.info({ msg: 'getting resource', count: productInstances.length }); - return resourceInstances; + return productInstances; } public createProduct(resource: ProductModel): ProductModel { @@ -40,4 +42,6 @@ export class ProductManager { return { ...resource, id: resourceId }; } + + public updateProduct(resource: ProductsModel) {} } diff --git a/src/products/routes/products.ts b/src/products/routes/products.ts index d67775a..0fb20f5 100644 --- a/src/products/routes/products.ts +++ b/src/products/routes/products.ts @@ -10,9 +10,9 @@ export const productRouterFactory: FactoryFunction = (dependencyContaine router.post('/', controller.createProducts); - // router.put("/:id", controller.updateProducts) + router.put('/:id', controller.updateProduct); - // router.delete("/:id", controller.deleteProducts) + 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..dea08da 100644 --- a/src/products/schema/products.schema.ts +++ b/src/products/schema/products.schema.ts @@ -1,6 +1,8 @@ import { z } from 'zod'; import { ProductType, ConsumptionProtocol } from '../models/entity.products'; +// export const = + export const expectedSchema = { name: 'string', description: 'string', @@ -13,14 +15,29 @@ export const expectedSchema = { consumption_link: 'string | null (optional)', }; -export const createProductSchema = z.object({ +export const deleteProductSchema = 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), 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(); } From df9682fe0c7783b61d0c4e1186d5eb3fc420f25e Mon Sep 17 00:00:00 2001 From: Asif Date: Wed, 29 Apr 2026 11:41:47 +0300 Subject: [PATCH 02/10] feat: add get vars --- openapi3.yaml | 33 ++++++++++++++++++++++++++ src/app.ts | 1 - src/index.ts | 3 +-- src/openapi.d.ts | 7 ++++++ src/products/controllers/products.ts | 10 ++++---- src/products/schema/products.schema.ts | 4 +++- 6 files changed, 48 insertions(+), 10 deletions(-) diff --git a/openapi3.yaml b/openapi3.yaml index c57b3f2..c5118fc 100644 --- a/openapi3.yaml +++ b/openapi3.yaml @@ -46,6 +46,39 @@ paths: - rasterized_vector - tiles3d - QMesh + - name: description + in: query + schema: + type: string + - name: bounding_polygon + in: query + schema: + $ref: '#/components/schemas/GeoJsonPolygon' + - 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: gets the resource responses: 200: diff --git a/src/app.ts b/src/app.ts index b04f140..5dcfa22 100644 --- a/src/app.ts +++ b/src/app.ts @@ -2,7 +2,6 @@ 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]> { diff --git a/src/index.ts b/src/index.ts index 9e5ccf0..07f573c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,13 +6,12 @@ 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') }); diff --git a/src/openapi.d.ts b/src/openapi.d.ts index a987bee..0e651a0 100644 --- a/src/openapi.d.ts +++ b/src/openapi.d.ts @@ -154,6 +154,13 @@ export interface operations { query?: { name?: string; type?: 'raster' | 'rasterized_vector' | 'tiles3d' | 'QMesh'; + description?: string; + bounding_polygon?: components['schemas']['GeoJsonPolygon']; + consumption_link?: string; + resolution_best?: number; + consumption_protocol?: 'WMS' | 'WMTS' | 'XYZ' | '3D Tiles'; + max_zoom?: number; + min_zoom?: number; }; header?: never; path?: never; diff --git a/src/products/controllers/products.ts b/src/products/controllers/products.ts index 84784d7..f9be0e4 100644 --- a/src/products/controllers/products.ts +++ b/src/products/controllers/products.ts @@ -8,8 +8,7 @@ import { ProductManager } from '../models/products.service'; import { getProductsQuerySchema } from '../schema/products.schema'; import { ZodError } from 'zod'; import { createProductSchema, deleteProductSchema, updateProductSchema } from '../schema/products.schema'; -import { QueryFailedError, UpdateDateColumn } from 'typeorm'; -import { parse } from 'dotenv'; +import { QueryFailedError } from 'typeorm'; @injectable() export class ProductsController { @@ -18,10 +17,8 @@ export class ProductsController { @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 ProductManager: ProductManager ) { const existingMetric = this.metricsRegistry.getSingleMetric('created_resource'); - this.createdProductCounter = (existingMetric as Counter) ?? new Counter({ @@ -44,6 +41,7 @@ export class ProductsController { } return res.json(allProducts); } + const parsed = getProductsQuerySchema.parse(req.query); const productsByQyery = await this.manager.getFilteredProducts(parsed); return res.json(productsByQyery); @@ -130,12 +128,12 @@ export class ProductsController { public deleteProduct: TypedRequestHandlers['DELETE /products/{id}'] = async (req, res, next) => { try { - const parsedBody = deleteProductSchema.parse(req.body); + const parsedBody = deleteProductSchema.parse(req.params); const id = parsedBody.id; const deletedProduct = await this.manager.deleteProduct(id as string); - console.log('Check'); return res.json({ + message: `Product ${id} was deleted successfully`, data: deletedProduct, }); } catch (error) { diff --git a/src/products/schema/products.schema.ts b/src/products/schema/products.schema.ts index dea08da..c6fee53 100644 --- a/src/products/schema/products.schema.ts +++ b/src/products/schema/products.schema.ts @@ -15,7 +15,7 @@ export const expectedSchema = { consumption_link: 'string | null (optional)', }; -export const deleteProductSchema = 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), @@ -48,6 +48,8 @@ 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(), From 503a2d4771ceef309445662f4fc190818852d139 Mon Sep 17 00:00:00 2001 From: Asif Date: Wed, 29 Apr 2026 12:05:43 +0300 Subject: [PATCH 03/10] test: test --- src/app.ts | 1 - src/products/controllers/products.ts | 2 - src/products/models/products.service.ts | 3 - src/products/models/products.ts | 94 ++++++++++++------------- 4 files changed, 47 insertions(+), 53 deletions(-) diff --git a/src/app.ts b/src/app.ts index 5dcfa22..3549bed 100644 --- a/src/app.ts +++ b/src/app.ts @@ -18,7 +18,6 @@ async function getApp(registerOptions?: RegisterOptions): Promise<[Application, } const app = container.resolve(ServerBuilder).build(); - return [app, container]; } diff --git a/src/products/controllers/products.ts b/src/products/controllers/products.ts index f9be0e4..0a9896a 100644 --- a/src/products/controllers/products.ts +++ b/src/products/controllers/products.ts @@ -31,7 +31,6 @@ export class ProductsController { public getProducts: TypedRequestHandlers['getProducts'] = async (req, res, next) => { try { const hasFilters = Object.keys(req.query ?? {}).length > 0; - if (!hasFilters) { const allProducts = await this.manager.getAllProducts(); if (allProducts.length === 0) { @@ -80,7 +79,6 @@ export class ProductsController { message: 'Invalid request', }); } - next(error); } }; diff --git a/src/products/models/products.service.ts b/src/products/models/products.service.ts index fa97a0a..a2051f1 100644 --- a/src/products/models/products.service.ts +++ b/src/products/models/products.service.ts @@ -6,9 +6,6 @@ import { AppDataSource } from '@src/common/db/data-source.js'; import { ProductEntity } from './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'; -import { createProductSchema } from '../schema/products.schema'; function buildOperators(ops: FindOperator[]): FindOperator | undefined { if (ops.length === 0) return undefined; diff --git a/src/products/models/products.ts b/src/products/models/products.ts index a4c5eb7..c70716c 100644 --- a/src/products/models/products.ts +++ b/src/products/models/products.ts @@ -1,47 +1,47 @@ -import type { Logger } from '@map-colonies/js-logger'; -import { inject, injectable } from 'tsyringe'; -import type { components } from '@openapi'; -import { SERVICES } from '@common/constants'; -import { PRODUCT_REPOSITORY_SYMBOL } from '../tokens'; - -const productInstance: ProductModel = { - id: '1', - name: 'ronin', - description: 'can you do a logistics run?', -}; -const productInstances: ProductsModel = [ - { - id: '1', - name: 'ronin', - description: 'can you do a logistics run?', - }, -]; - -function generateRandomId(): number { - const rangeOfIds = 100; - return Math.floor(Math.random() * rangeOfIds); -} - -export type ProductModel = components['schemas']['Product']; -export type ProductsModel = components['schemas']['Products']; - -@injectable() -export class ProductManager { - public constructor(@inject(SERVICES.LOGGER) private readonly logger: Logger) {} - - public getProducts(): ProductsModel { - this.logger.info({ msg: 'getting resource', count: productInstances.length }); - - return productInstances; - } - - public createProduct(resource: ProductModel): ProductModel { - const resourceId = String(generateRandomId()); - - this.logger.info({ msg: 'creating resource', resourceId }); - - return { ...resource, id: resourceId }; - } - - public updateProduct(resource: ProductsModel) {} -} +// import type { Logger } from '@map-colonies/js-logger'; +// import { inject, injectable } from 'tsyringe'; +// import type { components } from '@openapi'; +// import { SERVICES } from '@common/constants'; +// import { PRODUCT_REPOSITORY_SYMBOL } from '../tokens'; + +// const productInstance: ProductModel = { +// id: '1', +// name: 'ronin', +// description: 'can you do a logistics run?', +// }; +// const productInstances: ProductsModel = [ +// { +// id: '1', +// name: 'ronin', +// description: 'can you do a logistics run?', +// }, +// ]; + +// function generateRandomId(): number { +// const rangeOfIds = 100; +// return Math.floor(Math.random() * rangeOfIds); +// } + +// export type ProductModel = components['schemas']['Product']; +// export type ProductsModel = components['schemas']['Products']; + +// @injectable() +// export class ProductManager { +// public constructor(@inject(SERVICES.LOGGER) private readonly logger: Logger) {} + +// public getProducts(): ProductsModel { +// this.logger.info({ msg: 'getting resource', count: productInstances.length }); + +// return productInstances; +// } + +// public createProduct(resource: ProductModel): ProductModel { +// const resourceId = String(generateRandomId()); + +// this.logger.info({ msg: 'creating resource', resourceId }); + +// return { ...resource, id: resourceId }; +// } + +// public updateProduct(resource: ProductsModel) {} +// } From 736149108f3d9f5ca5dae5f0626ef8353631f945 Mon Sep 17 00:00:00 2001 From: Asif Date: Wed, 29 Apr 2026 12:07:56 +0300 Subject: [PATCH 04/10] test: test --- src/containerConfig.ts | 2 +- src/products/controllers/products.ts | 2 +- src/products/models/entity.products.ts | 2 +- ...roducts.service.ts => products.manager.ts} | 0 src/products/models/products.ts | 47 ------------------- 5 files changed, 3 insertions(+), 50 deletions(-) rename src/products/models/{products.service.ts => products.manager.ts} (100%) delete mode 100644 src/products/models/products.ts diff --git a/src/containerConfig.ts b/src/containerConfig.ts index 730bd2e..c53330e 100644 --- a/src/containerConfig.ts +++ b/src/containerConfig.ts @@ -11,7 +11,7 @@ import { getConfig } from './common/config'; import { AppDataSource } from './common/db/data-source'; import { ProductEntity } from './products/models/entity.products'; import { ProductsController } from './products/controllers/products'; -import { ProductManager } from './products/models/products.service'; +import { ProductManager } from './products/models/products.manager'; import { PRODUCT_CONTROLLER_SYMBOL, PRODUCT_REPOSITORY_SYMBOL, PRODUCT_SERVICE_SYMBOL } from './products/tokens'; export interface RegisterOptions { diff --git a/src/products/controllers/products.ts b/src/products/controllers/products.ts index 0a9896a..36e9941 100644 --- a/src/products/controllers/products.ts +++ b/src/products/controllers/products.ts @@ -4,7 +4,7 @@ import { injectable, inject } from 'tsyringe'; import { type Registry, Counter } from 'prom-client'; import type { TypedRequestHandlers } from '@openapi'; import { SERVICES } from '@common/constants'; -import { ProductManager } from '../models/products.service'; +import { ProductManager } from '../models/products.manager'; import { getProductsQuerySchema } from '../schema/products.schema'; import { ZodError } from 'zod'; import { createProductSchema, deleteProductSchema, updateProductSchema } from '../schema/products.schema'; diff --git a/src/products/models/entity.products.ts b/src/products/models/entity.products.ts index 1b00f99..aed663c 100644 --- a/src/products/models/entity.products.ts +++ b/src/products/models/entity.products.ts @@ -1,6 +1,6 @@ import { components } from '@src/openapi'; import { Entity, Column, PrimaryGeneratedColumn, Check } from 'typeorm'; -import { ProductModel } from './products.service'; +import { ProductModel } from './products.manager'; type GeoJsonPolygon = components['schemas']['GeoJsonPolygon']; export enum ProductType { diff --git a/src/products/models/products.service.ts b/src/products/models/products.manager.ts similarity index 100% rename from src/products/models/products.service.ts rename to src/products/models/products.manager.ts diff --git a/src/products/models/products.ts b/src/products/models/products.ts deleted file mode 100644 index c70716c..0000000 --- a/src/products/models/products.ts +++ /dev/null @@ -1,47 +0,0 @@ -// import type { Logger } from '@map-colonies/js-logger'; -// import { inject, injectable } from 'tsyringe'; -// import type { components } from '@openapi'; -// import { SERVICES } from '@common/constants'; -// import { PRODUCT_REPOSITORY_SYMBOL } from '../tokens'; - -// const productInstance: ProductModel = { -// id: '1', -// name: 'ronin', -// description: 'can you do a logistics run?', -// }; -// const productInstances: ProductsModel = [ -// { -// id: '1', -// name: 'ronin', -// description: 'can you do a logistics run?', -// }, -// ]; - -// function generateRandomId(): number { -// const rangeOfIds = 100; -// return Math.floor(Math.random() * rangeOfIds); -// } - -// export type ProductModel = components['schemas']['Product']; -// export type ProductsModel = components['schemas']['Products']; - -// @injectable() -// export class ProductManager { -// public constructor(@inject(SERVICES.LOGGER) private readonly logger: Logger) {} - -// public getProducts(): ProductsModel { -// this.logger.info({ msg: 'getting resource', count: productInstances.length }); - -// return productInstances; -// } - -// public createProduct(resource: ProductModel): ProductModel { -// const resourceId = String(generateRandomId()); - -// this.logger.info({ msg: 'creating resource', resourceId }); - -// return { ...resource, id: resourceId }; -// } - -// public updateProduct(resource: ProductsModel) {} -// } From 9e28a646c2a245ba7fd28c913186932027f38841 Mon Sep 17 00:00:00 2001 From: Asif Date: Wed, 29 Apr 2026 12:34:04 +0300 Subject: [PATCH 05/10] test: test protection --- src/containerConfig.ts | 2 +- src/products/controllers/products.ts | 2 +- src/products/models/entity.products.ts | 2 +- .../models/{products.manager.ts => products.ts} | 7 +++++++ .../unit/resourceName/models/resourceNameModel.spec.ts | 10 +++++----- 5 files changed, 15 insertions(+), 8 deletions(-) rename src/products/models/{products.manager.ts => products.ts} (96%) diff --git a/src/containerConfig.ts b/src/containerConfig.ts index c53330e..38881db 100644 --- a/src/containerConfig.ts +++ b/src/containerConfig.ts @@ -11,7 +11,7 @@ import { getConfig } from './common/config'; import { AppDataSource } from './common/db/data-source'; import { ProductEntity } from './products/models/entity.products'; import { ProductsController } from './products/controllers/products'; -import { ProductManager } from './products/models/products.manager'; +import { ProductManager } from './products/models/products'; import { PRODUCT_CONTROLLER_SYMBOL, PRODUCT_REPOSITORY_SYMBOL, PRODUCT_SERVICE_SYMBOL } from './products/tokens'; export interface RegisterOptions { diff --git a/src/products/controllers/products.ts b/src/products/controllers/products.ts index 36e9941..3cac4b2 100644 --- a/src/products/controllers/products.ts +++ b/src/products/controllers/products.ts @@ -4,7 +4,7 @@ import { injectable, inject } from 'tsyringe'; import { type Registry, Counter } from 'prom-client'; import type { TypedRequestHandlers } from '@openapi'; import { SERVICES } from '@common/constants'; -import { ProductManager } from '../models/products.manager'; +import { ProductManager } from '../models/products'; import { getProductsQuerySchema } from '../schema/products.schema'; import { ZodError } from 'zod'; import { createProductSchema, deleteProductSchema, updateProductSchema } from '../schema/products.schema'; diff --git a/src/products/models/entity.products.ts b/src/products/models/entity.products.ts index aed663c..51e036f 100644 --- a/src/products/models/entity.products.ts +++ b/src/products/models/entity.products.ts @@ -1,6 +1,6 @@ import { components } from '@src/openapi'; import { Entity, Column, PrimaryGeneratedColumn, Check } from 'typeorm'; -import { ProductModel } from './products.manager'; +import { ProductModel } from './products'; type GeoJsonPolygon = components['schemas']['GeoJsonPolygon']; export enum ProductType { diff --git a/src/products/models/products.manager.ts b/src/products/models/products.ts similarity index 96% rename from src/products/models/products.manager.ts rename to src/products/models/products.ts index a2051f1..287a788 100644 --- a/src/products/models/products.manager.ts +++ b/src/products/models/products.ts @@ -7,6 +7,13 @@ import { ProductEntity } from './entity.products.js'; import type { GetProductsQuery } from '../schema/products.schema.js'; import { inject, injectable } from 'tsyringe'; +// const productInstance: IResourceNameModel = { +// id: 1, +// name: 'ronin', +// description: 'can you do a logistics run?', +// }; +// export type IResourceNameModel = components['schemas']['resource']; + function buildOperators(ops: FindOperator[]): FindOperator | undefined { if (ops.length === 0) return undefined; if (ops.length === 1) return ops[0]; diff --git a/tests/unit/resourceName/models/resourceNameModel.spec.ts b/tests/unit/resourceName/models/resourceNameModel.spec.ts index 2037217..0847d6c 100644 --- a/tests/unit/resourceName/models/resourceNameModel.spec.ts +++ b/tests/unit/resourceName/models/resourceNameModel.spec.ts @@ -1,18 +1,18 @@ 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', () => { 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 = resourceNameManager.getAllProducts(); // expectation expect(resource.id).toBe(1); @@ -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); From 9ec7e838e75adcb8eeca5616e32f0650c1532c19 Mon Sep 17 00:00:00 2001 From: Asif Date: Wed, 29 Apr 2026 14:11:47 +0300 Subject: [PATCH 06/10] test: test --- .vscode/launch.json | 34 ------------------- launch.json | 15 ++++++++ src/products/models/products.ts | 2 +- .../resourceName/resourceName.spec.ts | 31 +++++++++++++---- 4 files changed, 40 insertions(+), 42 deletions(-) delete mode 100644 .vscode/launch.json create mode 100644 launch.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/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/src/products/models/products.ts b/src/products/models/products.ts index 287a788..95357a2 100644 --- a/src/products/models/products.ts +++ b/src/products/models/products.ts @@ -27,7 +27,7 @@ export type ProductsModel = components['schemas']['Products']; export class ProductManager { public constructor(@inject(SERVICES.LOGGER) private readonly logger: Logger) {} - public async getAllProducts() { + public async getAllProducts(): Promise { const repo = AppDataSource.getRepository(ProductEntity); return repo.find(); } diff --git a/tests/integration/resourceName/resourceName.spec.ts b/tests/integration/resourceName/resourceName.spec.ts index a317713..88c5c7f 100644 --- a/tests/integration/resourceName/resourceName.spec.ts +++ b/tests/integration/resourceName/resourceName.spec.ts @@ -28,24 +28,41 @@ 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.id).toBe('19c7d6ba-3979-46b0-81da-394021756dd0'); expect(resource.name).toBe('ronin'); expect(resource.description).toBe('can you do a logistics run?'); }); it('should return 200 status code and create the resource', async function () { - const response = await requestSender.createResource({ + const response = await requestSender.createProducts({ requestBody: { - description: 'aaa', - id: 1, - name: 'aaa', + id: '19c7d6ba-3979-46b0-81da-394021756dd0', + 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, }, }); From 35aacac7808395fdfa1d363e65d87aeadeeaf155 Mon Sep 17 00:00:00 2001 From: Asif Date: Wed, 29 Apr 2026 16:09:13 +0300 Subject: [PATCH 07/10] feat: create new dependency on repo --- src/common/db/data-source.ts | 1 + src/containerConfig.ts | 12 ++++++++-- src/index.ts | 1 + src/products/controllers/products.ts | 2 +- src/products/models/products.ts | 36 ++++++++++------------------ 5 files changed, 26 insertions(+), 26 deletions(-) diff --git a/src/common/db/data-source.ts b/src/common/db/data-source.ts index b23659b..c615337 100644 --- a/src/common/db/data-source.ts +++ b/src/common/db/data-source.ts @@ -2,6 +2,7 @@ import 'reflect-metadata'; import { DataSource } from 'typeorm'; import { ProductEntity } from '@src/products/models/entity.products'; +// Take it from CONFIG. export const AppDataSource = new DataSource({ type: 'postgres', host: 'localhost', diff --git a/src/containerConfig.ts b/src/containerConfig.ts index 38881db..5989042 100644 --- a/src/containerConfig.ts +++ b/src/containerConfig.ts @@ -1,6 +1,7 @@ import { getOtelMixin } from '@map-colonies/tracing-utils'; import { trace } from '@opentelemetry/api'; import { Registry } from 'prom-client'; +import { DataSource, Repository } from 'typeorm'; import type { DependencyContainer } from 'tsyringe/dist/typings/types'; import { jsLogger } from '@map-colonies/js-logger'; import { type InjectionObject, registerDependencies } from '@common/dependencyRegistration'; @@ -32,8 +33,15 @@ export const registerExternalValues = async (options?: RegisterOptions): Promise { token: SERVICES.LOGGER, provider: { useValue: logger } }, { token: SERVICES.TRACER, provider: { useValue: tracer } }, { token: SERVICES.METRICS, provider: { useValue: metricsRegistry } }, - - { token: PRODUCT_REPOSITORY_SYMBOL, provider: { useFactory: () => AppDataSource.getRepository(ProductEntity) } }, + { + token: PRODUCT_REPOSITORY_SYMBOL, + provider: { + useFactory(container): Repository { + const dataSource = container.resolve(PRODUCT_REPOSITORY_SYMBOL); + 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 07f573c..d29c928 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,6 +13,7 @@ void getApp() const config = container.resolve(SERVICES.CONFIG); const port = config.get('server.port'); const stubHealthCheck = async (): Promise => Promise.resolve(); + // Write real liveness. const server = createTerminus(createServer(app), { healthChecks: { '/liveness': stubHealthCheck }, onSignal: container.resolve('onSignal') }); server.listen(port, () => { diff --git a/src/products/controllers/products.ts b/src/products/controllers/products.ts index 3cac4b2..48e2450 100644 --- a/src/products/controllers/products.ts +++ b/src/products/controllers/products.ts @@ -14,8 +14,8 @@ import { QueryFailedError } from 'typeorm'; export class ProductsController { private readonly createdProductCounter: Counter; public constructor( - @inject(SERVICES.LOGGER) private readonly logger: Logger, @inject(ProductManager) private readonly manager: ProductManager, + @inject(SERVICES.LOGGER) private readonly logger: Logger, @inject(SERVICES.METRICS) private readonly metricsRegistry: Registry ) { const existingMetric = this.metricsRegistry.getSingleMetric('created_resource'); diff --git a/src/products/models/products.ts b/src/products/models/products.ts index 95357a2..e64375b 100644 --- a/src/products/models/products.ts +++ b/src/products/models/products.ts @@ -1,4 +1,4 @@ -import { And, ILike, MoreThan, LessThan, MoreThanOrEqual, LessThanOrEqual, FindOperator } from 'typeorm'; +import { And, ILike, MoreThan, LessThan, MoreThanOrEqual, LessThanOrEqual, FindOperator, Repository } from 'typeorm'; import type { Logger } from '@map-colonies/js-logger'; import type { components } from '@openapi'; import { SERVICES } from '@common/constants'; @@ -6,13 +6,7 @@ import { AppDataSource } from '@src/common/db/data-source.js'; import { ProductEntity } from './entity.products.js'; import type { GetProductsQuery } from '../schema/products.schema.js'; import { inject, injectable } from 'tsyringe'; - -// const productInstance: IResourceNameModel = { -// id: 1, -// name: 'ronin', -// description: 'can you do a logistics run?', -// }; -// export type IResourceNameModel = components['schemas']['resource']; +import { PRODUCT_REPOSITORY_SYMBOL } from '../tokens.js'; function buildOperators(ops: FindOperator[]): FindOperator | undefined { if (ops.length === 0) return undefined; @@ -25,15 +19,16 @@ export type ProductsModel = components['schemas']['Products']; @injectable() export class ProductManager { - public constructor(@inject(SERVICES.LOGGER) private readonly logger: Logger) {} + public constructor( + @inject(SERVICES.LOGGER) private readonly logger: Logger, + @inject(PRODUCT_REPOSITORY_SYMBOL) private readonly repository: Repository + ) {} public async getAllProducts(): Promise { - const repo = AppDataSource.getRepository(ProductEntity); - return repo.find(); + return await this.repository.find(); } public async getFilteredProducts(filters: GetProductsQuery) { - const repo = AppDataSource.getRepository(ProductEntity); const where: Partial> = {}; if (filters.name) where.name = ILike(`%${filters.name}%`); @@ -64,7 +59,7 @@ export class ProductManager { const maxZoom = buildOperators(maxZoomOps); if (maxZoom) where.max_zoom = maxZoom; - return repo.find({ where }); + return await this.repository.find({ where }); } public async createProduct(data: Partial) { @@ -79,10 +74,8 @@ export class ProductManager { min_zoom: data.min_zoom ?? null, max_zoom: data.max_zoom ?? null, }; - - const repo = AppDataSource.getRepository(ProductEntity); - const product = repo.create(productToCreate); - const savedProduct = await repo.save(product); + const product = this.repository.create(productToCreate); + const savedProduct = await this.repository.save(product); return savedProduct; } @@ -99,22 +92,19 @@ export class ProductManager { ...(data.max_zoom !== undefined && { max_zoom: data.max_zoom }), }; - const repo = AppDataSource.getRepository(ProductEntity); - const result = await repo.update(id, productToUpdate); + const result = await this.repository.update(id, productToUpdate); if (result.affected === 0) throw new Error(`ID: ${id} not found`); - return result; } public async deleteProduct(id: string) { - const repo = AppDataSource.getRepository(ProductEntity); - const productRemove = await repo.findOneBy({ + const productRemove = await this.repository.findOneBy({ id: id, }); if (!productRemove) { throw Error('Product not found'); } - await repo.remove(productRemove); + await this.repository.remove(productRemove); return productRemove; } } From ef69fc9dfda082eec248c96f9e530fb1a6653b6d Mon Sep 17 00:00:00 2001 From: Asif Date: Mon, 4 May 2026 18:07:21 +0300 Subject: [PATCH 08/10] feat: add config --- .gitignore | 2 + config/default.json | 28 +++-- docker-compose.yml | 18 ++++ launch.json | 15 --- openapi3.yaml | 29 ----- package-lock.json | 102 +++++++++++++++++- package.json | 2 + .../controllers/anotherResourceController.ts | 29 ----- .../models/anotherResourceManager.ts | 20 ---- .../routes/anotherResourceRouter.ts | 16 --- src/app.ts | 12 --- src/common/constants.ts | 1 + src/common/db/data-source.ts | 76 ++++++++++--- src/common/dependencyRegistration.ts | 22 ++-- src/common/interfaces.ts | 14 +++ src/common/utils/promiseTimeout.ts | 14 +++ src/containerConfig.ts | 52 ++++++++- src/index.ts | 5 +- src/openapi.d.ts | 50 --------- src/products/models/products.ts | 5 +- .../anotherResourceName.spec.ts | 56 ---------- .../models/anotherResourceManager.spec.ts | 22 ---- 22 files changed, 295 insertions(+), 295 deletions(-) create mode 100644 docker-compose.yml delete mode 100644 launch.json delete mode 100644 src/anotherResource/controllers/anotherResourceController.ts delete mode 100644 src/anotherResource/models/anotherResourceManager.ts delete mode 100644 src/anotherResource/routes/anotherResourceRouter.ts create mode 100644 src/common/utils/promiseTimeout.ts delete mode 100644 tests/integration/anotherResource/anotherResourceName.spec.ts delete mode 100644 tests/unit/anotherResource/models/anotherResourceManager.spec.ts 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/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 deleted file mode 100644 index 8e8d5c4..0000000 --- a/launch.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "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 c5118fc..c020e56 100644 --- a/openapi3.yaml +++ b/openapi3.yaml @@ -8,25 +8,6 @@ info: 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 @@ -304,13 +285,3 @@ components: properties: data: $ref: '#/components/schemas/Product' - anotherResource: - type: object - required: - - kind - - isAlive - properties: - kind: - type: string - isAlive: - type: boolean 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 3549bed..05b7448 100644 --- a/src/app.ts +++ b/src/app.ts @@ -2,21 +2,9 @@ import type { Application } from 'express'; import type { DependencyContainer } from 'tsyringe'; import { registerExternalValues, type RegisterOptions } from './containerConfig'; import { ServerBuilder } from './serverBuilder'; -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.info('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 c615337..76723a4 100644 --- a/src/common/db/data-source.ts +++ b/src/common/db/data-source.ts @@ -1,17 +1,65 @@ 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'; -// Take it from CONFIG. -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 AppDataSource = new DataSource({ +// type: 'postgres', +// host: 'localhost', +// port: 5432, +// username: 'myuser', +// password: 'mypassword', +// database: 'mydatabase', +// schema: 'public', +// synchronize: true, +// logging: false, +// entities: [ProductEntity], +// }); + +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)); + // console.log('DataSource options entities:', connectionSingleton.options.entities); + } + 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/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/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 5989042..55c9c3d 100644 --- a/src/containerConfig.ts +++ b/src/containerConfig.ts @@ -2,18 +2,22 @@ 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 { getConfig } from './common/config'; -import { AppDataSource } from './common/db/data-source'; +import { CleanupRegistry } from '@map-colonies/cleanup-registry'; import { ProductEntity } from './products/models/entity.products'; import { ProductsController } from './products/controllers/products'; import { ProductManager } from './products/models/products'; import { PRODUCT_CONTROLLER_SYMBOL, PRODUCT_REPOSITORY_SYMBOL, PRODUCT_SERVICE_SYMBOL } from './products/tokens'; +import { DATA_SOURCE_PROVIDER } from './common/db/data-source'; +import { dataSourceFactory } from './common/db/data-source'; export interface RegisterOptions { override?: InjectionObject[]; @@ -21,27 +25,67 @@ 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 } }, + + // METRICS: Avg time, how many operations been on the service, how many products got, how many errors. + // Func against error Functions. + + { + 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: { useValue: tracer }, + postInjectionHook: (container): void => { + // const + }, + }, { token: SERVICES.METRICS, provider: { useValue: 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(PRODUCT_REPOSITORY_SYMBOL); + 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 d29c928..1a6f0fa 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,7 +14,10 @@ void getApp() const port = config.get('server.port'); const stubHealthCheck = async (): Promise => Promise.resolve(); // Write real liveness. - const server = createTerminus(createServer(app), { healthChecks: { '/liveness': stubHealthCheck }, onSignal: container.resolve('onSignal') }); + 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 0e651a0..758a770 100644 --- a/src/openapi.d.ts +++ b/src/openapi.d.ts @@ -4,23 +4,6 @@ import type { TypedRequestHandlers as ImportedTypedRequestHandlers } from '@map-colonies/openapi-helpers/typedRequestHandler'; export type paths = { - '/anotherResource': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** gets the resource */ - get: operations['getAnotherResource']; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; '/products': { parameters: { query?: never; @@ -107,10 +90,6 @@ export type components = { DeletedProductResponse: { data?: components['schemas']['Product']; }; - anotherResource: { - kind: string; - isAlive: boolean; - }; }; responses: never; parameters: never; @@ -120,35 +99,6 @@ export type components = { }; export type $defs = Record; export interface operations { - getAnotherResource: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description OK */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['anotherResource']; - }; - }; - /** @description Bad Request */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['error']; - }; - }; - }; - }; getProducts: { parameters: { query?: { diff --git a/src/products/models/products.ts b/src/products/models/products.ts index e64375b..e63123d 100644 --- a/src/products/models/products.ts +++ b/src/products/models/products.ts @@ -2,7 +2,6 @@ import { And, ILike, MoreThan, LessThan, MoreThanOrEqual, LessThanOrEqual, FindO import type { Logger } from '@map-colonies/js-logger'; import type { components } from '@openapi'; import { SERVICES } from '@common/constants'; -import { AppDataSource } from '@src/common/db/data-source.js'; import { ProductEntity } from './entity.products.js'; import type { GetProductsQuery } from '../schema/products.schema.js'; import { inject, injectable } from 'tsyringe'; @@ -20,8 +19,8 @@ export type ProductsModel = components['schemas']['Products']; @injectable() export class ProductManager { public constructor( - @inject(SERVICES.LOGGER) private readonly logger: Logger, - @inject(PRODUCT_REPOSITORY_SYMBOL) private readonly repository: Repository + @inject(PRODUCT_REPOSITORY_SYMBOL) private readonly repository: Repository, + @inject(SERVICES.LOGGER) private readonly logger: Logger ) {} public async getAllProducts(): Promise { 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/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); - }); - }); -}); From 29a86bc412c64a7bd2d9144b3b1315289a2af6f0 Mon Sep 17 00:00:00 2001 From: Asif Date: Tue, 5 May 2026 15:45:16 +0300 Subject: [PATCH 09/10] feat: fix cr --- src/common/db/data-source.ts | 14 ------- src/common/tracing.ts | 1 + src/containerConfig.ts | 29 +++++++++----- src/index.ts | 1 - src/products/controllers/products.ts | 55 +++----------------------- src/products/models/entity.products.ts | 32 +++++++++------ src/products/models/products.ts | 16 +++++++- src/products/schema/products.schema.ts | 5 ++- 8 files changed, 62 insertions(+), 91 deletions(-) diff --git a/src/common/db/data-source.ts b/src/common/db/data-source.ts index 76723a4..3244505 100644 --- a/src/common/db/data-source.ts +++ b/src/common/db/data-source.ts @@ -16,19 +16,6 @@ const DB_TIMEOUT = 5000; export const DATA_SOURCE_PROVIDER = Symbol('dataSourceProvider'); -// 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], -// }); - export const createDataSourceOptions = (dbConfig: DbConfig): DataSourceOptions => { const { enableSslAuth, sslPaths, ...dataSourceOptions } = dbConfig; // eslint-disable-next-line @typescript-eslint/naming-convention @@ -43,7 +30,6 @@ export const createDataSourceOptions = (dbConfig: DbConfig): DataSourceOptions = export const getCachedDataSource = (dbConfig: DbConfig): DataSource => { if (connectionSingleton === undefined) { connectionSingleton = new DataSource(createDataSourceOptions(dbConfig)); - // console.log('DataSource options entities:', connectionSingleton.options.entities); } return connectionSingleton; }; 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/containerConfig.ts b/src/containerConfig.ts index 55c9c3d..1ea0f33 100644 --- a/src/containerConfig.ts +++ b/src/containerConfig.ts @@ -10,7 +10,7 @@ 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 { getConfig } from './common/config'; +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'; @@ -29,7 +29,6 @@ export const registerExternalValues = async (options?: RegisterOptions): Promise 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); @@ -38,9 +37,6 @@ export const registerExternalValues = async (options?: RegisterOptions): Promise { token: SERVICES.CONFIG, provider: { useValue: configInstance } }, { token: SERVICES.LOGGER, provider: { useValue: logger } }, - // METRICS: Avg time, how many operations been on the service, how many products got, how many errors. - // Func against error Functions. - { token: SERVICES.CLEANUP_REGISTRY, provider: { useValue: cleanupRegistry }, @@ -54,13 +50,26 @@ export const registerExternalValues = async (options?: RegisterOptions): Promise }, { token: SERVICES.TRACER, - provider: { useValue: tracer }, - postInjectionHook: (container): void => { - // const + 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: SERVICES.METRICS, provider: { useValue: metricsRegistry } }, - { token: DATA_SOURCE_PROVIDER, provider: { diff --git a/src/index.ts b/src/index.ts index 1a6f0fa..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'; diff --git a/src/products/controllers/products.ts b/src/products/controllers/products.ts index 48e2450..d3f1613 100644 --- a/src/products/controllers/products.ts +++ b/src/products/controllers/products.ts @@ -6,37 +6,23 @@ import type { TypedRequestHandlers } from '@openapi'; import { SERVICES } from '@common/constants'; import { ProductManager } from '../models/products'; import { getProductsQuerySchema } from '../schema/products.schema'; -import { ZodError } from 'zod'; import { createProductSchema, deleteProductSchema, updateProductSchema } from '../schema/products.schema'; import { QueryFailedError } from 'typeorm'; @injectable() export class ProductsController { - private readonly createdProductCounter: Counter; public constructor( @inject(ProductManager) private readonly manager: ProductManager, - @inject(SERVICES.LOGGER) private readonly logger: Logger, - @inject(SERVICES.METRICS) private readonly metricsRegistry: Registry - ) { - const existingMetric = this.metricsRegistry.getSingleMetric('created_resource'); - this.createdProductCounter = - (existingMetric as Counter) ?? - new Counter({ - name: 'created_product', - help: 'number of created products', - 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.manager.getAllProducts(); - if (allProducts.length === 0) { - return res.json({ - message: 'There is no products', - }); + if (!allProducts.length) { + res.status(200).json({ message: 'There is no products.' }); } return res.json(allProducts); } @@ -59,22 +45,13 @@ export class ProductsController { id: createdProduct.id, }); } catch (error) { - if (error instanceof ZodError) { - return res.status(400).json({ - message: 'Validation failed', - errors: error.issues.map((issue) => ({ - field: issue.path.join('.'), - message: issue.message, - })), - }); - } 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', }); @@ -97,17 +74,6 @@ export class ProductsController { return res.status(404).json({ message: error.message, }); - } else if (error instanceof ZodError) { - return res.status(400).json({ - message: 'Validation error', - errors: error.issues.map((issue) => { - const field = issue.path.join('.'); - return { - field: field, - message: issue.message, - }; - }), - }); } else if (error instanceof QueryFailedError) { { if (error.driverError.code == '23514' && error.driverError.constraint == 'check_polygon') { @@ -135,15 +101,6 @@ export class ProductsController { data: deletedProduct, }); } catch (error) { - if (error instanceof ZodError) { - return res.status(400).json({ - message: 'Validation failed', - errors: error.issues.map((issue) => ({ - field: issue.path.join('.'), - message: issue.message, - })), - }); - } next(error); } }; diff --git a/src/products/models/entity.products.ts b/src/products/models/entity.products.ts index 51e036f..8b11c09 100644 --- a/src/products/models/entity.products.ts +++ b/src/products/models/entity.products.ts @@ -1,21 +1,25 @@ 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 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)`) @@ -44,12 +48,14 @@ export class ProductEntity implements ProductModel { @Column({ type: 'enum', + nullable: true, enum: ProductType, }) type!: ProductType; @Column({ type: 'enum', + nullable: true, enum: ConsumptionProtocol, }) consumption_protocol!: ConsumptionProtocol; diff --git a/src/products/models/products.ts b/src/products/models/products.ts index e63123d..a00d529 100644 --- a/src/products/models/products.ts +++ b/src/products/models/products.ts @@ -6,19 +6,31 @@ import { ProductEntity } from './entity.products.js'; import type { GetProductsQuery } from '../schema/products.schema.js'; import { inject, injectable } from 'tsyringe'; import { PRODUCT_REPOSITORY_SYMBOL } from '../tokens.js'; +import type { Tracer } from '@opentelemetry/api'; +import { ProductsModel } from './entity.products.js'; +//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.TRACER) private readonly tracer: Tracer, @inject(PRODUCT_REPOSITORY_SYMBOL) private readonly repository: Repository, @inject(SERVICES.LOGGER) private readonly logger: Logger ) {} diff --git a/src/products/schema/products.schema.ts b/src/products/schema/products.schema.ts index c6fee53..f637bab 100644 --- a/src/products/schema/products.schema.ts +++ b/src/products/schema/products.schema.ts @@ -1,8 +1,6 @@ import { z } from 'zod'; import { ProductType, ConsumptionProtocol } from '../models/entity.products'; -// export const = - export const expectedSchema = { name: 'string', description: 'string', @@ -54,14 +52,17 @@ 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(), From 2e8375c1f14c3d61c72d1c5be9b6c7fb1b8f1696 Mon Sep 17 00:00:00 2001 From: Asif Date: Wed, 6 May 2026 18:17:26 +0300 Subject: [PATCH 10/10] feat: implement all the issues --- launch.json | 15 +++++ openapi3.yaml | 42 ++++++++++++-- src/common/errors.ts | 24 ++++++++ src/containerConfig.ts | 4 +- src/openapi.d.ts | 26 +++++++-- src/products/controllers/products.ts | 11 +--- src/products/models/entity.products.ts | 1 + src/products/models/products.ts | 55 +++++++++---------- src/products/routes/products.ts | 4 -- src/products/tokens.ts | 3 - .../resourceName/resourceName.spec.ts | 9 ++- .../models/resourceNameModel.spec.ts | 6 +- 12 files changed, 138 insertions(+), 62 deletions(-) create mode 100644 launch.json create mode 100644 src/common/errors.ts delete mode 100644 src/products/tokens.ts 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 c020e56..ab09edb 100644 --- a/openapi3.yaml +++ b/openapi3.yaml @@ -6,7 +6,6 @@ info: license: name: MIT url: https://opensource.org/licenses/MIT - paths: /products: get: @@ -33,8 +32,25 @@ paths: type: string - name: bounding_polygon in: query + # style: deepObject + # explode: true schema: - $ref: '#/components/schemas/GeoJsonPolygon' + 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: @@ -60,10 +76,10 @@ paths: in: query schema: type: number - summary: gets the resource + summary: get the products responses: 200: - description: A JSON array of products + description: Success content: application/json: schema: @@ -74,6 +90,8 @@ paths: application/json: schema: $ref: '#/components/schemas/error' + 500: + $ref: '#/components/responses/InternalServerError' post: operationId: createProduct tags: @@ -98,6 +116,9 @@ paths: application/json: schema: $ref: '#/components/schemas/error' + 500: + $ref: '#/components/responses/InternalServerError' + /products/{id}: put: operationId: updateProduct @@ -136,6 +157,9 @@ paths: application/json: schema: $ref: '#/components/schemas/error' + 500: + $ref: '#/components/responses/InternalServerError' + delete: operationId: deleteProduct tags: @@ -167,6 +191,9 @@ paths: application/json: schema: $ref: '#/components/schemas/error' + 500: + $ref: '#/components/responses/InternalServerError' + security: - {} components: @@ -285,3 +312,10 @@ components: properties: data: $ref: '#/components/schemas/Product' + responses: + InternalServerError: + description: Unexpected internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/error' 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/containerConfig.ts b/src/containerConfig.ts index 1ea0f33..f2fd842 100644 --- a/src/containerConfig.ts +++ b/src/containerConfig.ts @@ -15,7 +15,9 @@ import { CleanupRegistry } from '@map-colonies/cleanup-registry'; import { ProductEntity } from './products/models/entity.products'; import { ProductsController } from './products/controllers/products'; import { ProductManager } from './products/models/products'; -import { PRODUCT_CONTROLLER_SYMBOL, PRODUCT_REPOSITORY_SYMBOL, PRODUCT_SERVICE_SYMBOL } from './products/tokens'; +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'; diff --git a/src/openapi.d.ts b/src/openapi.d.ts index 758a770..fcb38a0 100644 --- a/src/openapi.d.ts +++ b/src/openapi.d.ts @@ -11,7 +11,7 @@ export type paths = { path?: never; cookie?: never; }; - /** gets the resource */ + /** get the products */ get: operations['getProducts']; put?: never; /** creates a new record of type product */ @@ -91,7 +91,17 @@ export type components = { data?: components['schemas']['Product']; }; }; - responses: never; + responses: { + /** @description Unexpected internal server error */ + InternalServerError: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['error']; + }; + }; + }; parameters: never; requestBodies: never; headers: never; @@ -105,7 +115,11 @@ export interface operations { name?: string; type?: 'raster' | 'rasterized_vector' | 'tiles3d' | 'QMesh'; description?: string; - bounding_polygon?: components['schemas']['GeoJsonPolygon']; + bounding_polygon?: { + /** @enum {string} */ + type?: 'Polygon'; + coordinates?: number[][][]; + }; consumption_link?: string; resolution_best?: number; consumption_protocol?: 'WMS' | 'WMTS' | 'XYZ' | '3D Tiles'; @@ -118,7 +132,7 @@ export interface operations { }; requestBody?: never; responses: { - /** @description A JSON array of products */ + /** @description Success */ 200: { headers: { [name: string]: unknown; @@ -136,6 +150,7 @@ export interface operations { 'application/json': components['schemas']['error']; }; }; + 500: components['responses']['InternalServerError']; }; }; createProduct: { @@ -169,6 +184,7 @@ export interface operations { 'application/json': components['schemas']['error']; }; }; + 500: components['responses']['InternalServerError']; }; }; updateProduct: { @@ -213,6 +229,7 @@ export interface operations { 'application/json': components['schemas']['error']; }; }; + 500: components['responses']['InternalServerError']; }; }; deleteProduct: { @@ -253,6 +270,7 @@ export interface operations { 'application/json': components['schemas']['error']; }; }; + 500: components['responses']['InternalServerError']; }; }; } diff --git a/src/products/controllers/products.ts b/src/products/controllers/products.ts index d3f1613..8782374 100644 --- a/src/products/controllers/products.ts +++ b/src/products/controllers/products.ts @@ -9,6 +9,8 @@ import { getProductsQuerySchema } 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 { public constructor( @@ -21,9 +23,6 @@ export class ProductsController { const hasFilters = Object.keys(req.query ?? {}).length > 0; if (!hasFilters) { const allProducts = await this.manager.getAllProducts(); - if (!allProducts.length) { - res.status(200).json({ message: 'There is no products.' }); - } return res.json(allProducts); } @@ -70,11 +69,7 @@ export class ProductsController { id: id, }); } catch (error) { - if (error instanceof Error && error.message.includes('not found')) { - return res.status(404).json({ - message: error.message, - }); - } else if (error instanceof QueryFailedError) { + if (error instanceof QueryFailedError) { { if (error.driverError.code == '23514' && error.driverError.constraint == 'check_polygon') { return res.status(400).json({ diff --git a/src/products/models/entity.products.ts b/src/products/models/entity.products.ts index 8b11c09..b62799f 100644 --- a/src/products/models/entity.products.ts +++ b/src/products/models/entity.products.ts @@ -4,6 +4,7 @@ import { Entity, Column, PrimaryGeneratedColumn, Check } from 'typeorm'; 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 const ProductType = { raster: 'raster', diff --git a/src/products/models/products.ts b/src/products/models/products.ts index a00d529..b401d3f 100644 --- a/src/products/models/products.ts +++ b/src/products/models/products.ts @@ -5,9 +5,10 @@ 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 '../tokens.js'; +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'; //buildOperators get array of TypeORM query operators and combines them into single operator, // return undefined if emepty. @@ -36,49 +37,41 @@ export class ProductManager { ) {} public async getAllProducts(): Promise { - return await this.repository.find(); + 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> = {}; - if (filters.name) where.name = ILike(`%${filters.name}%`); + if (filters.name) where.name = `${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); + 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: 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; + 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 async createProduct(data: Partial) { const productToCreate = { - name: data.name, - bounding_polygon: data.bounding_polygon, - type: data.type, - consumption_protocol: data.consumption_protocol, + ...data, consumption_link: data.consumption_link ?? null, description: data.description ?? null, resolution_best: data.resolution_best ?? null, @@ -104,7 +97,7 @@ export class ProductManager { }; const result = await this.repository.update(id, productToUpdate); - if (result.affected === 0) throw new Error(`ID: ${id} not found`); + if (result.affected === 0) throw new ProductNotFound(`Cant update is: ${id}, id was not found`); return result; } @@ -113,9 +106,11 @@ export class ProductManager { id: id, }); if (!productRemove) { - throw Error('Product not found'); + 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 0fb20f5..00f8d84 100644 --- a/src/products/routes/products.ts +++ b/src/products/routes/products.ts @@ -5,13 +5,9 @@ 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.updateProduct); - router.delete('/:id', controller.deleteProduct); return router; 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/tests/integration/resourceName/resourceName.spec.ts b/tests/integration/resourceName/resourceName.spec.ts index 88c5c7f..c630aa4 100644 --- a/tests/integration/resourceName/resourceName.spec.ts +++ b/tests/integration/resourceName/resourceName.spec.ts @@ -35,15 +35,14 @@ describe('resourceName', function () { const resource = response.body as paths['/products']['get']['responses'][200]['content']['application/json']; expect(response).toSatisfyApiSpec(); - expect(resource.id).toBe('19c7d6ba-3979-46b0-81da-394021756dd0'); - 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.createProducts({ + const response = await requestSender.createProduct({ requestBody: { - id: '19c7d6ba-3979-46b0-81da-394021756dd0', name: 'sssss3', description: 'valid description', bounding_polygon: { diff --git a/tests/unit/resourceName/models/resourceNameModel.spec.ts b/tests/unit/resourceName/models/resourceNameModel.spec.ts index 0847d6c..747bfbf 100644 --- a/tests/unit/resourceName/models/resourceNameModel.spec.ts +++ b/tests/unit/resourceName/models/resourceNameModel.spec.ts @@ -4,7 +4,7 @@ import { ProductManager } from '@src/products/models/products'; let resourceNameManager: ProductManager; -describe('ResourceNameManager', () => { +describe('ProductManager', () => { beforeEach(async function () { resourceNameManager = new ProductManager(await jsLogger({ enabled: false })); }); @@ -12,10 +12,10 @@ describe('ResourceNameManager', () => { describe('#getResource', () => { it('should return the resource of id 1', function () { // action - const resource = resourceNameManager.getAllProducts(); + 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?'); });