Skip to content

Commit

Permalink
Merge pull request #8413 from ever-co/fix/#8386-logging-middleware-ro…
Browse files Browse the repository at this point in the history
…utes

[Fix] #8386 Updated Call Log Middleware
  • Loading branch information
rahul-rocket authored Oct 14, 2024
2 parents a009fbb + 908b63d commit ad84931
Show file tree
Hide file tree
Showing 8 changed files with 174 additions and 97 deletions.
85 changes: 43 additions & 42 deletions packages/core/src/api-call-log/api-call-log-middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,14 @@ import { Injectable, Logger, NestMiddleware } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
import { v4 as uuidv4 } from 'uuid';
import * as jwt from 'jsonwebtoken';
import { ID, RequestMethod } from '@gauzy/contracts';
import { ID } from '@gauzy/contracts';
import { RequestContext } from '../core/context';
import { ApiCallLogService } from './api-call-log.service';
import { ApiCallLog } from './api-call-log.entity';

@Injectable()
export class ApiCallLogMiddleware implements NestMiddleware {
private readonly logger = new Logger(ApiCallLogMiddleware.name);
private readonly loggingEnabled = true;

constructor(private readonly apiCallLogService: ApiCallLogService) {}

Expand All @@ -35,25 +34,26 @@ export class ApiCallLogMiddleware implements NestMiddleware {
const organizationId = (req.headers['organization-id'] as ID) || null;
const tenantId = (req.headers['tenant-id'] as ID) || null;

// Redact sensitive data from request headers and body
const requestHeaders = this.redactSensitiveData(req.headers, ['authorization', 'token']);
const requestBody = this.redactSensitiveData(req.body, ['password', 'secretKey', 'accessToken']);

// Capture the original end method of the response object to log the response body
const originalEnd = res.end;
res.end = (chunk: any, encoding?: any, callback?: any) => {
if (chunk && (typeof chunk === 'string' || chunk instanceof Buffer || chunk instanceof Uint8Array)) {
responseBody += chunk; // Append chunk to response body
}
return originalEnd.call(res, chunk, encoding, callback); // Call original res.end with the arguments
};

// Get user ID from request context or JWT token
let userId = RequestContext.currentUserId();

try {
const authHeader = req.headers['authorization']; // Get the authorization header
const token = authHeader?.split(' ')[1]; // Extract the token from the authorization header
// Get the authorization header
const authHeader = req.headers['authorization'];

// Initialize token variable
let token: string | undefined;

// Check if the authorization header exists
if (authHeader) {
// Use a regular expression to extract the token
const bearerTokenMatch = authHeader.match(/^Bearer\s+(.+)$/i);

if (bearerTokenMatch && bearerTokenMatch[1]) {
token = bearerTokenMatch[1];
}
}

// Decode the JWT token and retrieve the user ID
if (!userId && token) {
const jwtPayload: string | jwt.JwtPayload = jwt.decode(token);
Expand All @@ -63,20 +63,37 @@ export class ApiCallLogMiddleware implements NestMiddleware {
this.logger.error('Failed to decode JWT token or retrieve user ID', error.stack);
}

// Redact sensitive data from request headers and body
const requestHeaders = this.redactSensitiveData(req.headers, ['authorization', 'Authorization', 'token']);
const requestBody = this.redactSensitiveData(req.body, ['password', 'hash', 'token']);

// Capture the original end method of the response object to log the response body
const originalEnd = res.end;
res.end = (chunk: any, encoding?: any, callback?: any) => {
if (chunk && (typeof chunk === 'string' || Buffer.isBuffer(chunk) || chunk instanceof Uint8Array)) {
// Convert chunk to a string if it's a Buffer
let chunkContent = typeof chunk === 'string' ? chunk : chunk.toString('utf-8');

// Try to parse the chunk string as JSON, fallback to a string if it's not JSON
try {
responseBody = JSON.parse(chunkContent); // Store as object if JSON
} catch (error) {
responseBody = chunkContent; // If not JSON, store as string
}
}
return originalEnd.call(res, chunk, encoding, callback); // Call original res.end with the arguments
};

// Listen for the 'finish' event to log the API call after the response is completed
res.on('finish', async () => {
// Redact sensitive data from response body
const redactedResponseBody = this.redactSensitiveData(responseBody, [
'password',
'secretKey',
'accessToken'
]);
responseBody = this.redactSensitiveData(responseBody, ['password']);

const entity = new ApiCallLog({
correlationId,
organizationId,
tenantId,
method: this.mapHttpMethodToEnum(req.method),
method: req.method,
url: req.originalUrl,
protocol: req.protocol || null,
ipAddress: req.ip || null,
Expand All @@ -85,12 +102,13 @@ export class ApiCallLogMiddleware implements NestMiddleware {
requestHeaders,
requestBody,
statusCode: res.statusCode,
responseBody: redactedResponseBody || {},
responseBody: responseBody || {},
requestTime: new Date(startTime),
responseTime: new Date(),
userId: userId || null
});
this.logger.debug('ApiCallLogMiddleware: logging API call entity:', entity);

this.logger.debug(`ApiCallLogMiddleware: logging API call entity: ${JSON.stringify(entity)}`);

try {
// Asynchronously log the API call to the database
Expand Down Expand Up @@ -138,21 +156,4 @@ export class ApiCallLogMiddleware implements NestMiddleware {

return cleanedData;
}

/**
* Maps an HTTP method string (e.g., 'GET', 'POST') to the corresponding RequestMethod enum.
*
* This function takes an HTTP method as a string and returns the matching RequestMethod enum.
* It handles case-insensitivity by converting the input string to uppercase.
* If the input does not match any valid HTTP method, it defaults to `RequestMethod.ALL`.
*
* @param method The HTTP method string to map (e.g., 'GET', 'POST').
* @returns The corresponding RequestMethod enum value.
*/
mapHttpMethodToEnum(method: string): RequestMethod {
const methodUpper = method.toUpperCase(); // Convert the input string to uppercase

// Return the corresponding RequestMethod enum value, or RequestMethod.ALL if not found
return RequestMethod[methodUpper as keyof typeof RequestMethod] ?? RequestMethod.ALL;
}
}
6 changes: 4 additions & 2 deletions packages/core/src/api-call-log/api-call-log.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { isMySQL, isPostgres } from '@gauzy/config';
import { IApiCallLog, ID, IUser, JsonData, RequestMethod } from '@gauzy/contracts';
import { ColumnIndex, MultiORMColumn, MultiORMEntity, MultiORMManyToOne } from '../core/decorators/entity';
import { TenantOrganizationBaseEntity, User } from '../core/entities/internal';
import { HttpMethodTransformerPipe } from '../shared/pipes';
import { MikroOrmApiCallLogRepository } from './repository/mikro-orm-api-call-log.repository';

@MultiORMEntity('api_call_log', { mikroOrmRepository: () => MikroOrmApiCallLogRepository })
Expand All @@ -32,13 +33,14 @@ export class ApiCallLog extends TenantOrganizationBaseEntity implements IApiCall
url: string;

/**
* The HTTP method (GET, POST, etc.) used in the request
* The HTTP method (GET, POST, etc.) used in the request.
* It is transformed from enum to string using HttpMethodTransformerPipe.
*/
@ApiProperty({ enum: RequestMethod })
@IsNotEmpty()
@IsEnum(RequestMethod)
@ColumnIndex()
@MultiORMColumn()
@MultiORMColumn({ transformer: new HttpMethodTransformerPipe() })
method: RequestMethod;

/**
Expand Down
30 changes: 21 additions & 9 deletions packages/core/src/api-call-log/api-call-log.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,18 +16,30 @@ import { TypeOrmApiCallLogRepository } from './repository/type-orm-api-call-log.
})
export class ApiCallLogModule implements NestModule {
/**
* Configures the middleware for Time Tracking routes (POST, PUT, PATCH, DELETE).
* Configures the middleware for Time Tracking routes (POST, PUT, PATCH, DELETE)
* excluding the '/timesheet/statistics' route.
*
* @param consumer The middleware consumer used to apply the middleware to specific routes.
*/
configure(consumer: MiddlewareConsumer) {
consumer
.apply(ApiCallLogMiddleware)
.forRoutes(
{ path: '/timesheet/*', method: RequestMethod.POST },
{ path: '/timesheet/*', method: RequestMethod.PUT },
{ path: '/timesheet/*', method: RequestMethod.PATCH },
{ path: '/timesheet/*', method: RequestMethod.DELETE }
);
consumer.apply(ApiCallLogMiddleware).forRoutes(
// POST Routes
{ path: '/timesheet/timer/*', method: RequestMethod.POST },
{ path: '/timesheet/time-log', method: RequestMethod.POST },
{ path: '/timesheet/activity/bulk', method: RequestMethod.POST },
{ path: '/timesheet/time-slot', method: RequestMethod.POST },
{ path: '/timesheet/screenshot', method: RequestMethod.POST },

// PUT Routes
{ path: '/timesheet/time-log/:id', method: RequestMethod.PUT },
{ path: '/timesheet/time-slot/:id', method: RequestMethod.PUT },
{ path: '/timesheet/status', method: RequestMethod.PUT },
{ path: '/timesheet/submit', method: RequestMethod.PUT },

// DELETE Routes
{ path: '/timesheet/time-log', method: RequestMethod.DELETE },
{ path: '/timesheet/time-slot', method: RequestMethod.DELETE },
{ path: '/timesheet/screenshot/:id', method: RequestMethod.DELETE }
);
}
}
39 changes: 18 additions & 21 deletions packages/core/src/api-call-log/api-call-log.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,20 @@ export class ApiCallLogService extends TenantAwareCrudService<ApiCallLog> {
// Ensure that filters are properly defined
const queryOptions: FindManyOptions<ApiCallLog> = {
where: {},
...filters
take: filters.take ?? 100, // Default to 100 if not provided
skip: filters.skip ? filters.take * (filters.skip - 1) : 0 // Calculate offset
};

// Apply sorting options (if provided)
if (filters.order) {
queryOptions.order = filters.order; // Order, in which entities should be ordered. Default to ASC if no order is provided.
}

// Check if `filters.where` is an array or an object, then apply individual filters
if (!Array.isArray(filters)) {
if (filters.organizationId) {
queryOptions.where['organizationId'] = filters.organizationId;
}
if (filters.correlationId) {
queryOptions.where['correlationId'] = filters.correlationId;
}
Expand All @@ -41,41 +50,29 @@ export class ApiCallLogService extends TenantAwareCrudService<ApiCallLog> {
if (filters.ipAddress) {
queryOptions.where['ipAddress'] = filters.ipAddress;
}
if (filters.method) {
queryOptions.where['method'] = filters.method;
}
if (filters.userId) {
queryOptions.where['userId'] = filters.userId;
}

// Apply date range filters for requestTime
if (filters.startRequestTime || filters.endRequestTime) {
// The start date for filtering, defaults to the start of today.
const startRequestTime = filters.startRequestTime
const start = filters.startRequestTime
? moment(filters.startRequestTime).toDate()
: moment().startOf('day').toDate();

// The end date for filtering, defaults to the end of today.
const endRequestTime = filters.endRequestTime
const end = filters.endRequestTime
? moment(filters.endRequestTime).toDate()
: moment().endOf('day').toDate();
: moment().endOf('day').toDate(); // Default to end of today if no end date is provided

// Retrieves a date range filter using the `startRequestTime` and `endRequestTime` values.
queryOptions.where['requestTime'] = Between(
startRequestTime, // Default to start of today if no start date is provided
endRequestTime // Default to end of today if no end date is provided
);
// Retrieves a date range filter using the `start` and `end` values.
queryOptions.where['requestTime'] = Between(start, end);
}
}

// Apply pagination and sorting options (if provided)
if (filters.take) {
queryOptions.take = filters.take;
}
if (filters.skip) {
queryOptions.skip = filters.skip;
}
if (filters.order) {
queryOptions.order = filters.order;
}

// Perform the query with filters, sorting, and pagination applied
return await super.findAll(queryOptions);
}
Expand Down
40 changes: 23 additions & 17 deletions packages/core/src/api-call-log/api-call-log.subscriber.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import { ApiCallLog } from './api-call-log.entity';
@EventSubscriber()
export class ApiCallLogSubscriber extends BaseEntityEventSubscriber<ApiCallLog> {
private readonly logger = new Logger(ApiCallLogSubscriber.name);

/**
* Indicates that this subscriber only listen to ApiCallLog events.
*/
Expand All @@ -26,15 +25,22 @@ export class ApiCallLogSubscriber extends BaseEntityEventSubscriber<ApiCallLog>
*/
async beforeEntityCreate(entity: ApiCallLog): Promise<void> {
try {
// Check if the database is SQLite and the entity's metaData is a JavaScript object
// Check if the database is SQLite and ensure that requestHeaders, requestBody, and responseBody are strings
if (isSqlite() || isBetterSqlite3()) {
entity.requestHeaders = JSON.stringify(entity.requestHeaders);
entity.requestBody = JSON.stringify(entity.requestBody);
entity.responseBody = JSON.stringify(entity.responseBody);
['requestHeaders', 'requestBody', 'responseBody'].forEach((field) => {
try {
if (typeof entity[field] === 'object') {
entity[field] = JSON.stringify(entity[field]); // Convert to JSON string
}
} catch (error) {
console.error(`Failed to stringify ${field}:`, error);
entity[field] = '{}'; // Set to an empty JSON object string in case of an error
}
});
}
} catch (error) {
// In case of error during JSON serialization, reset metaData to an empty object
this.logger.log('Error parsing JSON data in beforeEntityCreate:', error);
this.logger.error('Error parsing JSON data in beforeEntityCreate:', error);
}
}

Expand All @@ -49,22 +55,22 @@ export class ApiCallLogSubscriber extends BaseEntityEventSubscriber<ApiCallLog>
*/
async afterEntityLoad(entity: ApiCallLog, em?: MultiOrmEntityManager): Promise<void> {
try {
console.log('Parsing JSON data in afterEntityLoad: %s', entity);
// Check if the database is SQLite and attempt to parse JSON fields
if (isSqlite() || isBetterSqlite3()) {
if (entity.requestHeaders && typeof entity.requestHeaders === 'string') {
entity.requestHeaders = JSON.parse(entity.requestHeaders);
}
if (entity.requestBody && typeof entity.requestBody === 'string') {
entity.requestBody = JSON.parse(entity.requestBody);
}
if (entity.responseBody && typeof entity.responseBody === 'string') {
entity.responseBody = JSON.parse(entity.responseBody);
}
['requestHeaders', 'requestBody', 'responseBody'].forEach((field) => {
if (entity[field] && typeof entity[field] === 'string') {
try {
entity[field] = JSON.parse(entity[field]);
} catch (error) {
console.error(`Failed to parse ${field}:`, error);
entity[field] = {}; // Set to an empty object in case of a parsing error
}
}
});
}
} catch (error) {
// Log the error and reset the data to an empty object if JSON parsing fails
this.logger.log('Error parsing JSON data in afterEntityLoad:', error);
this.logger.error('Error parsing JSON data in afterEntityLoad:', error);
}
}
}
13 changes: 7 additions & 6 deletions packages/core/src/api-call-log/dto/api-call-log-filter.dto.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { ApiPropertyOptional, IntersectionType, PickType } from '@nestjs/swagger';
import { IsDateString, IsNumber, IsOptional, IsUUID } from 'class-validator';
import { ID } from '@gauzy/contracts';
import { PaginationParams } from '../../core/crud/pagination-params';
import { TenantOrganizationBaseDTO } from '../../core/dto/tenant-organization-base.dto';
import { ApiCallLog } from '../api-call-log.entity';
Expand All @@ -10,27 +11,27 @@ import { ApiCallLog } from '../api-call-log.entity';
export class ApiCallLogFilterDTO extends IntersectionType(
TenantOrganizationBaseDTO,
PickType(PaginationParams, ['skip', 'take', 'order']),
PickType(ApiCallLog, ['userId', 'ipAddress'] as const)
PickType(ApiCallLog, ['userId', 'ipAddress', 'method'] as const)
) {
// Correlation ID to filter the request against.
@ApiPropertyOptional()
@ApiPropertyOptional({ type: () => String })
@IsOptional()
@IsUUID()
correlationId?: string;
correlationId?: ID;

// Status Code to filter the request against.
@ApiPropertyOptional()
@ApiPropertyOptional({ type: () => Number })
@IsOptional()
@IsNumber()
statusCode?: number;

// Date range filters for requestTime and responseTime
@ApiPropertyOptional()
@ApiPropertyOptional({ type: () => Date })
@IsOptional()
@IsDateString()
startRequestTime?: Date;

@ApiPropertyOptional()
@ApiPropertyOptional({ type: () => Date })
@IsOptional()
@IsDateString()
endRequestTime?: Date;
Expand Down
Loading

0 comments on commit ad84931

Please sign in to comment.