Skip to content

feat(backend): add HTTP cache headers (ETag + Cache-Control) for GET endpoints — fixes #129#197

Closed
gbengaeben wants to merge 1 commit into
ChainForgee:mainfrom
gbengaeben:fix/issue-129-http-cache-headers
Closed

feat(backend): add HTTP cache headers (ETag + Cache-Control) for GET endpoints — fixes #129#197
gbengaeben wants to merge 1 commit into
ChainForgee:mainfrom
gbengaeben:fix/issue-129-http-cache-headers

Conversation

@gbengaeben

Copy link
Copy Markdown
Contributor

PR: feat(backend): add HTTP cache headers (ETag + Cache-Control) for GET endpoints — fixes #129

Summary

Adds an HttpCacheInterceptor to the NestJS backend that emits RFC 7232-compliant caching headers on safe responses and Cache-Control: no-store on mutations. The interceptor is gated behind HTTP_CACHE_ENABLED (default true) and can be tuned per route via decorator.

Issue success-criteria mapping

Issue #129 success condition How this PR satisfies it
ETag caching on GET endpoints HttpCacheInterceptor SHA-256s a canonical JSON of the handler return value, writes ETag, and on a matching If-None-Match short-circuits the response to 304 Not Modified with an empty body.
Cache-Control headers set Default private, must-revalidate on safe responses (so auth-scoped NGO data never lives in a shared cache). Decorator-driven overrides per handler.
Cache invalidation on mutations The mutation branch (POST/PUT/PATCH/DELETE) is evaluated first in intercept(), ahead of any skip-path or @SkipHttpCache decorator, and unconditionally emits Cache-Control: no-store.

What changed

New files

  • app/backend/src/common/decorators/http-cache.decorator.ts@HttpCache, @HttpCacheTtl, @SkipHttpCache decorators.
  • 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 for canonicalStringify.
  • app/backend/src/common/interceptors/__tests__/http-cache.interceptor.spec.ts — unit spec pinning every behavior below.

Modified

  • app/backend/src/app.module.ts — registers HttpCacheInterceptor as the last APP_INTERCEPTOR so it sees the raw handler response (innermost in NestJS's interceptor chain). Order: LoggingDeprecationHttpCache.

Behavior

  • GET / HEAD → emits ETag, Cache-Control, Vary: Authorization, Accept-Encoding. Streams / Buffers / Uint8Array / ArrayBufferView / non-JSON Content-Type responses skip ETag but still carry Cache-Control.
  • 304 Not Modified → returned when If-None-Match (strong, W/ weak, comma-separated multi-value, or * wildcard) matches the freshly computed ETag. Body is empty, Content-Length and Content-Type removed.
  • POST / PUT / PATCH / DELETECache-Control: no-store is set before the handler runs so mutation responses are never persisted by intermediaries, regardless of route or decorator.
  • Always-skipped paths: /api/docs, /api/v1/docs, /api/v2/docs, /api/v1/deprecated-test.
  • Decorators:
    • @SkipHttpCache() — never emit cache headers from the interceptor.
    • @HttpCacheTtl(seconds) — override max-age on the default private cache.
    • @HttpCache({ public: true, ttl: 60 }) — switch to public caching (use sparingly — only for endpoints that do not depend on Authorization).

Configuration (env-driven, all optional)

Variable Default Notes
HTTP_CACHE_ENABLED true false short-circuits the whole interceptor for staged rollout.
HTTP_CACHE_DEFAULT_TTL 0no-cache Positive integer seconds → max-age=N on the default private directive.
HTTP_CACHE_MAX_ETAG_BYTES 262144 (256 KB) Skips ETag hashing on larger payloads.

X-Http-Cache: miss|hit|bypass is set only when NODE_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 --runInBand for app/backend runs the new specs:

  • canonicalStringify covers primitives, BigInt, Date ISO serialization, key-order independence, array-order preservation, circulars, and nested structures.
  • HttpCacheInterceptor covers enable/disable, mutation no-store, HEAD cache surface + 304, default private, must-revalidate, key-order-independent ETag reproducibility, BigInt-safe body, @HttpCacheTtl / @HttpCache({public:true}) / @SkipHttpCache decorator behavior, skip-path bypass, and stream/Buffer/Uint8Array/WebReadable pass-through.
  • 304 short-circuit covers strong-match, *, W/, and comma-separated multi-value If-None-Match.

Maintainer follow-ups (out of PR scope)

  • The project ts-jest setup emits project-wide deprecations (TS5101 / TS5107) against app/backend/tsconfig.json (deprecated baseUrl / moduleResolution: node) for all specs, including pre-existing logging.interceptor.spec.ts. Adding "ignoreDeprecations": "5.0" to tsconfig.json unblocks the entire suite. Happy to follow up in a separate PR.
  • StreamableFile is detected transitively through .pipe / Symbol.asyncIterator. If you want a strict type guard, we can import StreamableFile from @nestjs/common and add an instanceof check — minor polish.

Safety / compatibility notes

  • The interceptor runs after ApiKeyGuard / RolesGuard / AdaptiveRateLimitGuard register via APP_GUARD (NestJS runs guards before interceptors), so Vary: Authorization and Cache-Control: private are applied to already-authenticated requests only.
  • The decorator metadata keys (http_cache:options, http_cache:skip) are namespaced and do not collide with existing isPublic or deprecation metadata.
  • No any casts are introduced — all narrowing uses specific type assertions.
  • app/backend/src/app.module.ts change is the existing provider list with a single new APP_INTERCEPTOR entry; no other providers are reordered.

Diff stats

 app/backend/src/app.module.ts                                          |   6 ++
 app/backend/src/common/decorators/http-cache.decorator.ts              |  53 ++++++++
 app/backend/src/common/interceptors/__tests__/http-cache.interceptor.spec.ts | 269 ++++++++++++++++++++
 app/backend/src/common/interceptors/http-cache.interceptor.ts          | 256 ++++++++++++++++++++
 app/backend/src/common/utils/__tests__/json-canonicalize.util.spec.ts  |  54 +++++
 app/backend/src/common/utils/json-canonicalize.util.ts                 |  70 ++++++++
 6 files changed, ~700 insertions

Resolves: #129
Tested: new unit specs pass individually; full backend tsc --noEmit succeeds.
Risk: low — interceptor is conservative (off-by-default for non-safe methods, opt-out via decorator or kill switch).

…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.

Copy link
Copy Markdown
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 gh pr edit --add-reviewer and the REST POST /pulls/{n}/requested_reviewers, but the GITHUB_TOKEN integration in this environment lacks the requestReviewsByLogin / requested_reviewers scope (403 Forbidden, Resource not accessible by integration). Could one of you self-assign the review?

PR summary:

  • Adds HttpCacheInterceptor (registered last among APP_INTERCEPTORs so it sees the raw handler response) emitting strong ETags (sha256 of canonical JSON), Cache-Control defaults of private, must-revalidate, and Vary: Authorization, Accept-Encoding.
  • POST/PUT/PATCH/DELETE always get Cache-Control: no-store; mutation branch runs before any skip-path or @SkipHttpCache decorator so it cannot be bypassed.
  • On a matching If-None-Match (handles weak W/, comma-separated multi-value, and * wildcard), the interceptor returns a 304 Not Modified with an empty body.
  • New decorators: @HttpCache, @HttpCacheTtl, @SkipHttpCache.
  • New unit specs cover every behavior above plus bigint / Date / typed-array bodies, HEAD cache surface+304, and stream/Buffer/Uint8Array bypass.

CC'ing @gbengaeben me as a self-reviewer too. Thanks!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[LOW] No cache headers set on API responses

1 participant