From dae51cb706c4b5038dcce1ac63e6173e3a38c6c6 Mon Sep 17 00:00:00 2001 From: gjovs <00drpixelss@gmail.com> Date: Tue, 1 Apr 2025 15:23:10 -0300 Subject: [PATCH 1/2] feat: add cache request adapter --- .vscode/settings.json | 1 + .../protocols/cache/generic-cache-service.ts | 6 + src/data/protocols/cache/index.ts | 1 + src/infra/cache/index.ts | 1 + src/infra/cache/node-cache.ts | 24 ++++ .../turbo-plugin/turbo-interceptor.ts | 7 +- .../service/adapters/cache-request-adapter.ts | 123 ++++++++++++++++++ src/infra/http/service/adapters/index.ts | 1 + src/util/cache.ts | 6 + .../adapters/cache-request-adapter.spec.ts | 106 +++++++++++++++ 10 files changed, 270 insertions(+), 6 deletions(-) create mode 100644 src/data/protocols/cache/generic-cache-service.ts create mode 100644 src/infra/cache/node-cache.ts create mode 100644 src/infra/http/service/adapters/cache-request-adapter.ts create mode 100644 test/unit/infra/infra/http/adapters/cache-request-adapter.spec.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index 9bbfba45..fa3a5dc8 100755 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -3,6 +3,7 @@ "acked", "agendash", "agentkeepalive", + "checkperiod", "codegen", "datora", "fastify", diff --git a/src/data/protocols/cache/generic-cache-service.ts b/src/data/protocols/cache/generic-cache-service.ts new file mode 100644 index 00000000..37e3a149 --- /dev/null +++ b/src/data/protocols/cache/generic-cache-service.ts @@ -0,0 +1,6 @@ +export interface GenericCacheService { + get(...args: unknown[]): Promise; + get(...args: unknown[]): T; + + set(...args: unknown[]): T; +} diff --git a/src/data/protocols/cache/index.ts b/src/data/protocols/cache/index.ts index facf5b36..68727195 100644 --- a/src/data/protocols/cache/index.ts +++ b/src/data/protocols/cache/index.ts @@ -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'; diff --git a/src/infra/cache/index.ts b/src/infra/cache/index.ts index d847d7ab..c1053989 100644 --- a/src/infra/cache/index.ts +++ b/src/infra/cache/index.ts @@ -1 +1,2 @@ export * from './factory'; +export * from './node-cache'; diff --git a/src/infra/cache/node-cache.ts b/src/infra/cache/node-cache.ts new file mode 100644 index 00000000..05f955aa --- /dev/null +++ b/src/infra/cache/node-cache.ts @@ -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(); diff --git a/src/infra/db/mssql/util/knex/extensions/turbo-plugin/turbo-interceptor.ts b/src/infra/db/mssql/util/knex/extensions/turbo-plugin/turbo-interceptor.ts index cdab1a94..22edeb0b 100644 --- a/src/infra/db/mssql/util/knex/extensions/turbo-plugin/turbo-interceptor.ts +++ b/src/infra/db/mssql/util/knex/extensions/turbo-plugin/turbo-interceptor.ts @@ -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; -function generateHashKeyToMemJs(value: string): string { - return createHash('sha256').update(value).digest('hex'); -} - const cache = new NodeCache({ stdTTL: 100, checkperiod: 120 }); const memCache = makeCacheServer(); diff --git a/src/infra/http/service/adapters/cache-request-adapter.ts b/src/infra/http/service/adapters/cache-request-adapter.ts new file mode 100644 index 00000000..8d7dd10a --- /dev/null +++ b/src/infra/http/service/adapters/cache-request-adapter.ts @@ -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 { + 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 + ): Record { + if (!headers || !this.options.headersFields?.length) return {}; + + const relevantHeaders: Record = {}; + + 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 { + 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 { + 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; + } +} diff --git a/src/infra/http/service/adapters/index.ts b/src/infra/http/service/adapters/index.ts index 9a2390c7..5e2a9f98 100755 --- a/src/infra/http/service/adapters/index.ts +++ b/src/infra/http/service/adapters/index.ts @@ -1,2 +1,3 @@ export * from './form-data-request-adapter'; export * from './request-adapter'; +export * from './cache-request-adapter'; diff --git a/src/util/cache.ts b/src/util/cache.ts index 6d9cb836..41b236f3 100644 --- a/src/util/cache.ts +++ b/src/util/cache.ts @@ -1,3 +1,5 @@ +import { createHash } from 'node:crypto'; + import { ALLOWED_CONTEXT, CacheContexts } from '@/data/protocols/cache'; import { name } from '../../package.json'; @@ -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'); +} diff --git a/test/unit/infra/infra/http/adapters/cache-request-adapter.spec.ts b/test/unit/infra/infra/http/adapters/cache-request-adapter.spec.ts new file mode 100644 index 00000000..b00eab3c --- /dev/null +++ b/test/unit/infra/infra/http/adapters/cache-request-adapter.spec.ts @@ -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'); + }); +}); From 1e178fbb797f2ab0f3d7f943796466909ab05fa4 Mon Sep 17 00:00:00 2001 From: gjovs <00drpixelss@gmail.com> Date: Tue, 1 Apr 2025 15:49:16 -0300 Subject: [PATCH 2/2] chore: update CHANGELOG --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 28af8baf..f82df8e4 100755 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ 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.3.0] - 2025-03-19 + +### Added + +- Adds the `CacheRequestAdapter` wrapper of cache functionalities inside request adapter structure and interfaces + + + ## [2.2.0] - 2025-03-19 ### Added