From d4423d9e33b47a7b83ebb8c217ecf82a528a20df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=98=D0=B2=D0=B0=D0=BD?= Date: Mon, 8 Dec 2025 21:59:51 +0300 Subject: [PATCH] add swagger + catalog controller --- package-lock.json | 66 ++++++++++++++++- package.json | 2 + src/app.module.ts | 3 +- src/catalog/catalog.controller.spec.ts | 18 +++++ src/catalog/catalog.controller.ts | 59 +++++++++++++++ src/catalog/catalog.module.ts | 10 +++ src/catalog/catalog.service.spec.ts | 18 +++++ src/catalog/catalog.service.ts | 98 +++++++++++++++++++++++++ src/catalog/dto/catalog-item.dto.ts | 9 +++ src/catalog/dto/catalog-params.dto.ts | 65 ++++++++++++++++ src/catalog/dto/catalog-response.dto.ts | 11 +++ src/main.ts | 13 +++- test/app.e2e-spec.ts | 25 ------- test/jest-e2e.json | 9 --- 14 files changed, 367 insertions(+), 39 deletions(-) create mode 100644 src/catalog/catalog.controller.spec.ts create mode 100644 src/catalog/catalog.controller.ts create mode 100644 src/catalog/catalog.module.ts create mode 100644 src/catalog/catalog.service.spec.ts create mode 100644 src/catalog/catalog.service.ts create mode 100644 src/catalog/dto/catalog-item.dto.ts create mode 100644 src/catalog/dto/catalog-params.dto.ts create mode 100644 src/catalog/dto/catalog-response.dto.ts delete mode 100644 test/app.e2e-spec.ts delete mode 100644 test/jest-e2e.json diff --git a/package-lock.json b/package-lock.json index 7641c10..9899aba 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,8 @@ "@nestjs/core": "^11.0.1", "@nestjs/mapped-types": "^2.1.0", "@nestjs/platform-express": "^11.0.1", + "@nestjs/swagger": "^11.2.3", + "class-transformer": "^0.5.1", "class-validator": "^0.14.3", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1" @@ -2054,6 +2056,12 @@ "node": ">=8" } }, + "node_modules/@microsoft/tsdoc": { + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/@microsoft/tsdoc/-/tsdoc-0.16.0.tgz", + "integrity": "sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==", + "license": "MIT" + }, "node_modules/@napi-rs/wasm-runtime": { "version": "0.2.12", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", @@ -2509,6 +2517,39 @@ "tslib": "^2.1.0" } }, + "node_modules/@nestjs/swagger": { + "version": "11.2.3", + "resolved": "https://registry.npmjs.org/@nestjs/swagger/-/swagger-11.2.3.tgz", + "integrity": "sha512-a0xFfjeqk69uHIUpP8u0ryn4cKuHdra2Ug96L858i0N200Hxho+n3j+TlQXyOF4EstLSGjTfxI1Xb2E1lUxeNg==", + "license": "MIT", + "dependencies": { + "@microsoft/tsdoc": "0.16.0", + "@nestjs/mapped-types": "2.1.0", + "js-yaml": "4.1.1", + "lodash": "4.17.21", + "path-to-regexp": "8.3.0", + "swagger-ui-dist": "5.30.2" + }, + "peerDependencies": { + "@fastify/static": "^8.0.0", + "@nestjs/common": "^11.0.1", + "@nestjs/core": "^11.0.1", + "class-transformer": "*", + "class-validator": "*", + "reflect-metadata": "^0.1.12 || ^0.2.0" + }, + "peerDependenciesMeta": { + "@fastify/static": { + "optional": true + }, + "class-transformer": { + "optional": true + }, + "class-validator": { + "optional": true + } + } + }, "node_modules/@nestjs/testing": { "version": "11.1.9", "resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-11.1.9.tgz", @@ -2638,6 +2679,13 @@ "url": "https://opencollective.com/pkgr" } }, + "node_modules/@scarf/scarf": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz", + "integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==", + "hasInstallScript": true, + "license": "Apache-2.0" + }, "node_modules/@sinclair/typebox": { "version": "0.34.41", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.41.tgz", @@ -3981,7 +4029,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, "license": "Python-2.0" }, "node_modules/array-timsort": { @@ -4471,6 +4518,12 @@ "dev": true, "license": "MIT" }, + "node_modules/class-transformer": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz", + "integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==", + "license": "MIT" + }, "node_modules/class-validator": { "version": "0.14.3", "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.3.tgz", @@ -7249,7 +7302,6 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", - "dev": true, "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -7432,7 +7484,6 @@ "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "dev": true, "license": "MIT" }, "node_modules/lodash.memoize": { @@ -9192,6 +9243,15 @@ "node": ">=8" } }, + "node_modules/swagger-ui-dist": { + "version": "5.30.2", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.30.2.tgz", + "integrity": "sha512-HWCg1DTNE/Nmapt+0m2EPXFwNKNeKK4PwMjkwveN/zn1cV2Kxi9SURd+m0SpdcSgWEK/O64sf8bzXdtUhigtHA==", + "license": "Apache-2.0", + "dependencies": { + "@scarf/scarf": "=1.4.0" + } + }, "node_modules/symbol-observable": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-4.0.0.tgz", diff --git a/package.json b/package.json index 755d5c0..a0a1b52 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,8 @@ "@nestjs/core": "^11.0.1", "@nestjs/mapped-types": "^2.1.0", "@nestjs/platform-express": "^11.0.1", + "@nestjs/swagger": "^11.2.3", + "class-transformer": "^0.5.1", "class-validator": "^0.14.3", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1" diff --git a/src/app.module.ts b/src/app.module.ts index 1134564..1e94450 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -2,9 +2,10 @@ import { Module } from '@nestjs/common'; import { AppController } from './app.controller'; import { AppService } from './app.service'; import { YachtModule } from './yacht/yacht.module'; +import { CatalogModule } from './catalog/catalog.module'; @Module({ - imports: [YachtModule], + imports: [YachtModule, CatalogModule], controllers: [AppController], providers: [AppService], }) diff --git a/src/catalog/catalog.controller.spec.ts b/src/catalog/catalog.controller.spec.ts new file mode 100644 index 0000000..a1c898c --- /dev/null +++ b/src/catalog/catalog.controller.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { CatalogController } from './catalog.controller'; + +describe('CatalogController', () => { + let controller: CatalogController; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [CatalogController], + }).compile(); + + controller = module.get(CatalogController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); +}); diff --git a/src/catalog/catalog.controller.ts b/src/catalog/catalog.controller.ts new file mode 100644 index 0000000..c8ba5a8 --- /dev/null +++ b/src/catalog/catalog.controller.ts @@ -0,0 +1,59 @@ +import { Controller, Get, Query } from '@nestjs/common'; +import { CatalogService } from './catalog.service'; +import { CatalogResponseDto } from './dto/catalog-response.dto'; +import { CatalogItemDto } from './dto/catalog-item.dto'; +import { CatalogParamsDto } from './dto/catalog-params.dto'; +import { ApiQuery, ApiResponse, ApiOperation } from '@nestjs/swagger'; + +@Controller('catalog') +export class CatalogController { + constructor(private readonly catalogService: CatalogService) {} + + @Get() + @ApiOperation({ summary: 'Get catalog with filters, search and sorting' }) + @ApiResponse({ + status: 200, + description: 'Catalog items with filters applied', + type: CatalogResponseDto, + }) + @ApiQuery({ + name: 'params', + required: false, + description: 'JSON string of filter parameters', + type: String, + }) + async getCatalog( + @Query('params') paramsString?: string, + ): Promise { + let params: CatalogParamsDto = {}; + + if (paramsString) { + try { + params = JSON.parse(decodeURIComponent(paramsString)); + } catch (error) { + params = {}; + } + } + + return this.catalogService.getCatalog(params); + } + + @Get('all') + @ApiOperation({ summary: 'Get all catalog items without filters' }) + @ApiResponse({ + status: 200, + description: 'All catalog items', + type: [CatalogItemDto], + }) + async getAllCatalogItems(): Promise { + return this.catalogService.getAllCatalogItems(); + } + + @Get('filtered') + @ApiOperation({ summary: 'Get catalog with explicit query parameters' }) + async getFilteredCatalog( + @Query() params: CatalogParamsDto, + ): Promise { + return this.catalogService.getCatalog(params); + } +} diff --git a/src/catalog/catalog.module.ts b/src/catalog/catalog.module.ts new file mode 100644 index 0000000..161cdd6 --- /dev/null +++ b/src/catalog/catalog.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { CatalogController } from './catalog.controller'; +import { CatalogService } from './catalog.service'; + +@Module({ + controllers: [CatalogController], + providers: [CatalogService], + exports: [CatalogService], +}) +export class CatalogModule {} diff --git a/src/catalog/catalog.service.spec.ts b/src/catalog/catalog.service.spec.ts new file mode 100644 index 0000000..50fa1b3 --- /dev/null +++ b/src/catalog/catalog.service.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { CatalogService } from './catalog.service'; + +describe('CatalogService', () => { + let service: CatalogService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [CatalogService], + }).compile(); + + service = module.get(CatalogService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/src/catalog/catalog.service.ts b/src/catalog/catalog.service.ts new file mode 100644 index 0000000..d180a1b --- /dev/null +++ b/src/catalog/catalog.service.ts @@ -0,0 +1,98 @@ +import { Injectable } from '@nestjs/common'; +import { CatalogItemDto } from './dto/catalog-item.dto'; +import { CatalogParamsDto } from './dto/catalog-params.dto'; +import { CatalogResponseDto } from './dto/catalog-response.dto'; + +@Injectable() +export class CatalogService { + private catalogItems: CatalogItemDto[] = [ + // SAMPLE DATA + { + id: 1, + name: 'Item 1', + length: 10, + speed: 100, + minCost: 50, + hasQuickRent: true, + pictureUrl: 'https://example.com/item1.jpg', + }, + { + id: 2, + name: 'Item 2', + length: 10, + speed: 100, + minCost: 50, + hasQuickRent: false, + pictureUrl: 'https://example.com/item1.jpg', + }, + { + id: 3, + name: 'Item 3', + length: 10, + speed: 100, + minCost: 50, + hasQuickRent: true, + pictureUrl: 'https://example.com/item1.jpg', + }, + { + id: 4, + name: 'Item 4', + length: 10, + speed: 100, + minCost: 50, + hasQuickRent: true, + pictureUrl: 'https://example.com/item1.jpg', + }, + ]; + + async getCatalog(params?: CatalogParamsDto): Promise { + let filteredItems = [...this.catalogItems]; + + if (params?.search) { + const searchTerm = params.search.toLowerCase(); + + filteredItems = filteredItems.filter((item) => + item.name.toLowerCase().includes(searchTerm), + ); + } + + if (params?.filter?.price) { + const { minvalue, maxvalue } = params.filter.price; + + filteredItems = filteredItems.filter((item) => { + if (minvalue !== undefined && item.minCost < minvalue) return false; + if (maxvalue !== undefined && item.minCost > maxvalue) return false; + return true; + }); + } + + if (params?.sort?.field) { + const { field, direction = 'asc' } = params.sort; + + filteredItems.sort((a, b) => { + let valueA = a[field]; + let valueB = b[field]; + + if (typeof valueA === 'string') valueA = valueA.toLowerCase(); + if (typeof valueB === 'string') valueB = valueB.toLowerCase(); + + if (valueA < valueB) return direction === 'asc' ? -1 : 1; + if (valueA > valueB) return direction === 'asc' ? 1 : -1; + + return 0; + }); + } + + return { + items: filteredItems, + total: filteredItems.length, + filters: params?.filter, + sort: params?.sort, + search: params?.search, + }; + } + + async getAllCatalogItems(): Promise { + return this.catalogItems; + } +} diff --git a/src/catalog/dto/catalog-item.dto.ts b/src/catalog/dto/catalog-item.dto.ts new file mode 100644 index 0000000..1dd3dcb --- /dev/null +++ b/src/catalog/dto/catalog-item.dto.ts @@ -0,0 +1,9 @@ +export class CatalogItemDto { + id?: number; + name: string; + length: number; + speed: number; + minCost: number; + hasQuickRent: boolean; + pictureUrl: string; +} diff --git a/src/catalog/dto/catalog-params.dto.ts b/src/catalog/dto/catalog-params.dto.ts new file mode 100644 index 0000000..38118d7 --- /dev/null +++ b/src/catalog/dto/catalog-params.dto.ts @@ -0,0 +1,65 @@ +import { + IsOptional, + IsString, + IsNumber, + ValidateNested, + IsEnum, +} from 'class-validator'; +import { Type } from 'class-transformer'; +import { ApiProperty } from '@nestjs/swagger'; + +export enum SortDirection { + ASC = 'asc', + DESC = 'desc', +} + +export class SortParams { + @ApiProperty({ required: false, enum: SortDirection }) + @IsOptional() + @IsEnum(SortDirection) + direction?: SortDirection; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + field?: string; +} + +export class PriceFilter { + @ApiProperty({ required: false }) + @IsOptional() + @IsNumber() + minvalue?: number; + + @ApiProperty({ required: false }) + @IsOptional() + @IsNumber() + maxvalue?: number; +} + +export class FilterParams { + @ApiProperty({ required: false, type: PriceFilter }) + @IsOptional() + @ValidateNested() + @Type(() => PriceFilter) + price?: PriceFilter; +} + +export class CatalogParamsDto { + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + search?: string; + + @ApiProperty({ required: false, type: SortParams }) + @IsOptional() + @ValidateNested() + @Type(() => SortParams) + sort?: SortParams; + + @ApiProperty({ required: false, type: FilterParams }) + @IsOptional() + @ValidateNested() + @Type(() => FilterParams) + filter?: FilterParams; +} diff --git a/src/catalog/dto/catalog-response.dto.ts b/src/catalog/dto/catalog-response.dto.ts new file mode 100644 index 0000000..ae08d65 --- /dev/null +++ b/src/catalog/dto/catalog-response.dto.ts @@ -0,0 +1,11 @@ +import { CatalogItemDto } from './catalog-item.dto'; + +export class CatalogResponseDto { + items: CatalogItemDto[]; + total: number; + page?: number; + limit?: number; + filters?: any; + sort?: any; + search?: string; +} diff --git a/src/main.ts b/src/main.ts index f76bc8d..82e40a8 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,8 +1,19 @@ import { NestFactory } from '@nestjs/core'; +import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; import { AppModule } from './app.module'; async function bootstrap() { const app = await NestFactory.create(AppModule); - await app.listen(process.env.PORT ?? 3000); + + const config = new DocumentBuilder() + .setTitle('Travelmarine backend') + .setDescription('Backend API for Travelmarine service') + .setVersion('0.1') + .build(); + + const documentFactory = () => SwaggerModule.createDocument(app, config); + SwaggerModule.setup('api', app, documentFactory); + + await app.listen(process.env.PORT ?? 8001); } bootstrap(); diff --git a/test/app.e2e-spec.ts b/test/app.e2e-spec.ts deleted file mode 100644 index 36852c5..0000000 --- a/test/app.e2e-spec.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { INestApplication } from '@nestjs/common'; -import request from 'supertest'; -import { App } from 'supertest/types'; -import { AppModule } from './../src/app.module'; - -describe('AppController (e2e)', () => { - let app: INestApplication; - - beforeEach(async () => { - const moduleFixture: TestingModule = await Test.createTestingModule({ - imports: [AppModule], - }).compile(); - - app = moduleFixture.createNestApplication(); - await app.init(); - }); - - it('/ (GET)', () => { - return request(app.getHttpServer()) - .get('/') - .expect(200) - .expect('Hello World!'); - }); -}); diff --git a/test/jest-e2e.json b/test/jest-e2e.json deleted file mode 100644 index e9d912f..0000000 --- a/test/jest-e2e.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "moduleFileExtensions": ["js", "json", "ts"], - "rootDir": ".", - "testEnvironment": "node", - "testRegex": ".e2e-spec.ts$", - "transform": { - "^.+\\.(t|j)s$": "ts-jest" - } -}