Skip to content
Merged
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 .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"acked",
"agendash",
"agentkeepalive",
"checkperiod",
"codegen",
"datora",
"fastify",
Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [2.4.0] - 2025-05-08

### Added

- Adds the `CacheRequestAdapter` wrapper of cache functionalities inside request adapter structure and interfaces

## [2.3.0] - 2025-05-06

### Added
Expand Down
6 changes: 6 additions & 0 deletions src/data/protocols/cache/generic-cache-service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export interface GenericCacheService {
get<T>(...args: unknown[]): Promise<T>;
get<T>(...args: unknown[]): T;

set<T>(...args: unknown[]): T;
}
1 change: 1 addition & 0 deletions src/data/protocols/cache/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ export * from './get';
export * from './set';
export * from './replace';
export * from './get-cache-key-by-context';
export * from './generic-cache-service';
1 change: 1 addition & 0 deletions src/infra/cache/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from './factory';
export * from './node-cache';
24 changes: 24 additions & 0 deletions src/infra/cache/node-cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import NodeCache from 'node-cache';

class NodeCacheSingleton {
private static instance: NodeCache;
private static readonly DEFAULT_TTL_IN_MINUTES = 60;
private static readonly DEFAULT_TTL_IN_SECONDS =
NodeCacheSingleton.DEFAULT_TTL_IN_MINUTES * 60;
private static readonly DEFAULT_TIME_TO_CHECK_IN_SECONDS = 120;

private constructor() {}

public static getInstance(): NodeCache {
if (!NodeCacheSingleton.instance) {
NodeCacheSingleton.instance = new NodeCache({
stdTTL: NodeCacheSingleton.DEFAULT_TTL_IN_SECONDS,
checkperiod: NodeCacheSingleton.DEFAULT_TIME_TO_CHECK_IN_SECONDS,
useClones: false
});
}
return NodeCacheSingleton.instance;
}
}

export const makeNodeCache = () => NodeCacheSingleton.getInstance();
Original file line number Diff line number Diff line change
@@ -1,17 +1,12 @@
import { createHash } from 'node:crypto';
import k, { Knex } from 'knex';
import NodeCache from 'node-cache';

import { logger } from '@/util';
import { makeCacheServer } from '@/infra/cache';
import { generateHashKeyToMemJs, logger } from '@/util';

type Services = 'memjs' | 'node-cache';
type GenericObject = Record<string, unknown>;

function generateHashKeyToMemJs(value: string): string {
return createHash('sha256').update(value).digest('hex');
}

const cache = new NodeCache({ stdTTL: 100, checkperiod: 120 });
const memCache = makeCacheServer();

Expand Down
123 changes: 123 additions & 0 deletions src/infra/http/service/adapters/cache-request-adapter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import type { GenericCacheService } from '@/data/protocols/cache';
import type { Data, HttpClient, HttpResponse } from '@/data/protocols/http';
import { generateHashKeyToMemJs, logger } from '@/util';

type Services = 'memjs' | 'node-cache';

const DEFAULT_CACHE_SERVICE: Services = 'node-cache';
const DEFAULT_TTL_IN_MINUTES = 60;

const MAX_RESULT_SIZE = 1024 * 1024 * 1; // ~ 1 MB

export class CachedRequestAdapter implements HttpClient {
constructor(
private readonly httpClient: HttpClient,
private readonly cacheService: GenericCacheService,
private readonly options: {
cacheService?: Services;
headersFields?: string[];
ttl?: number;
} = {}
) {}

async request(data: Data): Promise<HttpResponse> {
const key = this.generateCacheKey(data);
const cachedResponse = await this.getCachedResponse(key);

if (cachedResponse && Object.keys(cachedResponse).length > 1)
return cachedResponse;

const response = await this.httpClient.request(data);

setImmediate(() => {
this.saveToCache(key, response).catch((error) => {
logger.log({
message: `Cache save error: ${error.message}`,
level: 'warn'
});
});
});

return response;
}

private generateCacheKey(data: Data): string {
const method = data.method?.toUpperCase() || '';
const { url } = data;
const headers = this.getRelevantHeaders(data.headers);
const body = this.normalizeBody(data.body);

const keyObject = { method, url, headers, body };
return generateHashKeyToMemJs(JSON.stringify(keyObject));
}

private getRelevantHeaders(
headers?: Record<string, unknown>
): Record<string, string> {
if (!headers || !this.options.headersFields?.length) return {};

const relevantHeaders: Record<string, string> = {};

for (let i = 0; i < this.options.headersFields.length; i++) {
const field = this.options.headersFields[i];
if (headers[field] !== undefined) {
relevantHeaders[field] = String(headers[field]);
}
}

return relevantHeaders;
}

private normalizeBody(body: unknown): unknown {
if (typeof body !== 'object' || body === null || body === undefined)
return body;
return JSON.parse(JSON.stringify(body));
}

private async getCachedResponse(
key: string
): Promise<HttpResponse | undefined> {
try {
if (this.getCacheService() === 'node-cache') {
return this.cacheService.get(key);
}
const buffer = await this.cacheService.get(key);
return buffer ? JSON.parse(buffer.toString()) : undefined;
} catch (error) {
logger.log({
message: `Cache read error: ${error.message}`,
level: 'warn'
});
return undefined;
}
}

private async saveToCache(
key: string,
response: HttpResponse
): Promise<void> {
const responseString = JSON.stringify(response);

if (Buffer.byteLength(responseString) > MAX_RESULT_SIZE) {
return;
}

if (this.getCacheService() === 'node-cache') {
this.cacheService.set(key, response, this.getTtl());
} else {
await this.cacheService.set({
key,
value: responseString,
ttl: this.getTtl()
});
}
}

private getCacheService(): Services {
return this.options.cacheService || DEFAULT_CACHE_SERVICE;
}

private getTtl(): number {
return this.options.ttl || DEFAULT_TTL_IN_MINUTES;
}
}
1 change: 1 addition & 0 deletions src/infra/http/service/adapters/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from './form-data-request-adapter';
export * from './request-adapter';
export * from './cache-request-adapter';
6 changes: 6 additions & 0 deletions src/util/cache.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { createHash } from 'node:crypto';

import { ALLOWED_CONTEXT, CacheContexts } from '@/data/protocols/cache';

import { name } from '../../package.json';
Expand All @@ -18,3 +20,7 @@ export function getCacheKeyByContext(context: CacheContexts, meta?: string) {
const metaValue = meta ? `.${meta}` : '';
return `${applicationName}.${context}${metaValue}`;
}

export function generateHashKeyToMemJs(value: string): string {
return createHash('sha256').update(value).digest('hex');
}
106 changes: 106 additions & 0 deletions test/unit/infra/infra/http/adapters/cache-request-adapter.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import type { GenericCacheService } from '@/data/protocols/cache';
import type { Data, HttpClient } from '@/data/protocols/http';
import { makeNodeCache } from '@/infra/cache';
import { CachedRequestAdapter } from '@/infra/http/service';

const PORT = '3334';

type SutType = {
sut: CachedRequestAdapter;
cacheService: GenericCacheService;
httpClient: HttpClient;
};

type FactoryParams = {
cacheService?: 'node-cache' | 'memjs';
headersFields?: string[];
ttl?: number;
};

const makeSut = (params?: FactoryParams): SutType => {
const mockHttpClient = (): HttpClient => ({
request: jest.fn().mockImplementation(async (data: Data) => ({
statusCode: 200,
body: { url: data.url },
headers: {}
}))
});

const cacheService = makeNodeCache();
const httpClient = mockHttpClient();

return {
sut: new CachedRequestAdapter(httpClient, cacheService, {
cacheService: params?.cacheService,
headersFields: params?.headersFields,
ttl: params?.ttl
}),
cacheService,
httpClient
};
};

// Mock implementations

describe('CachedRequestAdapter', () => {
it('should return cached response for identical requests', async () => {
const { sut } = makeSut();
const data = { method: 'GET', url: `http://localhost:${PORT}` };

await sut.request(data);
const response = await sut.request(data);

expect(response.body).toHaveProperty('url', `http://localhost:${PORT}`);
});

it('should generate different keys for different headers', async () => {
const { sut, httpClient } = makeSut({ headersFields: ['x-auth'] });

const spyOnRequest = jest.spyOn(httpClient, 'request');

const request1 = {
method: 'GET',
url: '/secure',
headers: { 'x-auth': 'token1' }
};

const request2 = {
method: 'GET',
url: '/secure',
headers: { 'x-auth': 'token2' }
};

await sut.request(request1);
await sut.request(request2);

expect(spyOnRequest).toHaveBeenCalledTimes(2);
});

it('should respect TTL configuration', async () => {
const ttl = 1; // 1 second
const { sut, httpClient } = makeSut({ ttl });

const spyOnRequest = jest.spyOn(httpClient, 'request');

await sut.request({ method: 'GET', url: '/test' });
await new Promise((resolve) => {
setTimeout(resolve, 1500);
});
await sut.request({ method: 'GET', url: '/test' });

expect(spyOnRequest).toHaveBeenCalledTimes(2);
});

it('should handle cache server errors gracefully', async () => {
const { sut, cacheService } = makeSut({ cacheService: 'memjs' });

jest
.spyOn(cacheService, 'get')
.mockRejectedValueOnce(new Error('Cache error') as never);

const response = await sut.request({ method: 'GET', url: '/error' });

expect(response.statusCode).toBe(200);
expect(response.body).toHaveProperty('url', '/error');
});
});
Loading