Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions harvest-finance/backend/src/common/interceptors/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@
*/

export { VersioningInterceptor } from './versioning.interceptor';
export { ResponseInterceptor } from './response.interceptor';
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
import { ExecutionContext, CallHandler } from '@nestjs/common';
import { ResponseInterceptor } from './response.interceptor';
import { of } from 'rxjs';

describe('ResponseInterceptor', () => {
let interceptor: ResponseInterceptor;

beforeEach(() => {
interceptor = new ResponseInterceptor();
});

function createMockContext(
type: string = 'http',
headersSent: boolean = false,
statusCode: number = 200,
): Partial<ExecutionContext> {
const mockResponse = {
headersSent,
statusCode,
};
return {
getType: () => type as any,
switchToHttp: () => ({
getResponse: () => mockResponse as any,
getRequest: () => ({}) as any,
getNext: () => ({}) as any,
}),
};
}

function createMockCallHandler(returnValue: any): CallHandler {
return {
handle: () => of(returnValue),
};
}

it('should be defined', () => {
expect(interceptor).toBeDefined();
});

describe('REST (http) contexts', () => {
it('should wrap primitive values in a { data, meta } envelope', (done) => {
const context = createMockContext('http', false, 200) as ExecutionContext;
const handler = createMockCallHandler('hello');

interceptor.intercept(context, handler).subscribe((result) => {
expect(result).toEqual({
data: 'hello',
meta: {},
});
done();
});
});

it('should wrap array values in a { data, meta } envelope', (done) => {
const context = createMockContext('http', false, 200) as ExecutionContext;
const handler = createMockCallHandler([1, 2, 3]);

interceptor.intercept(context, handler).subscribe((result) => {
expect(result).toEqual({
data: [1, 2, 3],
meta: {},
});
done();
});
});

it('should wrap plain objects in a { data, meta } envelope', (done) => {
const context = createMockContext('http', false, 200) as ExecutionContext;
const handler = createMockCallHandler({ id: 1, name: 'Test' });

interceptor.intercept(context, handler).subscribe((result) => {
expect(result).toEqual({
data: { id: 1, name: 'Test' },
meta: {},
});
done();
});
});

it('should handle null and undefined by returning null data', (done) => {
const context = createMockContext('http', false, 200) as ExecutionContext;
const handler = createMockCallHandler(null);

interceptor.intercept(context, handler).subscribe((result) => {
expect(result).toEqual({
data: null,
meta: {},
});
done();
});
});

it('should leave pre-existing { data, meta } envelopes unchanged', (done) => {
const context = createMockContext('http', false, 200) as ExecutionContext;
const original = { data: [1, 2], meta: { total: 10, page: 2 } };
const handler = createMockCallHandler(original);

interceptor.intercept(context, handler).subscribe((result) => {
expect(result).toEqual(original);
done();
});
});

it('should ensure meta is an object if pre-existing { data, meta } envelope has non-object meta', (done) => {
const context = createMockContext('http', false, 200) as ExecutionContext;
const original = { data: [1, 2], meta: null };
const handler = createMockCallHandler(original);

interceptor.intercept(context, handler).subscribe((result) => {
expect(result).toEqual({
data: [1, 2],
meta: {},
});
done();
});
});

it('should convert objects with "data" but missing "meta" and shift extra fields to meta', (done) => {
const context = createMockContext('http', false, 200) as ExecutionContext;
const original = { data: [1, 2], total: 10, page: 1 };
const handler = createMockCallHandler(original);

interceptor.intercept(context, handler).subscribe((result) => {
expect(result).toEqual({
data: [1, 2],
meta: { total: 10, page: 1 },
});
done();
});
});

it('should convert objects with "items" and "total" to { data: items, meta: { total } }', (done) => {
const context = createMockContext('http', false, 200) as ExecutionContext;
const original = { items: [{ id: 1 }], total: 5, page: 2 };
const handler = createMockCallHandler(original);

interceptor.intercept(context, handler).subscribe((result) => {
expect(result).toEqual({
data: [{ id: 1 }],
meta: { total: 5, page: 2 },
});
done();
});
});

it('should bypass response wrapping if response headers are already sent', (done) => {
const context = createMockContext('http', true, 200) as ExecutionContext;
const handler = createMockCallHandler('raw stream or buffer');

interceptor.intercept(context, handler).subscribe((result) => {
expect(result).toBe('raw stream or buffer');
done();
});
});

it('should bypass response wrapping for 204 No Content status', (done) => {
const context = createMockContext('http', false, 204) as ExecutionContext;
const handler = createMockCallHandler(undefined);

interceptor.intercept(context, handler).subscribe((result) => {
expect(result).toBeUndefined();
done();
});
});

it('should bypass response wrapping for 304 Not Modified status', (done) => {
const context = createMockContext('http', false, 304) as ExecutionContext;
const handler = createMockCallHandler('not modified');

interceptor.intercept(context, handler).subscribe((result) => {
expect(result).toBe('not modified');
done();
});
});

it('should bypass response wrapping for Buffer payloads', (done) => {
const context = createMockContext('http', false, 200) as ExecutionContext;
const buffer = Buffer.from('hello');
const handler = createMockCallHandler(buffer);

interceptor.intercept(context, handler).subscribe((result) => {
expect(result).toBe(buffer);
done();
});
});

it('should bypass response wrapping for StreamableFile payloads', (done) => {
const context = createMockContext('http', false, 200) as ExecutionContext;
const streamableFileMock = {
getStream: () => ({}),
constructor: { name: 'StreamableFile' },
};
const handler = createMockCallHandler(streamableFileMock);

interceptor.intercept(context, handler).subscribe((result) => {
expect(result).toBe(streamableFileMock);
done();
});
});
});

describe('Non-REST contexts', () => {
it('should bypass interceptor for graphql contexts', (done) => {
const context = createMockContext('graphql') as ExecutionContext;
const handler = createMockCallHandler({ id: 1 });

interceptor.intercept(context, handler).subscribe((result) => {
expect(result).toEqual({ id: 1 });
done();
});
});

it('should bypass interceptor for websockets (ws) contexts', (done) => {
const context = createMockContext('ws') as ExecutionContext;
const handler = createMockCallHandler({ event: 'ping' });

interceptor.intercept(context, handler).subscribe((result) => {
expect(result).toEqual({ event: 'ping' });
done();
});
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { Response } from 'express';

@Injectable()
export class ResponseInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
// Only intercept HTTP requests (ignore WebSockets, GraphQL, etc.)
if (context.getType() !== 'http') {
return next.handle();
}

const response = context.switchToHttp().getResponse<Response>();

return next.handle().pipe(
map((resData) => {
// If response headers are already sent, or it's a 204 No Content / 304 Not Modified, bypass
if (
response.headersSent ||
response.statusCode === 204 ||
response.statusCode === 304
) {
return resData;
}

// Avoid wrapping buffers, streams, or StreamableFile instances
if (
Buffer.isBuffer(resData) ||
(resData &&
typeof resData === 'object' &&
('getStream' in resData ||
'stream' in resData ||
resData.constructor?.name === 'StreamableFile'))
) {
return resData;
}

// If returned data is null or undefined, return clean standard empty envelope
if (resData === null || resData === undefined) {
return {
data: null,
meta: {},
};
}

// Handle object responses
if (typeof resData === 'object') {
const hasData = 'data' in resData;
const hasMeta = 'meta' in resData;

// Case 1: Already enveloped in { data, meta }
if (hasData && hasMeta) {
return {
data: resData.data,
meta:
resData.meta && typeof resData.meta === 'object'
? resData.meta
: {},
};
}

// Case 2: Object has a 'data' key but no 'meta' key (e.g. { data: T[], total: number })
if (hasData && !hasMeta) {
const { data, ...rest } = resData;
return {
data,
meta: rest,
};
}

// Case 3: Paginated structure with { items: T[], total: number }
const hasItems = 'items' in resData && Array.isArray(resData.items);
const hasTotal =
'total' in resData && typeof resData.total === 'number';

if (hasItems && hasTotal) {
const { items, total, ...rest } = resData;
return {
data: items,
meta: {
total,
...rest,
},
};
}
}

// Default Case: Wrap the response raw data
return {
data: resData,
meta: {},
};
}),
);
}
}
3 changes: 3 additions & 0 deletions harvest-finance/backend/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { ThrottlerExceptionFilter } from './common/filters/throttler-exception.f
import { SorobanExceptionFilter } from './common/filters/soroban-exception.filter';
import { CustomLoggerService } from './logger/custom-logger.service';
import { VersioningInterceptor } from './common/interceptors/versioning.interceptor';
import { ResponseInterceptor } from './common/interceptors/response.interceptor';

async function bootstrap() {
const app = await NestFactory.create(AppModule, {
Expand All @@ -39,6 +40,8 @@ async function bootstrap() {
}),
);

app.useGlobalInterceptors(new ResponseInterceptor());

app.useWebSocketAdapter(new IoAdapter(app));

const config = new DocumentBuilder()
Expand Down