add swagger + catalog controller

This commit is contained in:
Иван 2025-12-08 21:59:51 +03:00
parent 171d670629
commit d4423d9e33
14 changed files with 367 additions and 39 deletions

66
package-lock.json generated
View File

@ -13,6 +13,8 @@
"@nestjs/core": "^11.0.1", "@nestjs/core": "^11.0.1",
"@nestjs/mapped-types": "^2.1.0", "@nestjs/mapped-types": "^2.1.0",
"@nestjs/platform-express": "^11.0.1", "@nestjs/platform-express": "^11.0.1",
"@nestjs/swagger": "^11.2.3",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.3", "class-validator": "^0.14.3",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1" "rxjs": "^7.8.1"
@ -2054,6 +2056,12 @@
"node": ">=8" "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": { "node_modules/@napi-rs/wasm-runtime": {
"version": "0.2.12", "version": "0.2.12",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz",
@ -2509,6 +2517,39 @@
"tslib": "^2.1.0" "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": { "node_modules/@nestjs/testing": {
"version": "11.1.9", "version": "11.1.9",
"resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-11.1.9.tgz", "resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-11.1.9.tgz",
@ -2638,6 +2679,13 @@
"url": "https://opencollective.com/pkgr" "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": { "node_modules/@sinclair/typebox": {
"version": "0.34.41", "version": "0.34.41",
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.41.tgz", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.41.tgz",
@ -3981,7 +4029,6 @@
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"dev": true,
"license": "Python-2.0" "license": "Python-2.0"
}, },
"node_modules/array-timsort": { "node_modules/array-timsort": {
@ -4471,6 +4518,12 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/class-validator": {
"version": "0.14.3", "version": "0.14.3",
"resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.3.tgz", "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.3.tgz",
@ -7249,7 +7302,6 @@
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"argparse": "^2.0.1" "argparse": "^2.0.1"
@ -7432,7 +7484,6 @@
"version": "4.17.21", "version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/lodash.memoize": { "node_modules/lodash.memoize": {
@ -9192,6 +9243,15 @@
"node": ">=8" "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": { "node_modules/symbol-observable": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-4.0.0.tgz", "resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-4.0.0.tgz",

View File

@ -24,6 +24,8 @@
"@nestjs/core": "^11.0.1", "@nestjs/core": "^11.0.1",
"@nestjs/mapped-types": "^2.1.0", "@nestjs/mapped-types": "^2.1.0",
"@nestjs/platform-express": "^11.0.1", "@nestjs/platform-express": "^11.0.1",
"@nestjs/swagger": "^11.2.3",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.3", "class-validator": "^0.14.3",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1" "rxjs": "^7.8.1"

View File

@ -2,9 +2,10 @@ import { Module } from '@nestjs/common';
import { AppController } from './app.controller'; import { AppController } from './app.controller';
import { AppService } from './app.service'; import { AppService } from './app.service';
import { YachtModule } from './yacht/yacht.module'; import { YachtModule } from './yacht/yacht.module';
import { CatalogModule } from './catalog/catalog.module';
@Module({ @Module({
imports: [YachtModule], imports: [YachtModule, CatalogModule],
controllers: [AppController], controllers: [AppController],
providers: [AppService], providers: [AppService],
}) })

View File

@ -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>(CatalogController);
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
});

View File

@ -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<CatalogResponseDto> {
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<CatalogItemDto[]> {
return this.catalogService.getAllCatalogItems();
}
@Get('filtered')
@ApiOperation({ summary: 'Get catalog with explicit query parameters' })
async getFilteredCatalog(
@Query() params: CatalogParamsDto,
): Promise<CatalogResponseDto> {
return this.catalogService.getCatalog(params);
}
}

View File

@ -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 {}

View File

@ -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>(CatalogService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});

View File

@ -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<CatalogResponseDto> {
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<CatalogItemDto[]> {
return this.catalogItems;
}
}

View File

@ -0,0 +1,9 @@
export class CatalogItemDto {
id?: number;
name: string;
length: number;
speed: number;
minCost: number;
hasQuickRent: boolean;
pictureUrl: string;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -1,8 +1,19 @@
import { NestFactory } from '@nestjs/core'; import { NestFactory } from '@nestjs/core';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import { AppModule } from './app.module'; import { AppModule } from './app.module';
async function bootstrap() { async function bootstrap() {
const app = await NestFactory.create(AppModule); 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(); bootstrap();

View File

@ -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<App>;
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!');
});
});

View File

@ -1,9 +0,0 @@
{
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": ".",
"testEnvironment": "node",
"testRegex": ".e2e-spec.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
}
}