Skip to content

Commit

Permalink
add health controller, exception handling and rate limit
Browse files Browse the repository at this point in the history
  • Loading branch information
saminegash committed Oct 16, 2024
1 parent e357c18 commit a5221ea
Show file tree
Hide file tree
Showing 12 changed files with 272 additions and 57 deletions.
1 change: 1 addition & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,6 @@ RUN pnpm install
COPY . .

RUN pnpm run build
EXPOSE 3000

CMD [ "pnpm", "start:dev" ]
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,12 @@
"@nestjs/swagger": "^7.4.2",
"@nestjs/typeorm": "^10.0.2",
"axios": "^1.7.7",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"moralis": "^2.27.2",
"nodemailer": "^6.9.15",
"pg": "^8.13.0",
"rate-limiter-flexible": "^5.0.3",
"reflect-metadata": "^0.2.0",
"rxjs": "^7.8.1",
"typeorm": "^0.3.20"
Expand Down
119 changes: 86 additions & 33 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

27 changes: 19 additions & 8 deletions src/alert/alert.controller.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
import { Controller, Post, Body } from '@nestjs/common';
import {
Controller,
Post,
Body,
ValidationPipe,
UsePipes,
} from '@nestjs/common';
import { AlertService } from './alert.service';
import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { CreateAlertDto } from './dto/create-alert.dto';
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';

@ApiTags('alerts')
@Controller('alerts')
Expand All @@ -9,13 +16,17 @@ export class AlertController {

@Post()
@ApiOperation({ summary: 'Create a new price alert' })
async createAlert(
@Body() alertData: { chain: string; targetPrice: number; email: string },
) {
@ApiResponse({
status: 201,
description: 'The alert has been successfully created.',
})
@ApiResponse({ status: 400, description: 'Invalid input data.' })
@UsePipes(new ValidationPipe({ transform: true }))
async createAlert(@Body() createAlertDto: CreateAlertDto) {
return this.alertService.createAlert(
alertData.chain,
alertData.targetPrice,
alertData.email,
createAlertDto.chain,
createAlertDto.targetPrice,
createAlertDto.email,
);
}
}
26 changes: 19 additions & 7 deletions src/alert/alert.service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Injectable, Logger } from '@nestjs/common';
import { Injectable, Logger, HttpException, HttpStatus } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Alert } from './entities/alert.entity';
Expand Down Expand Up @@ -32,8 +32,16 @@ export class AlertService {
targetPrice: number,
email: string,
): Promise<Alert> {
const alert = this.alertRepository.create({ chain, targetPrice, email });
return this.alertRepository.save(alert);
try {
const alert = this.alertRepository.create({ chain, targetPrice, email });
return await this.alertRepository.save(alert);
} catch (error) {
this.logger.error(`Error creating alert: ${error.message}`, error.stack);
throw new HttpException(
'Failed to create alert',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}

async checkAlerts() {
Expand All @@ -50,7 +58,7 @@ export class AlertService {
}
}
} catch (error) {
this.logger.error('Error checking alerts', error.stack);
this.logger.error(`Error checking alerts: ${error.message}`, error.stack);
}
}

Expand All @@ -76,7 +84,7 @@ export class AlertService {
}
} catch (error) {
this.logger.error(
`Error checking price increase for ${chain}`,
`Error checking price increase for ${chain}: ${error.message}`,
error.stack,
);
}
Expand All @@ -85,7 +93,10 @@ export class AlertService {

private async getCurrentPrice(chain: string): Promise<number> {
const prices = await this.priceService.getPricesLastHour(chain);
return prices[0]?.price || 0;
if (prices.length === 0) {
throw new Error(`No recent prices found for ${chain}`);
}
return prices[0].price;
}

private async sendAlertEmail(alert: Alert, currentPrice: number) {
Expand Down Expand Up @@ -115,7 +126,8 @@ export class AlertService {
});
this.logger.log(`Email sent to ${to}: ${subject}`);
} catch (error) {
this.logger.error('Error sending email', error.stack);
this.logger.error(`Error sending email: ${error.message}`, error.stack);
throw new Error('Failed to send email');
}
}
}
21 changes: 20 additions & 1 deletion src/alert/dto/create-alert.dto.ts
Original file line number Diff line number Diff line change
@@ -1 +1,20 @@
export class CreateAlertDto {}
import { IsString, IsNumber, IsEmail, IsIn } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';

export class CreateAlertDto {
@ApiProperty({
description: 'The blockchain to monitor',
enum: ['ethereum', 'polygon'],
})
@IsString()
@IsIn(['ethereum', 'polygon'])
chain: string;

@ApiProperty({ description: 'The target price to trigger the alert' })
@IsNumber()
targetPrice: number;

@ApiProperty({ description: 'The email address to send the alert to' })
@IsEmail()
email: string;
}
4 changes: 4 additions & 0 deletions src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { ScheduleModule } from '@nestjs/schedule';
import { PriceModule } from './price/price.module';
import { AlertModule } from './alert/alert.module';
import { HealthController } from './health/health.controller';
import { ScheduledTasksService } from './scheduled-tasks.service';
import configuration from './config/configuration';

@Module({
Expand All @@ -31,5 +33,7 @@ import configuration from './config/configuration';
PriceModule,
AlertModule,
],
controllers: [HealthController],
providers: [ScheduledTasksService],
})
export class AppModule {}
42 changes: 42 additions & 0 deletions src/common/http-exception.filter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import {
ExceptionFilter,
Catch,
ArgumentsHost,
HttpException,
HttpStatus,
Logger,
} from '@nestjs/common';
import { Request, Response } from 'express';

@Catch()
export class HttpExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger(HttpExceptionFilter.name);

catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
const status =
exception instanceof HttpException
? exception.getStatus()
: HttpStatus.INTERNAL_SERVER_ERROR;

const message =
exception instanceof HttpException
? exception.getResponse()
: 'Internal server error';

this.logger.error(
`Http Status: ${status} Error Message: ${JSON.stringify(message)}`,
exception instanceof Error ? exception.stack : undefined,
`${request.method} ${request.url}`,
);

response.status(status).json({
statusCode: status,
timestamp: new Date().toISOString(),
path: request.url,
message: message,
});
}
}
38 changes: 38 additions & 0 deletions src/common/rate-limiting.guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import {
Injectable,
CanActivate,
ExecutionContext,
HttpException,
HttpStatus,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { RateLimiterMemory } from 'rate-limiter-flexible';

@Injectable()
export class RateLimitingGuard implements CanActivate {
private rateLimiter: RateLimiterMemory;

constructor() {
this.rateLimiter = new RateLimiterMemory({
points: 10, // Number of points
duration: 1, // Per second
});
}

canActivate(
context: ExecutionContext,
): boolean | Promise<boolean> | Observable<boolean> {
const request = context.switchToHttp().getRequest();
const key = request.ip;

return this.rateLimiter
.consume(key)
.then(() => true)
.catch(() => {
throw new HttpException(
'Too Many Requests',
HttpStatus.TOO_MANY_REQUESTS,
);
});
}
}
13 changes: 13 additions & 0 deletions src/health/health.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { Controller, Get } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';

@ApiTags('health')
@Controller('health')
export class HealthController {
@Get()
@ApiOperation({ summary: 'Check application health' })
@ApiResponse({ status: 200, description: 'Application is healthy' })
check() {
return { status: 'ok' };
}
}
12 changes: 11 additions & 1 deletion src/main.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,24 @@
import { RateLimitingGuard } from './common/rate-limiting.guard';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import { ValidationPipe } from '@nestjs/common';
import { AppModule } from './app.module';
import { HttpExceptionFilter } from './common/http-exception.filter';

async function bootstrap() {
const app = await NestFactory.create(AppModule);

app.useGlobalPipes(new ValidationPipe());
app.useGlobalFilters(new HttpExceptionFilter());
app.useGlobalGuards(new RateLimitingGuard());

const config = new DocumentBuilder()
.setTitle('Blockchain Price Tracker')
.setDescription('API for tracking Ethereum and Polygon prices')
.setVersion('1.0')
.addTag('prices')
.addTag('alerts')
.addTag('health')
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('api', app, document);
Expand Down
23 changes: 16 additions & 7 deletions src/price/price.service.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Injectable, Logger } from '@nestjs/common';
import { Injectable, Logger, HttpException, HttpStatus } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { MoreThan, Repository } from 'typeorm';
import { Repository, MoreThan } from 'typeorm';
import { Price } from './entities/price.entity';
import { ConfigService } from '@nestjs/config';
import axios from 'axios';
Expand Down Expand Up @@ -28,7 +28,11 @@ export class PriceService {
this.logger.log(`Saved ${chain} price: $${price}`);
}
} catch (error) {
this.logger.error('Error saving prices', error.stack);
this.logger.error(`Error saving prices: ${error.message}`, error.stack);
throw new HttpException(
'Failed to save prices',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}

Expand All @@ -44,18 +48,23 @@ export class PriceService {
});
return parseFloat(response.data.usdPrice);
} catch (error) {
this.logger.error(`Error fetching ${chain} price`, error.stack);
throw error;
this.logger.error(
`Error fetching ${chain} price: ${error.message}`,
error.stack,
);
throw new HttpException(
`Failed to fetch ${chain} price`,
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}

private getAddress(chain: string): string {
// You might want to store these in a configuration file
const addresses = {
ethereum: '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2', // WETH
polygon: '0x0d500b1d8e8ef31e21c99d1db9a6444d3adf1270', // WMATIC
};
return addresses[chain];
return addresses[chain] || '';
}

async getPricesLastHour(chain: string): Promise<Price[]> {
Expand Down

0 comments on commit a5221ea

Please sign in to comment.