diff --git a/docs/docs/cache.md b/docs/docs/cache.md index 148d17e4c34..ccf00e2df6f 100644 --- a/docs/docs/cache.md +++ b/docs/docs/cache.md @@ -256,6 +256,17 @@ Only `GET` endpoints are cached. Also, HTTP server routes that use the native re @@PlatformCacheInterceptor@@. ::: +::: tip Force refresh +Send the `Cache-Control: no-cache` header with your request (or set the same header on the @@PlatformContext@@) to +bypass @@PlatformCacheInterceptor@@ temporarily. This lets you fetch a fresh response without clearing the cache and is +useful for debugging, warm-up scripts, or administrative tooling. You can override this behavior via the `byPass` +option on @@UseCache@@: + +- Services/methods that aren't tied to HTTP default to `byPass: false`, meaning caching is always considered unless you opt out. +- Controllers/endpoints default to `byPass: "no-cache"`, so sending `Cache-Control: no-cache` skips both the lookup and write for that request without touching existing entries. +- Supply a predicate `byPass: (args, $ctx) => boolean` when you need finer control (tenant-based bypass, admin flag, etc.). + ::: + ## Cache a value Because @@UseCache@@ uses @@PlatformCacheInterceptor@@ and not a middleware, you can also apply the decorator on any @@ -450,9 +461,9 @@ background if current `ttl` is under 45 minutes. ## Refresh cached value -A service method response can be cached by using the `@UseCache` decorator. Sometimes, we need to explicitly refresh the cached data because the consumed data backend state has changed. -because the consumed data backend state has changed. By implementing a notifications service, the backend data can trigger an event to tell your API that -the data has changed. +A service method response can be cached by using the `@UseCache` decorator. Sometimes, we need to explicitly refresh the +cached data because the consumed data backend state has changed. By implementing a notifications service, the backend +data can trigger an event to tell your API that the data has changed. Here is short example: @@ -491,7 +502,8 @@ export class NotificationsService { This small example will force the data refresh. ::: tip -If you have several cached method calls, then the refresh will also be done on all of these methods called by the function passed to `PlatformCache.refresh()`. +If you have several cached method calls, then the refresh will also be done on all of these methods called by the +function passed to `PlatformCache.refresh()`. ::: ## Multi caching diff --git a/docs/package.json b/docs/package.json index 3655c7a8958..344fa5b6e2a 100644 --- a/docs/package.json +++ b/docs/package.json @@ -13,7 +13,7 @@ "@tsed/vitepress-theme": "1.5.7", "@vueuse/core": "10.11.0", "axios": "1.12.0", - "lodash": "4.17.21", + "lodash": "4.17.23", "lucide-vue-next": "^0.436.0", "vitepress": "1.5.0" }, diff --git a/docs/yarn.lock b/docs/yarn.lock index 8f523281722..b461d04889e 100644 --- a/docs/yarn.lock +++ b/docs/yarn.lock @@ -754,7 +754,7 @@ __metadata: "@vueuse/core": "npm:10.11.0" autoprefixer: "npm:^10.4.19" axios: "npm:1.12.0" - lodash: "npm:4.17.21" + lodash: "npm:4.17.23" lucide-vue-next: "npm:^0.436.0" postcss: "npm:^8.4.39" tailwindcss: "npm:3.4.4" @@ -2187,10 +2187,10 @@ __metadata: languageName: node linkType: hard -"lodash@npm:4.17.21": - version: 4.17.21 - resolution: "lodash@npm:4.17.21" - checksum: 10/c08619c038846ea6ac754abd6dd29d2568aa705feb69339e836dfa8d8b09abbb2f859371e86863eda41848221f9af43714491467b5b0299122431e202bb0c532 +"lodash@npm:4.17.23": + version: 4.17.23 + resolution: "lodash@npm:4.17.23" + checksum: 10/82504c88250f58da7a5a4289f57a4f759c44946c005dd232821c7688b5fcfbf4a6268f6a6cdde4b792c91edd2f3b5398c1d2a0998274432cff76def48735e233 languageName: node linkType: hard diff --git a/packages/di/src/common/interfaces/InterceptorContext.ts b/packages/di/src/common/interfaces/InterceptorContext.ts index 5227ee28a52..19318b2d82e 100644 --- a/packages/di/src/common/interfaces/InterceptorContext.ts +++ b/packages/di/src/common/interfaces/InterceptorContext.ts @@ -42,7 +42,7 @@ export interface InterceptorNext { export interface InterceptorContext { target: Klass; propertyKey: string | symbol; - args: any[]; + args: unknown[]; next: InterceptorNext; options?: Opts; } diff --git a/packages/platform/platform-cache/readme.md b/packages/platform/platform-cache/readme.md index 44b9193b3d2..6e417b1271e 100644 --- a/packages/platform/platform-cache/readme.md +++ b/packages/platform/platform-cache/readme.md @@ -35,6 +35,16 @@ A package of Ts.ED framework. See website: https://tsed.devdocs/cache.html npm install --save @tsed/platform-cache ``` +## Force refresh + +Send the header `Cache-Control: no-cache` (or set it directly on the `PlatformContext`) to bypass +`PlatformCacheInterceptor` for a single request. This is handy when you want a fresh response without wiping the cache. +You can override or extend this behavior with the `byPass` option on `@UseCache`: + +- Service methods default to `byPass: false`, so they always consult the cache unless you explicitly return `true` from your predicate. +- HTTP endpoints default to `byPass: "no-cache"`, which automatically honours the `Cache-Control: no-cache` header for one-off refreshes. +- Provide a function (`byPass: (args, $ctx) => boolean`) to build custom strategies (e.g., bypass for admins or based on payload). + ## Contributors Please read [contributing guidelines here](https://tsed.devcontributing.html). diff --git a/packages/platform/platform-cache/src/interceptors/PlatformCacheInterceptor.spec.ts b/packages/platform/platform-cache/src/interceptors/PlatformCacheInterceptor.spec.ts index 8d89e1587ea..2a5aac56ade 100644 --- a/packages/platform/platform-cache/src/interceptors/PlatformCacheInterceptor.spec.ts +++ b/packages/platform/platform-cache/src/interceptors/PlatformCacheInterceptor.spec.ts @@ -1,4 +1,5 @@ import {isClass} from "@tsed/core"; +import {runInContext} from "@tsed/di"; import {serialize} from "@tsed/json-mapper"; import {PlatformTest} from "@tsed/platform-http/testing"; @@ -105,10 +106,13 @@ describe("PlatformCacheInterceptor", () => { describe("canRefreshInBackground()", () => { it("should refresh key in background", async () => { const {cache, interceptor} = await getInterceptorFixture(); + const $ctx = PlatformTest.createRequestContext(); const next = vi.fn(); - await interceptor.canRefreshInBackground("key", {refreshThreshold: 300, ttl: 10000}, next); + await runInContext($ctx, () => { + return interceptor.canRefreshInBackground("key", {refreshThreshold: 300, ttl: 10000}, next, $ctx); + }); expect(cache.get).toHaveBeenCalledWith("$$queue:key"); expect(cache.set).toHaveBeenCalledWith("$$queue:key", true, {ttl: 120}); @@ -120,10 +124,21 @@ describe("PlatformCacheInterceptor", () => { const {cache, interceptor} = await getInterceptorFixture({ ttl: 9700 }); + const $ctx = PlatformTest.createRequestContext(); const next = vi.fn(); - await interceptor.canRefreshInBackground("key", {refreshThreshold: 300, ttl: 10000}, next); + await runInContext($ctx, () => + interceptor.canRefreshInBackground( + "key", + { + refreshThreshold: 300, + ttl: 10000 + }, + next, + $ctx + ) + ); expect(cache.get).toHaveBeenCalledWith("$$queue:key"); expect(cache.ttl).toHaveBeenCalledWith("key"); @@ -133,6 +148,39 @@ describe("PlatformCacheInterceptor", () => { }); }); describe("cacheMethod()", () => { + it("should bypass method cache when byPass returns true", async () => { + const {cache, interceptor} = await getInterceptorFixture(); + cache.getCachedObject = vi.fn(); + cache.setCachedObject = vi.fn(); + cache.defaultKeyResolver = () => defaultKeyResolver; + + class Test { + @UseCache({ + ttl: 10000, + byPass: () => true + }) + test(arg: string) { + return ""; + } + } + + const next = vi.fn().mockResolvedValue("fresh"); + const context: any = { + target: Test, + propertyKey: "test", + args: ["value"], + options: { + ttl: 10000, + byPass: () => true + } + }; + + const result = await interceptor.cacheMethod(context, next); + + expect(cache.getCachedObject).not.toHaveBeenCalled(); + expect(cache.setCachedObject).not.toHaveBeenCalled(); + expect(result).toEqual("fresh"); + }); it("should return the cached response", async () => { const {cache, interceptor} = await getInterceptorFixture(); cache.getCachedObject = vi.fn().mockResolvedValue({ @@ -454,20 +502,14 @@ describe("PlatformCacheInterceptor", () => { } const next = vi.fn(); - const $ctx = { - request: { - method: "GET", - url: "/", - get: vi.fn() - }, - response: { - getBody: vi.fn().mockReturnValue({ - data: "data" - }), - setHeaders: vi.fn(), - onEnd: vi.fn() - } - }; + const $ctx = PlatformTest.createRequestContext(); + vi.spyOn($ctx.request, "get"); + vi.spyOn($ctx, "get").mockReturnValue(undefined); + vi.spyOn($ctx.response, "getBody").mockReturnValue({ + data: "data" + }); + vi.spyOn($ctx.response, "setHeaders").mockReturnThis(); + vi.spyOn($ctx.response, "onEnd").mockImplementation(() => $ctx.response); const context: any = { target: Test, propertyKey: "test", @@ -483,9 +525,9 @@ describe("PlatformCacheInterceptor", () => { const result = await interceptor.cacheResponse(context, next); - expect(cache.getCachedObject).toHaveBeenCalledWith('Test:test:value:{"request":{"method":"GET","url":"/"},"response":{}}'); + expect(cache.getCachedObject).toHaveBeenCalledWith("Test:test:value"); expect(result).toEqual(undefined); - expect((interceptor as any).sendResponse).toHaveBeenCalledWith({data: '{"data":"data"}'}, $ctx); + expect((interceptor as any).sendResponse).toHaveBeenCalledWith({data: '{"data":"data"}'}); expect($ctx.request.get).toHaveBeenCalledWith("cache-control"); /* @@ -526,20 +568,14 @@ describe("PlatformCacheInterceptor", () => { } const next = vi.fn(); - const $ctx = { - request: { - method: "GET", - url: "/", - get: vi.fn() - }, - response: { - getBody: vi.fn().mockReturnValue({ - data: "data" - }), - setHeaders: vi.fn(), - onEnd: vi.fn() - } - }; + const $ctx = PlatformTest.createRequestContext(); + vi.spyOn($ctx.request, "get"); + vi.spyOn($ctx, "get").mockReturnValue(undefined); + vi.spyOn($ctx.response, "getBody").mockReturnValue({ + data: "data" + }); + vi.spyOn($ctx.response, "setHeaders").mockReturnThis(); + vi.spyOn($ctx.response, "onEnd").mockImplementation(() => $ctx.response); const context: any = { target: Test, propertyKey: "test", @@ -556,9 +592,9 @@ describe("PlatformCacheInterceptor", () => { const result = await interceptor.cacheResponse(context, next); - expect(cache.getCachedObject).toHaveBeenCalledWith('TEST:value:{"request":{"method":"GET","url":"/"},"response":{}}'); + expect(cache.getCachedObject).toHaveBeenCalledWith("TEST:value"); expect(result).toEqual(undefined); - expect((interceptor as any).sendResponse).toHaveBeenCalledWith({data: '{"data":"data"}'}, $ctx); + expect((interceptor as any).sendResponse).toHaveBeenCalledWith({data: '{"data":"data"}'}); expect($ctx.request.get).toHaveBeenCalledWith("cache-control"); /* @@ -568,13 +604,15 @@ describe("PlatformCacheInterceptor", () => { expect(cache.setCachedObject).toHaveBeenCalledWith() */ }); - it("should call the method and set the cache", async () => { + it("should bypass cached response when cache-control is no-cache", async () => { const cache = { get: vi.fn().mockResolvedValue(false), set: vi.fn().mockResolvedValue(false), del: vi.fn().mockResolvedValue(true), calculateTTL: vi.fn().mockReturnValue(10000), - getCachedObject: vi.fn().mockResolvedValue(undefined), + getCachedObject: vi.fn().mockResolvedValue({ + data: JSON.stringify({data: "cached"}) + }), setCachedObject: vi.fn().mockResolvedValue("test"), defaultKeyResolver: () => defaultKeyResolver }; @@ -595,24 +633,141 @@ describe("PlatformCacheInterceptor", () => { } } + const next = vi.fn().mockResolvedValue({data: "fresh"}); + const $ctx = PlatformTest.createRequestContext(); + vi.spyOn($ctx.request, "get").mockImplementation((key: string) => (key === "cache-control" ? "no-cache" : undefined)); + vi.spyOn($ctx, "get").mockReturnValue(undefined); + vi.spyOn($ctx.response, "setHeaders").mockReturnThis(); + vi.spyOn($ctx.response, "onEnd").mockImplementation((cb) => { + cb && cb(); + return $ctx.response; + }); + vi.spyOn($ctx.response, "getBody").mockReturnValue({data: "fresh"}); + vi.spyOn($ctx.response, "getHeaders").mockReturnValue({ + "x-key": "key" + }); + + const context: any = { + target: Test, + propertyKey: "test", + args: ["value", $ctx], + options: { + ttl: 10000, + refreshThreshold: 1000 + } + }; + + const sendResponseSpy = vi.spyOn(interceptor as any, "sendResponse").mockReturnValue(undefined); + + const result = await interceptor.cacheResponse(context, next); + + expect(cache.getCachedObject).not.toHaveBeenCalled(); + expect(next).toHaveBeenCalledWith(); + expect(sendResponseSpy).not.toHaveBeenCalled(); + expect($ctx.request.get).toHaveBeenCalledWith("cache-control"); + expect($ctx.response.setHeaders).toHaveBeenCalledWith({ + "cache-control": `max-age=10000` + }); + expect(result).toEqual({data: "fresh"}); + expect(cache.setCachedObject).not.toHaveBeenCalled(); + }); + it("should ignore cache-control header when byPass is false", async () => { + const cache = { + get: vi.fn().mockResolvedValue(false), + set: vi.fn().mockResolvedValue(false), + del: vi.fn().mockResolvedValue(true), + calculateTTL: vi.fn().mockReturnValue(10000), + getCachedObject: vi.fn().mockResolvedValue({ + data: JSON.stringify({data: "data"}) + }), + setCachedObject: vi.fn().mockResolvedValue("test"), + defaultKeyResolver: () => defaultKeyResolver + }; + const interceptor = await PlatformTest.invoke(PlatformCacheInterceptor, [ + { + token: PlatformCache, + use: cache + } + ]); + + class Test { + @UseCache({ + ttl: 10000, + refreshThreshold: 1000, + byPass: false + }) + test(arg: string) { + return ""; + } + } + const next = vi.fn(); - const $ctx = { - request: { - method: "GET", - url: "/", - get: vi.fn() - }, - response: { - getBody: vi.fn().mockReturnValue({ - data: "data" - }), - getHeaders: vi.fn().mockReturnValue({ - "x-key": "key" - }), - setHeaders: vi.fn(), - onEnd: vi.fn() + const $ctx = PlatformTest.createRequestContext(); + vi.spyOn($ctx.request, "get").mockImplementation((key: string) => (key === "cache-control" ? "no-cache" : undefined)); + vi.spyOn($ctx, "get").mockReturnValue(undefined); + vi.spyOn($ctx.response, "setHeaders").mockReturnThis(); + const context: any = { + target: Test, + propertyKey: "test", + args: ["value", $ctx], + options: { + ttl: 10000, + refreshThreshold: 1000, + byPass: false } }; + + vi.spyOn(interceptor, "canRefreshInBackground").mockResolvedValue(); + const sendResponseSpy = vi.spyOn(interceptor as any, "sendResponse").mockResolvedValue(undefined); + + await interceptor.cacheResponse(context, next); + + expect(cache.getCachedObject).toHaveBeenCalledWith("Test:test:value"); + expect(sendResponseSpy).toHaveBeenCalledWith({data: '{"data":"data"}'}); + expect(next).not.toHaveBeenCalled(); + }); + it("should call the method and set the cache", async () => { + const cache = { + get: vi.fn().mockResolvedValue(false), + set: vi.fn().mockResolvedValue(false), + del: vi.fn().mockResolvedValue(true), + calculateTTL: vi.fn().mockReturnValue(10000), + getCachedObject: vi.fn().mockResolvedValue(undefined), + setCachedObject: vi.fn().mockResolvedValue("test"), + defaultKeyResolver: () => defaultKeyResolver + }; + const interceptor = await PlatformTest.invoke(PlatformCacheInterceptor, [ + { + token: PlatformCache, + use: cache + } + ]); + + class Test { + @UseCache({ + ttl: 10000, + refreshThreshold: 1000 + }) + test(arg: string) { + return ""; + } + } + + const next = vi.fn(); + const $ctx = PlatformTest.createRequestContext(); + vi.spyOn($ctx.request, "get"); + vi.spyOn($ctx, "get").mockReturnValue(undefined); + vi.spyOn($ctx.response, "getBody").mockReturnValue({ + data: "data" + }); + vi.spyOn($ctx.response, "getHeaders").mockReturnValue({ + "x-key": "key" + }); + vi.spyOn($ctx.response, "setHeaders").mockReturnThis(); + vi.spyOn($ctx.response, "onEnd").mockImplementation((cb) => { + cb && cb(); + return $ctx.response; + }); const context: any = { target: Test, propertyKey: "test", @@ -628,19 +783,17 @@ describe("PlatformCacheInterceptor", () => { const result = await interceptor.cacheResponse(context, next); - expect(cache.getCachedObject).toHaveBeenCalledWith('Test:test:value:{"request":{"method":"GET","url":"/"},"response":{}}'); + expect(cache.getCachedObject).toHaveBeenCalledWith("Test:test:value"); expect(result).toEqual(undefined); expect($ctx.response.setHeaders).toHaveBeenCalledWith({ "cache-control": `max-age=10000` }); - await $ctx.response.onEnd.mock.calls[0][0](); - expect(cache.setCachedObject).toHaveBeenCalledWith( - 'Test:test:value:{"request":{"method":"GET","url":"/"},"response":{}}', + "Test:test:value", {data: "data"}, { - args: ["value", {request: {method: "GET", url: "/"}, response: {}}], + args: ["value"], headers: {"x-key": "key"}, ttl: 10000 } diff --git a/packages/platform/platform-cache/src/interceptors/PlatformCacheInterceptor.ts b/packages/platform/platform-cache/src/interceptors/PlatformCacheInterceptor.ts index 42d9a63533c..f17033af102 100644 --- a/packages/platform/platform-cache/src/interceptors/PlatformCacheInterceptor.ts +++ b/packages/platform/platform-cache/src/interceptors/PlatformCacheInterceptor.ts @@ -1,7 +1,18 @@ import {IncomingMessage, ServerResponse} from "node:http"; import {isClass, isString, nameOf} from "@tsed/core"; -import {BaseContext, Constant, DIContext, Inject, Interceptor, InterceptorContext, InterceptorMethods, InterceptorNext} from "@tsed/di"; +import { + type BaseContext, + Constant, + context, + DIContext, + Inject, + Interceptor, + InterceptorContext, + InterceptorMethods, + InterceptorNext, + runInContext +} from "@tsed/di"; import {deserialize, serialize} from "@tsed/json-mapper"; import {Logger} from "@tsed/logger"; @@ -10,6 +21,7 @@ import {PlatformCacheOptions} from "../interfaces/PlatformCacheOptions.js"; import {PlatformCache} from "../services/PlatformCache.js"; import {getPrefix} from "../utils/getPrefix.js"; import {isEndpoint} from "../utils/isEndpoint.js"; + const cleanHeaders = (headers: Record, blacklist: string[]) => { return Object.entries(headers) .filter(([key]) => !blacklist.includes(key.toLowerCase())) @@ -21,6 +33,30 @@ const cleanHeaders = (headers: Record, blacklist: string[]) => }, {}); }; +interface Response { + setHeader(key: string, value: string): this; + + setHeaders(headers: Record): this; + + body(data: unknown): this; + + status(status: number): this; + + onEnd(cb: any): void; + + getBody(): unknown; + + getHeaders(): Record; +} + +type Context = DIContext & { + request?: { + get(key: string): string | undefined; + method: string; + }; + response?: Response; +}; + /** * @platform */ @@ -59,7 +95,8 @@ export class PlatformCacheInterceptor implements InterceptorMethods { refreshThreshold?: number; ttl: any; }, - next: Function + next: Function, + $ctx?: Context ) { const inQueue = await this.hasKeyInQueue(key); @@ -69,16 +106,26 @@ export class PlatformCacheInterceptor implements InterceptorMethods { const currentTTL = await this.cache.ttl(key); const calculatedTTL = this.cache.calculateTTL(currentTTL, ttl); - if (currentTTL === undefined || currentTTL < calculatedTTL - refreshThreshold) { - await next(); + try { + if (currentTTL === undefined || currentTTL < calculatedTTL - refreshThreshold) { + if ($ctx) { + await runInContext($ctx, () => next()); + } else { + await next(); + } + } + } finally { + await this.deleteKeyFromQueue(key); } - - await this.deleteKeyFromQueue(key); } } async cacheMethod(context: InterceptorContext, next: InterceptorNext) { - const {key, type, ttl, collectionType, refreshThreshold, args, canCache} = this.getOptions(context); + const {key, type, ttl, collectionType, refreshThreshold, args, canCache, $ctx, byPass} = this.getOptions(context, false); + + if (this.shouldByPassCache(byPass, args, $ctx)) { + return next(); + } const set = (result: any) => { if (!canCache || (canCache && canCache(result))) { @@ -100,11 +147,16 @@ export class PlatformCacheInterceptor implements InterceptorMethods { return result; } - this.canRefreshInBackground(key, {refreshThreshold, ttl}, async () => { - const result = await next(); + this.canRefreshInBackground( + key, + {refreshThreshold, ttl}, + async () => { + const result = await next(); - await set(result); - }).catch((er) => + await set(result); + }, + $ctx + ).catch((er) => this.logger.error({ event: "CACHE_ERROR", method: "cacheMethod", @@ -121,43 +173,57 @@ export class PlatformCacheInterceptor implements InterceptorMethods { return deserialize(JSON.parse(data), {collectionType, type}); } - async cacheResponse(context: InterceptorContext, next: InterceptorNext) { - const {request, response} = context.args[context.args.length - 1]; + async cacheResponse(interceptorContext: InterceptorContext, next: InterceptorNext) { + const {key, ttl, args, $ctx, byPass} = this.getOptions(interceptorContext, "no-cache"); + const currentCtx = $ctx || context(); - if (request.method !== "GET") { + if (this.getMethod(currentCtx) !== "GET") { return next(); } - const {key, ttl, args, $ctx} = this.getOptions(context); + const shouldByPass = this.shouldByPassCache(byPass, args, currentCtx); + const useCache = !shouldByPass; - const cachedObject = await this.cache.getCachedObject(key); + if (useCache) { + const cachedObject = await this.cache.getCachedObject(key); - if (cachedObject && !(request.get("cache-control") === "no-cache")) { - return this.sendResponse(cachedObject, $ctx); + if (cachedObject) { + return this.sendResponse(cachedObject); + } } const result = await next(); - const calculatedTTL = this.cache.calculateTTL(result, ttl); - $ctx.response.setHeaders({ + currentCtx.response?.setHeaders({ "cache-control": `max-age=${calculatedTTL}` }); - // cache final response with his headers and body - response.onEnd(() => { - this.cache.setCachedObject(key, response.getBody(), { + currentCtx.response?.onEnd(() => { + if (!useCache) { + return; + } + + this.cache.setCachedObject(key, currentCtx.response!.getBody(), { ttl: calculatedTTL, args, - headers: cleanHeaders(response.getHeaders(), this.blacklist) + headers: cleanHeaders(currentCtx.response!.getHeaders(), this.blacklist) }); }); return result; } - protected getArgs(context: InterceptorContext) { - return context.args.reduce((args, arg) => { + protected getMethod($ctx = context()) { + return $ctx.request?.method; + } + + protected noCache($ctx = context()) { + return $ctx.request?.get("cache-control") === "no-cache" || $ctx.get("cache-control") === "no-cache"; + } + + protected getArgs(context: InterceptorContext): unknown[] { + return context.args.reduce((args: unknown[], arg) => { if (arg instanceof DIContext || arg instanceof IncomingMessage || arg instanceof ServerResponse) { return args; } @@ -167,25 +233,35 @@ export class PlatformCacheInterceptor implements InterceptorMethods { } return args.concat(arg); - }, []); + }, [] as unknown[]) as unknown[]; } - protected getOptions(context: InterceptorContext) { - const $ctx = context.args[context.args.length - 1]; + protected getOptions( + interceptorContext: InterceptorContext, + defaultByPass: PlatformCacheOptions["byPass"] = false + ) { + const $ctx = this.getContextFromArgs(interceptorContext.args) || context(); - const {ttl, type, collectionType, key: k = this.cache.defaultKeyResolver(), refreshThreshold} = context.options || {}; + const { + ttl, + type, + collectionType, + key: k = this.cache.defaultKeyResolver(), + refreshThreshold, + byPass = defaultByPass + } = interceptorContext.options || {}; - let {canCache} = context.options || {}; + let {canCache} = interceptorContext.options || {}; - const args = this.getArgs(context); - const keyArgs = isString(k) ? k : k(args, $ctx); + const args = this.getArgs(interceptorContext); + const keyArgs = isString(k) ? k : k(args as any[], $ctx as any); if (canCache && canCache === "no-nullish") { canCache = (item: any) => ![null, undefined].includes(item); } return { - key: [...[this.prefix, ...getPrefix(context.target, context.propertyKey)].filter(Boolean), keyArgs].join(":"), + key: [...[this.prefix, ...getPrefix(interceptorContext.target, interceptorContext.propertyKey)].filter(Boolean), keyArgs].join(":"), refreshThreshold, ttl, type, @@ -193,10 +269,27 @@ export class PlatformCacheInterceptor implements InterceptorMethods { collectionType, keyArgs, canCache, - $ctx + $ctx, + byPass }; } + protected getContextFromArgs(args: unknown[]): Context | undefined { + return args.find((arg): arg is Context => arg instanceof DIContext); + } + + protected shouldByPassCache(byPass: PlatformCacheOptions["byPass"], args: unknown[], $ctx?: Context) { + if (!byPass) { + return false; + } + + if (byPass === "no-cache") { + return this.noCache($ctx); + } + + return typeof byPass === "function" ? byPass(args as any[], $ctx as BaseContext) : !!byPass; + } + protected async hasKeyInQueue(key: string) { return !!(await this.cache.get(`$$queue:${key}`)); } @@ -209,28 +302,31 @@ export class PlatformCacheInterceptor implements InterceptorMethods { await this.cache.del(`$$queue:${key}`); } - protected sendResponse(cachedObject: PlatformCachedObject, $ctx: BaseContext) { + protected sendResponse(cachedObject: PlatformCachedObject) { const {headers, ttl} = cachedObject; - const {request, response} = $ctx; + const $ctx = context(); + if ($ctx.request && $ctx.response) { + const requestEtag = $ctx.request.get("if-none-match"); - const requestEtag = request.get("if-none-match"); + if (requestEtag && headers?.etag === requestEtag) { + $ctx.response.status(304).setHeaders(headers).body(undefined); - if (requestEtag && headers.etag === requestEtag) { - response.status(304).setHeaders(headers).body(undefined); + return undefined; + } - return undefined; - } + const data = JSON.parse(cachedObject.data); - const data = JSON.parse(cachedObject.data); + $ctx.response + .setHeaders({ + ...headers, + "x-cached": "true", + "cache-control": `max-age=${ttl}` + }) + .body(data); - $ctx.response - .setHeaders({ - ...headers, - "x-cached": "true", - "cache-control": `max-age=${ttl}` - }) - .body(data); + return cachedObject.data; + } - return data; + return cachedObject.data; } } diff --git a/packages/platform/platform-cache/src/interfaces/PlatformCacheOptions.ts b/packages/platform/platform-cache/src/interfaces/PlatformCacheOptions.ts index 94a6cc53f91..3deff41a175 100644 --- a/packages/platform/platform-cache/src/interfaces/PlatformCacheOptions.ts +++ b/packages/platform/platform-cache/src/interfaces/PlatformCacheOptions.ts @@ -24,4 +24,11 @@ export interface PlatformCacheOptions extends MetadataTypes { * The function determine if the result must be cached or not. */ canCache?: ((item: any) => boolean) | "no-nullish"; + /** + * Configure the bypass cache strategy. + * - `false` (default for services) never bypasses the cache. + * - `"no-cache"` (default for HTTP endpoints) bypasses when the `Cache-Control: no-cache` header is present. + * - Provide a function to decide dynamically based on the current call arguments/context. + */ + byPass?: false | "no-cache" | ((args: any[], $ctx?: BaseContext) => boolean); } diff --git a/packages/platform/platform-test-sdk/src/tests/testCache.ts b/packages/platform/platform-test-sdk/src/tests/testCache.ts index 1e89ae3ff40..aff6452b41e 100644 --- a/packages/platform/platform-test-sdk/src/tests/testCache.ts +++ b/packages/platform/platform-test-sdk/src/tests/testCache.ts @@ -143,7 +143,6 @@ export function testCache(options: PlatformTestingSdkOpts) { const response2 = await request.get("/rest/caches/scenario-1").set("cache-control", "no-cache").expect(200); expect(response2.text).toContain("hello world"); - expect(response2.headers["cache-control"]).toMatch(/max-age=300/); expect(response2.headers["x-cached"]).toBeUndefined(); expect(response2.headers["etag"]).toEqual(response2.headers["etag"]); });