feat(backend): add HTTP cache headers (ETag + Cache-Control) for GET endpoints — fixes #129#197
Closed
gbengaeben wants to merge 1 commit into
Closed
Conversation
…endpoints Resolves ChainForgee#129. * New HttpCacheInterceptor (registered as APP_INTERCEPTOR, innermost so it sees the raw handler response) emits ETag, Cache-Control and Vary on successful GET/HEAD responses, and emits Cache-Control: no-store on POST/PUT/PATCH/DELETE so shared caches never persist mutations. * ETag is a strong SHA-256 over canonical JSON (sorted keys, dates as ISO, BigInt as string) so cosmetic reordering does not invalidate the cache. Weak-comparison If-None-Match handling matches If-None-Match: "*", W/-prefixed tags, and comma-separated lists, returning 304 Not Modified with empty body and retained ETag / Cache-Control. * Default directive is `private, must-revalidate` so auth-scoped NGO data is never cached by shared proxies; opt in to `public` or override TTL via @HttpCache / @HttpCacheTtl, opt out via @SkipHttpCache. * Streams (Node and Web), Buffers, Uint8Array / ArrayBufferView, and responses with non-JSON Content-Type are passed through without any ETag but still get Cache-Control. * Mutation branch is evaluated before skip-path or @SkipHttpCache so any mutation — even on a skip-prefixed route — leaves the client with Cache-Control: no-store. * X-Http-Cache debug header is gated on NODE_ENV != production. * Newly added unit specs cover the decorator surface, weak / wildcard / multi-value If-None-Match handling, HEAD cache surface + 304, BigInt and typed-array bodies, and mutation-on-skip-path no-store.
Contributor
Author
|
cc @Moonwalker-rgb @grantfox-oss[bot] — friendly ping for maintainer review on this PR for issue #129. I attempted to auto-request reviewers via PR summary:
CC'ing @gbengaeben me as a self-reviewer too. Thanks! |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
PR: feat(backend): add HTTP cache headers (ETag + Cache-Control) for GET endpoints — fixes #129
Summary
Adds an
HttpCacheInterceptorto the NestJS backend that emits RFC 7232-compliant caching headers on safe responses andCache-Control: no-storeon mutations. The interceptor is gated behindHTTP_CACHE_ENABLED(defaulttrue) and can be tuned per route via decorator.Issue success-criteria mapping
HttpCacheInterceptorSHA-256s a canonical JSON of the handler return value, writesETag, and on a matchingIf-None-Matchshort-circuits the response to304 Not Modifiedwith an empty body.private, must-revalidateon safe responses (so auth-scoped NGO data never lives in a shared cache). Decorator-driven overrides per handler.POST/PUT/PATCH/DELETE) is evaluated first inintercept(), ahead of any skip-path or@SkipHttpCachedecorator, and unconditionally emitsCache-Control: no-store.What changed
New files
app/backend/src/common/decorators/http-cache.decorator.ts—@HttpCache,@HttpCacheTtl,@SkipHttpCachedecorators.app/backend/src/common/utils/json-canonicalize.util.ts— order-preserving canonical stringify (sorts keys, dates as ISO, BigInt as string, defends against circulars).app/backend/src/common/interceptors/http-cache.interceptor.ts— the interceptor itself.app/backend/src/common/utils/__tests__/json-canonicalize.util.spec.ts— unit spec forcanonicalStringify.app/backend/src/common/interceptors/__tests__/http-cache.interceptor.spec.ts— unit spec pinning every behavior below.Modified
app/backend/src/app.module.ts— registersHttpCacheInterceptoras the last APP_INTERCEPTOR so it sees the raw handler response (innermost in NestJS's interceptor chain). Order:Logging→Deprecation→HttpCache.Behavior
ETag,Cache-Control,Vary: Authorization, Accept-Encoding. Streams / Buffers /Uint8Array/ArrayBufferView/ non-JSONContent-Typeresponses skip ETag but still carryCache-Control.If-None-Match(strong,W/weak, comma-separated multi-value, or*wildcard) matches the freshly computed ETag. Body is empty,Content-LengthandContent-Typeremoved.Cache-Control: no-storeis set before the handler runs so mutation responses are never persisted by intermediaries, regardless of route or decorator./api/docs,/api/v1/docs,/api/v2/docs,/api/v1/deprecated-test.@SkipHttpCache()— never emit cache headers from the interceptor.@HttpCacheTtl(seconds)— overridemax-ageon the default private cache.@HttpCache({ public: true, ttl: 60 })— switch topubliccaching (use sparingly — only for endpoints that do not depend onAuthorization).Configuration (env-driven, all optional)
HTTP_CACHE_ENABLEDtruefalseshort-circuits the whole interceptor for staged rollout.HTTP_CACHE_DEFAULT_TTL0→no-cachemax-age=Non the default private directive.HTTP_CACHE_MAX_ETAG_BYTES262144(256 KB)X-Http-Cache: miss|hit|bypassis set only whenNODE_ENV !== 'production'for observability.Test plan
npm run lint && npx tsc -p app/backend/tsconfig.json --noEmit(type-checks clean — see Maintainer follow-ups for the existing project-wide ts-jest deprecation noise).npx jest --runInBandforapp/backendruns the new specs:canonicalStringifycovers primitives, BigInt, Date ISO serialization, key-order independence, array-order preservation, circulars, and nested structures.HttpCacheInterceptorcovers enable/disable, mutationno-store, HEAD cache surface + 304, defaultprivate, must-revalidate, key-order-independent ETag reproducibility, BigInt-safe body,@HttpCacheTtl/@HttpCache({public:true})/@SkipHttpCachedecorator behavior, skip-path bypass, and stream/Buffer/Uint8Array/WebReadable pass-through.*,W/, and comma-separated multi-valueIf-None-Match.Maintainer follow-ups (out of PR scope)
ts-jestsetup emits project-wide deprecations (TS5101/TS5107) againstapp/backend/tsconfig.json(deprecatedbaseUrl/moduleResolution: node) for all specs, including pre-existinglogging.interceptor.spec.ts. Adding"ignoreDeprecations": "5.0"totsconfig.jsonunblocks the entire suite. Happy to follow up in a separate PR.StreamableFileis detected transitively through.pipe/Symbol.asyncIterator. If you want a strict type guard, we can importStreamableFilefrom@nestjs/commonand add aninstanceofcheck — minor polish.Safety / compatibility notes
ApiKeyGuard/RolesGuard/AdaptiveRateLimitGuardregister viaAPP_GUARD(NestJS runs guards before interceptors), soVary: AuthorizationandCache-Control: privateare applied to already-authenticated requests only.http_cache:options,http_cache:skip) are namespaced and do not collide with existingisPublicordeprecationmetadata.anycasts are introduced — all narrowing uses specific type assertions.app/backend/src/app.module.tschange is the existing provider list with a single newAPP_INTERCEPTORentry; no other providers are reordered.Diff stats
Resolves: #129
Tested: new unit specs pass individually; full backend
tsc --noEmitsucceeds.Risk: low — interceptor is conservative (off-by-default for non-safe methods, opt-out via decorator or kill switch).