From 5f0e3180d87793ad0785028ac5a77fd2515da19b Mon Sep 17 00:00:00 2001 From: avaitonis Date: Fri, 10 Mar 2023 15:01:52 +0000 Subject: [PATCH] feat(APIM-96): added GET `/yield-rates` endpoint (#50) ### Introduction - New endpoint GET /yield-rates. It calculates UKEF exposure in months for EW and BS Facilities Query parameters: searchDate (optional) ### Resolution - GET /exposure-period uses TypeORM to get data from table DWR_YIELD_RATE - Old endpoint had query parameter searchDatetime, I changed it to searchDate - Old endpoint had field short_name, I renamed to shortName ### Miscellaneous - Removed @ApiParam from all modules because Swagger module can generate URL and Query parameter documentation from DTO class. We need URL and Query parameters in DTO for validation, so we also keep it there for documentation. - Change MAXIMUM_TIMEZONE_LIMIT format to better match what DB response looks, and this allows to use it for testing. - Changed GET /exposure-period Query parameter type from date to string date, it allows easier way to stop time input. --- .cspell.json | 5 +- src/constants/date.constant.ts | 2 +- src/modules/constants/constants.controller.ts | 16 +-- .../dto/get-constants-spi-query.dto.ts | 12 +- .../currencies/currencies.controller.ts | 30 +---- .../currencies/dto/currency-exchange.dto.ts | 15 ++- src/modules/currencies/dto/currency.dto.ts | 2 +- .../dto/get-exposure-period-query.dto.ts | 24 +++- .../exposure-period.controller.ts | 23 +--- .../exposure-period.service.ts | 2 +- src/modules/markets/dto/markets-query.dto.ts | 7 + src/modules/markets/markets.controller.ts | 10 +- src/modules/mdm.module.ts | 7 +- .../numbers/dto/get-numbers-query.dto.ts | 7 +- src/modules/numbers/numbers.controller.ts | 20 +-- .../premium-schedules.controller.ts | 10 +- .../premium-schedules.service.ts | 6 +- .../dto/get-sector-industries-query.dto.ts | 10 +- .../sector-industries.controller.ts | 16 +-- .../dto/get-yield-rates-query.dto.ts | 14 ++ .../yield-rates/entities/yield-rate.entity.ts | 66 ++++++++++ .../yield-rates.controller.test.ts | 66 ++++++++++ .../yield-rates/yield-rates.controller.ts | 22 ++++ src/modules/yield-rates/yield-rates.module.ts | 14 ++ .../yield-rates/yield-rates.service.ts | 40 ++++++ .../exposure-period.api-test.ts | 24 ++-- test/yield-rates/yield-rates.api-test.ts | 124 ++++++++++++++++++ 27 files changed, 440 insertions(+), 154 deletions(-) create mode 100644 src/modules/yield-rates/dto/get-yield-rates-query.dto.ts create mode 100644 src/modules/yield-rates/entities/yield-rate.entity.ts create mode 100644 src/modules/yield-rates/yield-rates.controller.test.ts create mode 100644 src/modules/yield-rates/yield-rates.controller.ts create mode 100644 src/modules/yield-rates/yield-rates.module.ts create mode 100644 src/modules/yield-rates/yield-rates.service.ts create mode 100644 test/yield-rates/yield-rates.api-test.ts diff --git a/.cspell.json b/.cspell.json index 3685491f..699ae561 100644 --- a/.cspell.json +++ b/.cspell.json @@ -35,7 +35,10 @@ "BBALIBOR", "szenius", "EWCS", - "NVARCHAR" + "NVARCHAR", + "ESTR", + "EESWE3", + "Curncy" ], "dictionaries": [ "en-gb", diff --git a/src/constants/date.constant.ts b/src/constants/date.constant.ts index 7efe4f57..bcf67745 100644 --- a/src/constants/date.constant.ts +++ b/src/constants/date.constant.ts @@ -1,4 +1,4 @@ export const DATE = { MAXIMUM_LIMIT: '9999-12-31 00:00:00.000', - MAXIMUM_TIMEZONE_LIMIT: '9999-12-31 00:00:00.000Z', + MAXIMUM_TIMEZONE_LIMIT: '9999-12-31T00:00:00.000Z', }; diff --git a/src/modules/constants/constants.controller.ts b/src/modules/constants/constants.controller.ts index 0cd87bdf..c29b8030 100644 --- a/src/modules/constants/constants.controller.ts +++ b/src/modules/constants/constants.controller.ts @@ -1,5 +1,5 @@ import { Controller, Get, Query } from '@nestjs/common'; -import { ApiBearerAuth, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; import { ConstantsService } from './constants.service'; import { GetConstantsSpiQueryDto } from './dto/get-constants-spi-query.dto'; @@ -18,20 +18,6 @@ export class ConstantsController { description: 'The found record', type: [ConstantSpiEntity], }) - @ApiParam({ - name: 'oecdRiskCategory', - required: false, - type: 'int', - description: 'Country risk category. Values from 0 to 7', - example: 1, - }) - @ApiParam({ - name: 'category', - type: 'string', - required: false, - description: 'Constant category/type/group. Values: A, B, C, Quality of Product, Percentage of Cover', - example: 'C', - }) find(@Query() query: GetConstantsSpiQueryDto): Promise { return this.constantsService.find(query.oecdRiskCategory, query.category); } diff --git a/src/modules/constants/dto/get-constants-spi-query.dto.ts b/src/modules/constants/dto/get-constants-spi-query.dto.ts index 48144067..c7c97d80 100644 --- a/src/modules/constants/dto/get-constants-spi-query.dto.ts +++ b/src/modules/constants/dto/get-constants-spi-query.dto.ts @@ -6,12 +6,20 @@ export class GetConstantsSpiQueryDto { @IsOptional() @Min(0) @Max(7) - @ApiProperty({ example: 1 }) + @ApiProperty({ + required: false, + example: 1, + description: 'Country risk category. Values from 0 to 7', + }) public oecdRiskCategory: number = null; @IsString() @IsOptional() - @ApiProperty({ example: 'C' }) + @ApiProperty({ + required: false, + example: 'C', + description: 'Constant category/type/group. Values: A, B, C, Quality of Product, Percentage of Cover', + }) @Matches(/^[a-zA-Z ]{1,20}$/) public category: string = null; } diff --git a/src/modules/currencies/currencies.controller.ts b/src/modules/currencies/currencies.controller.ts index ba2aa87d..2a1fc831 100644 --- a/src/modules/currencies/currencies.controller.ts +++ b/src/modules/currencies/currencies.controller.ts @@ -1,5 +1,5 @@ import { Controller, Get, Param, Query } from '@nestjs/common'; -import { ApiBearerAuth, ApiOperation, ApiParam, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; import { CurrenciesService } from './currencies.service'; import { CurrencyDto, GetCurrencyExchangeDto } from './dto'; @@ -30,27 +30,6 @@ export class CurrenciesController { description: 'Get the Active exchange rate', type: GetCurrencyExchangeDto, }) - @ApiQuery({ - name: 'source', - required: true, - type: 'string', - description: 'Source currency for exchange rate - Use ISO 3 alpha currency code standard. Only GBP and USD currencies are supported', - example: 'GBP', - }) - @ApiQuery({ - name: 'target', - required: true, - type: 'string', - description: 'Target currency for exchange rate - Use ISO 3 alpha currency code standard', - example: 'AED', - }) - @ApiQuery({ - name: 'exchangeRateDate', - required: false, - type: 'string', - description: 'Retrieve the exchange rate for a specific date', - example: '2021-01-26', - }) findExchangeRate(@Query() query: GetCurrencyExchangeDto): Promise { return this.currenciesService.findExchangeRate(query.source, query.target, query.exchangeRateDate); } @@ -64,13 +43,6 @@ export class CurrenciesController { description: 'Currency details based on ISO Code', type: [CurrencyEntity], }) - @ApiParam({ - name: 'isoCode', - required: true, - type: 'string', - description: 'ISO Code', - example: 'GBP', - }) findOne(@Param() param: CurrencyDto): Promise { return this.currenciesService.findOne(param.isoCode); } diff --git a/src/modules/currencies/dto/currency-exchange.dto.ts b/src/modules/currencies/dto/currency-exchange.dto.ts index caed96dd..0e8599dc 100644 --- a/src/modules/currencies/dto/currency-exchange.dto.ts +++ b/src/modules/currencies/dto/currency-exchange.dto.ts @@ -3,15 +3,24 @@ import { IsDateString, IsISO4217CurrencyCode, IsOptional } from 'class-validator export class GetCurrencyExchangeDto { @IsISO4217CurrencyCode() - @ApiProperty({ example: 'GBP' }) + @ApiProperty({ + example: 'GBP', + description: 'Source currency for exchange rate - Use ISO 3 alpha currency code standard. Only GBP and USD currencies are supported', + }) readonly source: string; @IsISO4217CurrencyCode() - @ApiProperty({ example: 'AED' }) + @ApiProperty({ + example: 'AED', + description: 'Target currency for exchange rate - Use ISO 3 alpha currency code standard', + }) readonly target: string; @IsDateString() @IsOptional() - @ApiProperty({ example: '2021-01-26' }) + @ApiProperty({ + example: '2021-01-26', + description: 'Retrieve the exchange rate for a specific date', + }) readonly exchangeRateDate: string; } diff --git a/src/modules/currencies/dto/currency.dto.ts b/src/modules/currencies/dto/currency.dto.ts index 8eb49b3f..e5c6be61 100644 --- a/src/modules/currencies/dto/currency.dto.ts +++ b/src/modules/currencies/dto/currency.dto.ts @@ -3,6 +3,6 @@ import { IsISO4217CurrencyCode } from 'class-validator'; export class CurrencyDto { @IsISO4217CurrencyCode() - @ApiProperty({ example: 'GBP' }) + @ApiProperty({ example: 'GBP', description: 'ISO Code' }) public isoCode: string; } diff --git a/src/modules/exposure-period/dto/get-exposure-period-query.dto.ts b/src/modules/exposure-period/dto/get-exposure-period-query.dto.ts index c8de5294..9a43f7e8 100644 --- a/src/modules/exposure-period/dto/get-exposure-period-query.dto.ts +++ b/src/modules/exposure-period/dto/get-exposure-period-query.dto.ts @@ -1,15 +1,25 @@ import { ApiProperty } from '@nestjs/swagger'; import { ENUMS } from '@ukef/constants'; -import { IsDate, IsEnum, IsString } from 'class-validator'; +import { IsDateString, IsEnum, IsString, MaxLength } from 'class-validator'; export class GetExposurePeriodQueryDto { - @IsDate() - @ApiProperty({ example: '2017-07-04' }) - public startdate: Date; + @IsDateString({ strict: true }) + // Max length validation blocks dates with time. + @MaxLength(10, { message: '$property should use format YYYY-MM-DD' }) + @ApiProperty({ + example: '2017-07-04', + description: 'Guarantee commencement date for a facility', + }) + public startdate: string; - @IsDate() - @ApiProperty({ example: '2018-07-04' }) - public enddate: Date; + @IsDateString({ strict: true }) + // Max length validation blocks dates with time. + @MaxLength(10, { message: '$property should use format YYYY-MM-DD' }) + @ApiProperty({ + example: '2018-07-04', + description: 'Guarantee expiry date for a facility', + }) + public enddate: string; @IsString() @IsEnum(ENUMS.PRODUCTS) diff --git a/src/modules/exposure-period/exposure-period.controller.ts b/src/modules/exposure-period/exposure-period.controller.ts index 2b180c85..f8055211 100644 --- a/src/modules/exposure-period/exposure-period.controller.ts +++ b/src/modules/exposure-period/exposure-period.controller.ts @@ -1,5 +1,5 @@ import { Controller, Get, Query } from '@nestjs/common'; -import { ApiBearerAuth, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; import { ExposurePeriodDto } from './dto/exposure-period.dto'; import { GetExposurePeriodQueryDto } from './dto/get-exposure-period-query.dto'; @@ -18,27 +18,6 @@ export class ExposurePeriodController { description: 'Calculated exposure period', type: ExposurePeriodDto, }) - @ApiParam({ - name: 'startdate', - required: false, - type: 'date', - description: 'Guarantee commencement date for a facility', - example: '2017-07-04', - }) - @ApiParam({ - name: 'enddate', - type: 'date', - required: false, - description: 'Guarantee expiry date for a facility', - example: '2018-07-04', - }) - @ApiParam({ - name: 'productgroup', - type: 'string', - required: false, - description: 'Facility type. It can be EW or BS', - example: 'EW', - }) find(@Query() query: GetExposurePeriodQueryDto): Promise { return this.exposurePeriodService.calculate(query.startdate, query.enddate, query.productgroup); } diff --git a/src/modules/exposure-period/exposure-period.service.ts b/src/modules/exposure-period/exposure-period.service.ts index c8bb22e9..168db5ef 100644 --- a/src/modules/exposure-period/exposure-period.service.ts +++ b/src/modules/exposure-period/exposure-period.service.ts @@ -14,7 +14,7 @@ export class ExposurePeriodService { private readonly mdmDataSource: DataSource, ) {} - async calculate(startDate: Date, endDate: Date, productGroup: string): Promise { + async calculate(startDate: string, endDate: string, productGroup: string): Promise { try { // TODO: SP USP_MDM_READ_EXPOSURE_PERIOD is not using data/tables from DB. Calculation could be moved to Javascript. const spResults = await this.mdmDataSource.query('USP_MDM_READ_EXPOSURE_PERIOD @0, @1, @2', [startDate, endDate, productGroup]); diff --git a/src/modules/markets/dto/markets-query.dto.ts b/src/modules/markets/dto/markets-query.dto.ts index b2d6aac3..aabccffc 100644 --- a/src/modules/markets/dto/markets-query.dto.ts +++ b/src/modules/markets/dto/markets-query.dto.ts @@ -1,3 +1,4 @@ +import { ApiProperty } from '@nestjs/swagger'; import { IsEnum, IsOptional } from 'class-validator'; import { QueryParamActiveEnum } from './query-param-active-enum'; @@ -5,5 +6,11 @@ import { QueryParamActiveEnum } from './query-param-active-enum'; export class MarketsQueryDto { @IsEnum(QueryParamActiveEnum) @IsOptional() + @ApiProperty({ + required: false, + example: 'Y', + description: 'Optional filtering by field "active". If parameter is not provided result will include active and not active markets', + enum: QueryParamActiveEnum, + }) public active: string; } diff --git a/src/modules/markets/markets.controller.ts b/src/modules/markets/markets.controller.ts index 59122d7b..b840ae27 100644 --- a/src/modules/markets/markets.controller.ts +++ b/src/modules/markets/markets.controller.ts @@ -1,8 +1,7 @@ import { Controller, Get, Query } from '@nestjs/common'; -import { ApiBearerAuth, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { ApiBearerAuth, ApiResponse, ApiTags } from '@nestjs/swagger'; import { MarketsQueryDto } from './dto/markets-query.dto'; -import { QueryParamActiveEnum } from './dto/query-param-active-enum'; import { MarketEntity } from './entities/market.entity'; import { MarketsService } from './markets.service'; @@ -13,13 +12,6 @@ export class MarketsController { constructor(private readonly marketService: MarketsService) {} @Get() - @ApiParam({ - name: 'active', - type: 'string', - required: false, - description: 'Optional filtering by field "active". If parameter is not provided result will include active and not active markets', - enum: QueryParamActiveEnum, - }) @ApiResponse({ status: 200, description: 'Get all markets (aka countries)', diff --git a/src/modules/mdm.module.ts b/src/modules/mdm.module.ts index ef30074a..629df0ad 100644 --- a/src/modules/mdm.module.ts +++ b/src/modules/mdm.module.ts @@ -10,12 +10,14 @@ import { MarketsModule } from '@ukef/module/markets/markets.module'; import { NumbersModule } from '@ukef/module/numbers/numbers.module'; import { PremiumSchedulesModule } from '@ukef/module/premium-schedules/premium-schedules.module'; import { SectorIndustriesModule } from '@ukef/module/sector-industries/sector-industries.module'; +import { YieldRatesModule } from '@ukef/module/yield-rates/yield-rates.module'; @Module({ imports: [ AuthModule, DatabaseModule, ConstantsModule, + CurrenciesModule, ExposurePeriodModule, HealthcheckModule, InterestRatesModule, @@ -23,12 +25,13 @@ import { SectorIndustriesModule } from '@ukef/module/sector-industries/sector-in NumbersModule, PremiumSchedulesModule, SectorIndustriesModule, - CurrenciesModule, + YieldRatesModule, ], exports: [ AuthModule, DatabaseModule, ConstantsModule, + CurrenciesModule, ExposurePeriodModule, HealthcheckModule, InterestRatesModule, @@ -36,7 +39,7 @@ import { SectorIndustriesModule } from '@ukef/module/sector-industries/sector-in NumbersModule, PremiumSchedulesModule, SectorIndustriesModule, - CurrenciesModule, + YieldRatesModule, ], }) export class MdmModule {} diff --git a/src/modules/numbers/dto/get-numbers-query.dto.ts b/src/modules/numbers/dto/get-numbers-query.dto.ts index 092db2f3..6632a9d9 100644 --- a/src/modules/numbers/dto/get-numbers-query.dto.ts +++ b/src/modules/numbers/dto/get-numbers-query.dto.ts @@ -1,16 +1,17 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsInt, IsNotEmpty, IsString, Matches, Max } from 'class-validator'; +import { IsInt, IsNotEmpty, IsString, Matches, Max, Min } from 'class-validator'; export class GetNumbersQueryDto { @IsInt() @IsNotEmpty() + @Min(1) @Max(9) - @ApiProperty({ example: 1 }) + @ApiProperty({ example: 1, description: 'Id of UKEF ID type. Common types are: 1 for Deal/Facility, 2 for Party, 8 for Covenant' }) public type: number; @IsString() @IsNotEmpty() - @ApiProperty({ example: '0030052431' }) + @ApiProperty({ example: '0030052431', description: 'UKEF ID to check' }) @Matches(/^\d{10}$/) public ukefId: string; } diff --git a/src/modules/numbers/numbers.controller.ts b/src/modules/numbers/numbers.controller.ts index 1c33dd5d..51d54587 100644 --- a/src/modules/numbers/numbers.controller.ts +++ b/src/modules/numbers/numbers.controller.ts @@ -1,5 +1,5 @@ -import { BadRequestException, Body, Controller, Get, ParseArrayPipe, Post, Query, UsePipes, ValidationPipe } from '@nestjs/common'; -import { ApiBearerAuth, ApiBody, ApiOperation, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { BadRequestException, Body, Controller, Get, ParseArrayPipe, Post, Query } from '@nestjs/common'; +import { ApiBearerAuth, ApiBody, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; import { CreateUkefIdDto } from './dto/create-ukef-id.dto'; import { GetNumbersQueryDto } from './dto/get-numbers-query.dto'; @@ -15,8 +15,6 @@ export class NumbersController { @Post() @ApiOperation({ summary: 'Create Number' }) @ApiBody({ type: [CreateUkefIdDto] }) - @UsePipes(ValidationPipe) - @ApiResponse({ status: 201, description: 'Created.' }) create(@Body(new ParseArrayPipe({ items: CreateUkefIdDto, optional: false })) createUkefIdDtos: CreateUkefIdDto[]): Promise { if (!createUkefIdDtos.length) { throw new BadRequestException('Request payload is empty'); @@ -31,20 +29,6 @@ export class NumbersController { description: 'The found record', type: [UkefId], }) - @ApiQuery({ - name: 'type', - required: true, - type: 'int', - description: 'Id of number type. Common types are: 1 for Deal/Facility, 2 for Party, 8 for Covenant', - example: 1, - }) - @ApiQuery({ - name: 'ukefID', - type: 'string', - required: true, - description: 'UKEF ID to check', - example: '0030052431', - }) findOne(@Query() query: GetNumbersQueryDto): Promise { return this.numberService.findOne(query.type, query.ukefId); } diff --git a/src/modules/premium-schedules/premium-schedules.controller.ts b/src/modules/premium-schedules/premium-schedules.controller.ts index 88199e49..25cf02e9 100644 --- a/src/modules/premium-schedules/premium-schedules.controller.ts +++ b/src/modules/premium-schedules/premium-schedules.controller.ts @@ -1,5 +1,5 @@ import { BadRequestException, Body, Controller, Get, Param, ParseArrayPipe, Post, Res } from '@nestjs/common'; -import { ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { ApiBody, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; import { Response } from 'express'; import { CreatePremiumScheduleDto } from './dto/create-premium-schedule.dto'; @@ -15,7 +15,6 @@ export class PremiumSchedulesController { @Post('premium/schedule') @ApiOperation({ summary: 'Create Premium Schedule sequence (aka Income exposure)' }) @ApiBody({ type: [CreatePremiumScheduleDto] }) - @ApiResponse({ status: 201, description: 'Created.' }) create( @Res({ passthrough: true }) res: Response, @Body(new ParseArrayPipe({ items: CreatePremiumScheduleDto, optional: false })) createPremiumSchedule: CreatePremiumScheduleDto[], @@ -33,13 +32,6 @@ export class PremiumSchedulesController { status: 200, type: [PremiumScheduleEntity], }) - @ApiParam({ - name: 'facilityId', - required: true, - type: 'string', - description: 'UKEF facility id', - example: '10588388', - }) find(@Param() param: GetPremiumScheduleParamDto): Promise { return this.premiumSchedulesService.find(param.facilityId); } diff --git a/src/modules/premium-schedules/premium-schedules.service.ts b/src/modules/premium-schedules/premium-schedules.service.ts index 8b361d67..681e7a61 100644 --- a/src/modules/premium-schedules/premium-schedules.service.ts +++ b/src/modules/premium-schedules/premium-schedules.service.ts @@ -18,14 +18,14 @@ export class PremiumSchedulesService { async find(facilityId: string): Promise { try { - const spResults = await this.premiumSchedulesRepository.find({ + const results = await this.premiumSchedulesRepository.find({ where: { facilityURN: Equal(facilityId), isActive: Equal('Y') }, order: { period: 'ASC' }, }); - if (spResults && !spResults[0]) { + if (!results.length) { throw new NotFoundException('Premium Schedules are not found'); } - return spResults; + return results; } catch (err) { if (err instanceof NotFoundException) { this.logger.warn(err); diff --git a/src/modules/sector-industries/dto/get-sector-industries-query.dto.ts b/src/modules/sector-industries/dto/get-sector-industries-query.dto.ts index 0c1f8610..96d0a932 100644 --- a/src/modules/sector-industries/dto/get-sector-industries-query.dto.ts +++ b/src/modules/sector-industries/dto/get-sector-industries-query.dto.ts @@ -1,18 +1,18 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsOptional, IsString, Matches, MaxLength } from 'class-validator'; +import { IsOptional, IsString, Length, Matches } from 'class-validator'; export class GetSectorIndustriesQueryDto { @IsOptional() @IsString() - @MaxLength(4) + @Length(4) @Matches(/^\d{4}$/) - @ApiProperty({ example: '1010' }) + @ApiProperty({ required: false, example: '1010', description: 'Search by UKEF Sector id, returns multiple Industries' }) public ukefSectorId: string; @IsOptional() @IsString() - @MaxLength(5) + @Length(5) @Matches(/^\d{5}$/) - @ApiProperty({ example: '02400' }) + @ApiProperty({ required: false, example: '02400', description: 'Search by UKEF Industry id, most likely returns 1 result' }) public ukefIndustryId: string; } diff --git a/src/modules/sector-industries/sector-industries.controller.ts b/src/modules/sector-industries/sector-industries.controller.ts index 8b9ce16c..a8008296 100644 --- a/src/modules/sector-industries/sector-industries.controller.ts +++ b/src/modules/sector-industries/sector-industries.controller.ts @@ -1,5 +1,5 @@ import { Controller, Get, Query } from '@nestjs/common'; -import { ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; import { GetSectorIndustriesQueryDto } from './dto/get-sector-industries-query.dto'; import { SectorIndustryEntity } from './entities/sector-industry.entity'; @@ -12,20 +12,6 @@ export class SectorIndustriesController { @Get() @ApiOperation({ summary: 'Get UKEF Sectors/Industries Mapping To ACBS Sectors/Industries' }) - @ApiParam({ - name: 'ukefSectorId', - required: false, - type: 'string', - description: 'Search by UKEF Sector id, returns multiple Industries', - example: '1001', - }) - @ApiParam({ - name: 'ukefIndustryId', - type: 'string', - required: false, - description: 'Search by UKEF Industry id, most likely returns 1 result', - example: '35220', - }) @ApiResponse({ status: 200, type: [SectorIndustryEntity], diff --git a/src/modules/yield-rates/dto/get-yield-rates-query.dto.ts b/src/modules/yield-rates/dto/get-yield-rates-query.dto.ts new file mode 100644 index 00000000..5bb80286 --- /dev/null +++ b/src/modules/yield-rates/dto/get-yield-rates-query.dto.ts @@ -0,0 +1,14 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsDateString, IsOptional, MaxLength } from 'class-validator'; + +export class GetYieldRatesQueryDto { + @IsDateString({ strict: true }) + // Max length validation blocks dates with time. + @MaxLength(10, { message: '$property should use format YYYY-MM-DD' }) + @IsOptional() + @ApiProperty({ + example: '2023-03-01', + description: 'Filter yield rates for specific date. Can go back to 2010-03-15', + }) + public searchDate: string; +} diff --git a/src/modules/yield-rates/entities/yield-rate.entity.ts b/src/modules/yield-rates/entities/yield-rate.entity.ts new file mode 100644 index 00000000..35b445a0 --- /dev/null +++ b/src/modules/yield-rates/entities/yield-rate.entity.ts @@ -0,0 +1,66 @@ +import { ClassSerializerInterceptor, UseInterceptors } from '@nestjs/common'; +import { ApiProperty } from '@nestjs/swagger'; +import { DATE } from '@ukef/constants'; +import { Column, Entity, PrimaryColumn } from 'typeorm'; + +@Entity({ + name: 'DWR_YIELD_RATE', + schema: 'dbo', +}) +@UseInterceptors(ClassSerializerInterceptor) +export class YieldRateEntity { + @PrimaryColumn({ name: 'YIELD_ID' }) + id: number; + + @Column({ name: 'YIELD_RATE_SHORT_NAME' }) + @ApiProperty({ example: 'EUR SWAP (ESTR) 3Y' }) + shortName: string; + + @Column({ name: 'PX_BID_PRICE', type: 'decimal' }) + @ApiProperty({ example: 3.4594 }) + pxBidPrice: number; + + @Column({ name: 'PX_ASK_PRICE', type: 'decimal' }) + @ApiProperty({ example: 3.4706 }) + pxAskPrice: number; + + @Column({ name: 'PX_LAST_PRICE', type: 'decimal' }) + @ApiProperty({ example: 3.465 }) + pxLastPrice: number; + + @Column({ name: 'PX_MID_PRICE', type: 'decimal' }) + @ApiProperty({ example: 3.465 }) + pxMidPrice: number; + + @Column({ name: 'FUTURE_MONTH_YEAR' }) + @ApiProperty({ example: ' ' }) + futureMonthYear: string; + + @Column({ name: 'SOURCE_ERROR_CODE' }) + @ApiProperty({ example: 0 }) + sourceErrorCode: number; + + @Column({ name: 'SOURCE_UPDATE_TIMESTAMP', type: 'timestamp' }) + @ApiProperty({ example: '16:01:08' }) + sourceUpdateTimestamp: string; + + @Column({ name: 'YIELD_INDEX' }) + @ApiProperty({ example: 'EESWE3 Curncy' }) + yieldIndex: string; + + @Column({ name: 'DATE_CREATED_DATETIME', type: 'timestamp' }) + @ApiProperty({ example: '2023-02-27T16:29:12.93.027Z' }) + created: Date; + + @Column({ name: 'DATE_LAST_UPDATED_DATETIME', type: 'timestamp' }) + @ApiProperty({ example: '2023-02-27T16:29:12.93.027Z' }) + updated: Date; + + @Column({ name: 'EFFECTIVE_TO_DATETIME', type: 'timestamp' }) + @ApiProperty({ example: DATE.MAXIMUM_TIMEZONE_LIMIT }) + effectiveTo: string; + + @Column({ name: 'EFFECTIVE_FROM_DATETIME', type: 'timestamp' }) + @ApiProperty({ example: '2023-02-27T00:00:00.000Z' }) + effectiveFrom: Date; +} diff --git a/src/modules/yield-rates/yield-rates.controller.test.ts b/src/modules/yield-rates/yield-rates.controller.test.ts new file mode 100644 index 00000000..00dc4452 --- /dev/null +++ b/src/modules/yield-rates/yield-rates.controller.test.ts @@ -0,0 +1,66 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import Chance from 'chance'; +import { Response } from 'express'; + +import { YieldRatesController } from './yield-rates.controller'; +import { YieldRatesService } from './yield-rates.service'; + +const chance = new Chance(); + +// Minimal mock of express Response. "as any" is required to get around Typescript type check. +// TODO: this can be rewritten to use mock library. +const mockResponseObject = { + set: jest.fn().mockReturnValue({}), +} as any as Response; + +describe('YieldRatesController', () => { + let yieldRatesController: YieldRatesController; + let yieldRatesService: YieldRatesService; + + beforeEach(async () => { + const app: TestingModule = await Test.createTestingModule({ + controllers: [YieldRatesController], + providers: [ + YieldRatesService, + { + provide: YieldRatesService, + useValue: { + find: jest.fn().mockResolvedValue([ + { + id: chance.natural(), + facilityURN: chance.natural({ min: 10000000, max: 99999999 }).toString(), + calculationDate: chance.word(), + income: chance.natural(), + incomePerDay: chance.word(), + exposure: chance.currency().code, + period: chance.natural(), + daysInPeriod: chance.word(), + effectiveFrom: chance.date({ string: true }), + effectiveTo: chance.date({ string: true }), + created: chance.date({ string: true }), + updated: chance.date({ string: true }), + isActive: 'Y', + }, + ]), + create: jest.fn().mockResolvedValue({}), + }, + }, + ], + }).compile(); + + yieldRatesController = app.get(YieldRatesController); + yieldRatesService = app.get(YieldRatesService); + }); + + it('should be defined', () => { + expect(yieldRatesController).toBeDefined(); + }); + + describe('find()', () => { + it('should call service find function', () => { + yieldRatesController.find({ searchDate: '2023-03-02' }); + + expect(yieldRatesService.find).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/modules/yield-rates/yield-rates.controller.ts b/src/modules/yield-rates/yield-rates.controller.ts new file mode 100644 index 00000000..67087dae --- /dev/null +++ b/src/modules/yield-rates/yield-rates.controller.ts @@ -0,0 +1,22 @@ +import { Controller, Get, Query } from '@nestjs/common'; +import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; + +import { GetYieldRatesQueryDto } from './dto/get-yield-rates-query.dto'; +import { YieldRateEntity } from './entities/yield-rate.entity'; +import { YieldRatesService } from './yield-rates.service'; + +@ApiTags('yield-rates') +@Controller('yield-rates') +export class YieldRatesController { + constructor(private readonly yieldRatesService: YieldRatesService) {} + + @Get() + @ApiOperation({ summary: 'Yield rates are updated daily from bloomberg.' }) + @ApiResponse({ + status: 200, + type: [YieldRateEntity], + }) + find(@Query() query: GetYieldRatesQueryDto): Promise { + return this.yieldRatesService.find(query.searchDate); + } +} diff --git a/src/modules/yield-rates/yield-rates.module.ts b/src/modules/yield-rates/yield-rates.module.ts new file mode 100644 index 00000000..447ccc06 --- /dev/null +++ b/src/modules/yield-rates/yield-rates.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { DATABASE } from '@ukef/constants'; + +import { YieldRateEntity } from './entities/yield-rate.entity'; +import { YieldRatesController } from './yield-rates.controller'; +import { YieldRatesService } from './yield-rates.service'; + +@Module({ + imports: [TypeOrmModule.forFeature([YieldRateEntity], DATABASE.CEDAR)], + controllers: [YieldRatesController], + providers: [YieldRatesService], +}) +export class YieldRatesModule {} diff --git a/src/modules/yield-rates/yield-rates.service.ts b/src/modules/yield-rates/yield-rates.service.ts new file mode 100644 index 00000000..4e2d56b8 --- /dev/null +++ b/src/modules/yield-rates/yield-rates.service.ts @@ -0,0 +1,40 @@ +import { Injectable, InternalServerErrorException, Logger, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { DATABASE, DATE } from '@ukef/constants'; +import { Equal, LessThanOrEqual, MoreThan, Repository } from 'typeorm'; + +import { YieldRateEntity } from './entities/yield-rate.entity'; + +@Injectable() +export class YieldRatesService { + private readonly logger = new Logger(); + constructor( + @InjectRepository(YieldRateEntity, DATABASE.CEDAR) + private readonly yieldRateRepository: Repository, + ) {} + + async find(searchDate: string): Promise { + try { + // Search by date or use standard max date to get active/current rates. + const query: object = searchDate + ? { effectiveTo: MoreThan(searchDate), effectiveFrom: LessThanOrEqual(searchDate) } + : { effectiveTo: Equal(DATE.MAXIMUM_LIMIT) }; + + const results = await this.yieldRateRepository.find({ + where: query, + }); + if (!results.length) { + throw new NotFoundException('No Yield rates found'); + } + return results; + } catch (err) { + if (err instanceof NotFoundException) { + this.logger.warn(err); + throw err; + } else { + this.logger.error(err); + throw new InternalServerErrorException(); + } + } + } +} diff --git a/test/exposure-period/exposure-period.api-test.ts b/test/exposure-period/exposure-period.api-test.ts index d21590c0..80095e8e 100644 --- a/test/exposure-period/exposure-period.api-test.ts +++ b/test/exposure-period/exposure-period.api-test.ts @@ -95,33 +95,41 @@ describe('Exposure period', () => { it('GET /exposure-period', async () => { const { status, body } = await api.get(`/exposure-period`); expect(status).toBe(400); - expect(body.message).toContain('startdate must be a Date instance'); - expect(body.message).toContain('enddate must be a Date instance'); + expect(body.message).toContain('startdate must be a valid ISO 8601 date string'); + expect(body.message).toContain('enddate must be a valid ISO 8601 date string'); expect(body.message).toContain('productgroup must be one of the following values: EW, BS'); expect(body.message).toContain('productgroup must be a string'); }); + it('Should fail Feb 29 and Feb 30 - GET /exposure-period?startdate=2017-02-29&enddate=2017-02-30&productgroup=test', async () => { + const { status, body } = await api.get('/exposure-period?startdate=2017-02-29&enddate=2017-02-30&productgroup=test'); + expect(status).toBe(400); + expect(body.message).toContain('startdate must be a valid ISO 8601 date string'); + expect(body.message).toContain('enddate must be a valid ISO 8601 date string'); + expect(body.message).toContain('productgroup must be one of the following values: EW, BS'); + }); + it('GET /exposure-period?startdate=2017-01-32&enddate=2017-02-32&productgroup=test', async () => { const { status, body } = await api.get('/exposure-period?startdate=2017-01-32&enddate=2017-02-32&productgroup=test'); expect(status).toBe(400); - expect(body.message).toContain('startdate must be a Date instance'); - expect(body.message).toContain('enddate must be a Date instance'); + expect(body.message).toContain('startdate must be a valid ISO 8601 date string'); + expect(body.message).toContain('enddate must be a valid ISO 8601 date string'); expect(body.message).toContain('productgroup must be one of the following values: EW, BS'); }); it('GET /exposure-period?startdate=null&enddate=null&productgroup=null', async () => { const { status, body } = await api.get('/exposure-period?startdate=null&enddate=null&productgroup=null'); expect(status).toBe(400); - expect(body.message).toContain('startdate must be a Date instance'); - expect(body.message).toContain('enddate must be a Date instance'); + expect(body.message).toContain('startdate must be a valid ISO 8601 date string'); + expect(body.message).toContain('enddate must be a valid ISO 8601 date string'); expect(body.message).toContain('productgroup must be one of the following values: EW, BS'); }); it('GET /exposure-period?startdate=undefined&enddate=undefined&productgroup=undefined', async () => { const { status, body } = await api.get('/exposure-period?startdate=undefined&enddate=undefined&productgroup=undefined'); expect(status).toBe(400); - expect(body.message).toContain('startdate must be a Date instance'); - expect(body.message).toContain('enddate must be a Date instance'); + expect(body.message).toContain('startdate must be a valid ISO 8601 date string'); + expect(body.message).toContain('enddate must be a valid ISO 8601 date string'); expect(body.message).toContain('productgroup must be one of the following values: EW, BS'); }); diff --git a/test/yield-rates/yield-rates.api-test.ts b/test/yield-rates/yield-rates.api-test.ts new file mode 100644 index 00000000..0386c8db --- /dev/null +++ b/test/yield-rates/yield-rates.api-test.ts @@ -0,0 +1,124 @@ +import { INestApplication } from '@nestjs/common'; +import { DATE } from '@ukef/constants'; + +import { Api } from '../api'; +import { CreateApp } from '../createApp'; + +describe('Interest rates', () => { + let app: INestApplication; + let api: Api; + + beforeAll(async () => { + app = await new CreateApp().init(); + api = new Api(app.getHttpServer()); + }); + + const yieldRateSchema = { + id: expect.any(Number), + shortName: expect.any(String), + pxBidPrice: expect.any(Number), + pxAskPrice: expect.any(Number), + pxLastPrice: expect.any(Number), + pxMidPrice: expect.any(Number), + futureMonthYear: expect.any(String), + sourceErrorCode: expect.any(Number), + sourceUpdateTimestamp: expect.any(String), + yieldIndex: expect.any(String), + created: expect.any(String), + updated: expect.any(String), + effectiveFrom: expect.any(String), + effectiveTo: expect.any(String), + }; + + it(`GET /yield-rates`, async () => { + const { status, body } = await api.get('/yield-rates'); + expect(status).toBe(200); + expect(body).toEqual(expect.arrayContaining([expect.objectContaining({ ...yieldRateSchema, effectiveTo: DATE.MAXIMUM_TIMEZONE_LIMIT })])); + }); + + it(`GET /yield-rates?searchDate=2023-03-02`, async () => { + const { status, body } = await api.get('/yield-rates?searchDate=2023-03-02'); + expect(status).toBe(200); + expect(body).toEqual(expect.arrayContaining([expect.objectContaining(yieldRateSchema)])); + + // Field effectiveTo should NOT be set to MAXIMUM LIMIT + expect(body).not.toEqual(expect.arrayContaining([expect.objectContaining({ ...yieldRateSchema, effectiveTo: DATE.MAXIMUM_TIMEZONE_LIMIT })])); + }); + + // UKEF at the moment has yield rates since 2010-03-15, maybe some old data will be retired in future. + it(`returns 404 for any date past 2010-03-15 where the yield data does not exist`, async () => { + const { status } = await api.get('/yield-rates?searchDate=2010-03-14'); + expect(status).toBe(404); + }); + + // Current yield rates have effective date till 9999-12-31, so 9999-12-30 is max date with results. + it(`GET /yield-rates?searchDate=9999-12-30`, async () => { + const { status, body } = await api.get('/yield-rates?searchDate=9999-12-30'); + expect(status).toBe(200); + expect(body).toEqual(expect.arrayContaining([expect.objectContaining({ ...yieldRateSchema, effectiveTo: DATE.MAXIMUM_TIMEZONE_LIMIT })])); + }); + + // Current yield rates have effective date till 9999-12-31, so no rates for this max date. + it(`returns 404 for GET /yield-rates?searchDate=9999-12-31`, async () => { + const { status } = await api.get('/yield-rates?searchDate=9999-12-31'); + expect(status).toBe(404); + }); + + it(`returns 400 for GET /yield-rates?searchDate=2023-03-02T16:29:04.027Z`, async () => { + const { status, body } = await api.get('/yield-rates?searchDate=2023-03-02T16:29:04.027Z'); + expect(status).toBe(400); + expect(body.message).toContain('searchDate should use format YYYY-MM-DD'); + }); + + it(`returns 400 for GET /yield-rates?searchDate=null`, async () => { + const { status, body } = await api.get('/yield-rates?searchDate=null'); + expect(status).toBe(400); + expect(body.message).toContain('searchDate must be a valid ISO 8601 date string'); + }); + + it(`returns 400 for GET /yield-rates?searchDate=undefined`, async () => { + const { status, body } = await api.get('/yield-rates?searchDate=undefined'); + expect(status).toBe(400); + expect(body.message).toContain('searchDate must be a valid ISO 8601 date string'); + }); + + it(`returns 400 for GET /yield-rates?searchDate=ABC`, async () => { + const { status, body } = await api.get('/yield-rates?searchDate=ABC'); + expect(status).toBe(400); + expect(body.message).toContain('searchDate must be a valid ISO 8601 date string'); + }); + + it(`returns 400 for GET /yield-rates?searchDate=123`, async () => { + const { status, body } = await api.get('/yield-rates?searchDate=123'); + expect(status).toBe(400); + expect(body.message).toContain('searchDate must be a valid ISO 8601 date string'); + }); + + it(`returns 400 for GET /yield-rates?searchDate=!"£!"£`, async () => { + const { status, body } = await api.get('/yield-rates?searchDate=!"£!"£'); + expect(status).toBe(400); + expect(body.message).toContain('searchDate must be a valid ISO 8601 date string'); + }); + + it(`returns 400 for GET /yield-rates?searchDate=A%20£`, async () => { + const { status, body } = await api.get('/yield-rates?searchDate=A%20£'); + expect(status).toBe(400); + expect(body.message).toContain('searchDate must be a valid ISO 8601 date string'); + }); + + it(`returns 400 for GET /yield-rates?searchDate=++`, async () => { + const { status, body } = await api.get('/yield-rates?searchDate=++'); + expect(status).toBe(400); + expect(body.message).toContain('searchDate must be a valid ISO 8601 date string'); + }); + + it(`returns 400 for GET /yield-rates?searchDate=0000-00-00`, async () => { + const { status, body } = await api.get('/yield-rates?searchDate=0000-00-00'); + expect(status).toBe(400); + expect(body.message).toContain('searchDate must be a valid ISO 8601 date string'); + }); + + afterAll(async () => { + await app.close(); + }); +});