diff --git a/docs/specs/http-caching.md b/docs/specs/http-caching.md new file mode 100644 index 000000000..30fc378c5 --- /dev/null +++ b/docs/specs/http-caching.md @@ -0,0 +1,598 @@ +# Spec: HTTP Caching Primitives in ColdBox + +**Status:** Tier 1 (event-caching-integrated) implemented — see `system/web/context/RequestContext.cfc`, `system/web/context/Response.cfc`, `system/Bootstrap.cfc`. Tier 2 (§4.3, standalone) remains unimplemented. +**Target:** ColdBox 8.3.0 (or next minor) +**Runtime:** BoxLang + CFML (Adobe, Lucee) — pure HTTP header mechanics, no BIF dependency +**Related:** ColdBox's existing Event Caching (`system/Bootstrap.cfc`, `HandlerService.cfc`); +`docs/specs/sse-streaming.md` (the annotation/interception-point conventions this spec follows) + +--- + +## 1. Motivation + +Before this work, a case-insensitive grep across `system/` for `etag`, `last-modified`, +`cache-control`, `if-none-match`, `if-modified-since`, and `304` returned **zero hits** (the lone +`"304"` string anywhere in the codebase was a status-text lookup entry, unrelated to caching). +ColdBox had no concept of HTTP-level conditional requests or cache negotiation — every response, +cached server-side or not, always sent a full `200` with a full body. + +That is a real gap for anything that talks to a browser, CDN, or reverse proxy: API resources +that rarely change, static-ish content endpoints, polling clients, HTMX partials. All of them +would benefit from being able to say "you already have this" (`Cache-Control`) or "ask me, and +I'll say 304 if nothing changed" (`ETag` / `Last-Modified`). + +### This is not Event Caching, and must not be confused with it + +ColdBox already ships a caching system that looks adjacent but solves a different problem, and +this spec's central design move is to sit *on top of* it rather than duplicate it. + +**Event Caching** (`cache="true"` on a handler action, `system/Bootstrap.cfc:243-403`) caches the +**server's own rendered output** in CacheBox so ColdBox can skip re-running the handler and +re-rendering the view on a hit. It is a *server compute* optimization. It has nothing to say to +the client — a cache hit still ships a full `200` response with the full body over the wire, +every time, forever, to every client, even one that already has last minute's identical bytes. + +**HTTP Caching** (this spec) is the *client-facing* layer: it lets the browser, a CDN, or a +reverse proxy skip the round trip entirely, or lets the server skip sending the body when the +client can prove it already has the current representation. It is orthogonal to whether the +server itself re-computed that representation. + +They compose. §4 is built entirely around the observation that Event Caching's existing +machinery — a pre-execution cache lookup, sitting in memory with the full rendered body, before +ColdBox has committed to sending it — is exactly the leverage point HTTP caching needs, and that +combining the two is nearly free. + +### Non-goals + +- **Not** a route-level cache-rules system (`cache`/`cacheTimeout`/`cacheKey` as route-struct + keys, à la Nitro's `routeRules`). That is a separate, later recommendation that would *consume* + the primitives this spec defines — it is not designed here. +- **Not** replacing or deprecating Event Caching. Every existing `cache="true"` handler keeps + working exactly as it does today if it never opts into anything this spec adds. +- **Not** CDN/reverse-proxy configuration, surrogate keys, or cache purging APIs. +- **Not** content negotiation via `Vary` on representation (gzip/br, `Accept`-based format + switching). `Vary` is mentioned only as a correctness caveat in §4.5. + +--- + +## 2. Two independent knobs + +| | Event Caching (existing) | HTTP Caching (this spec) | +|---|---|---| +| **Question it answers** | "Do I need to re-run the handler and re-render?" | "Does the *client* need to re-fetch the body?" | +| **Where it lives** | Server (CacheBox) | Client / CDN / proxy, via response headers | +| **Mechanism** | `system/Bootstrap.cfc:245-289` looks up a cache entry keyed by event + hashed RC before calling `runEvent()` | `ETag` / `Last-Modified` response headers, checked against `If-None-Match` / `If-Modified-Since` request headers | +| **On a hit today** | Skips handler execution, replays stored body — but still ships the full body | N/A (doesn't exist yet) | +| **On a hit after this spec** | Unchanged, unless `etag`/`lastModified` also opted in | Client gets `304 Not Modified`, zero-byte body | +| **Annotation** | `cache="true" cacheTimeout="30" ...` (`HandlerService.cfc:801-811`) | New sibling annotations on the *same* function, see §4.1 | + +A handler can use either, both, or neither. The deep automatic behavior in §4.2 only activates +when **both** are opted into together — that combination is where the free win is. + +--- + +## 3. Manual API surface + +The baseline, engine-agnostic primitives every automatic behavior in §4 is built from. These are +useful standalone even without any annotation. + +### 3.1 `RequestContext.cfc` + +Built entirely from methods that already exist: `setHTTPHeader( name=, value= )` +(`RequestContext.cfc:2173`), `getHTTPHeader( header, defaultValue )` (`:2144`), `noExecution()` +(`:1192`). + +```java +/** + * Sets the ETag response header and checks it against an incoming If-None-Match. + * On a match, short-circuits the request with a bare 304 and returns true. + * + * @value The entity tag value (unquoted - quoting is handled here) + * @weak Mark as a weak validator (W/"...") - use when the representation is + * semantically-but-not-byte-identical across regenerations + * + * @return True if the request was short-circuited with a 304 + */ +boolean function etag( required string value, boolean weak = false ){ + var tag = ( arguments.weak ? "W/" : "" ) & '"#arguments.value#"'; + setHTTPHeader( name="ETag", value=tag ); + + if ( getHTTPHeader( "If-None-Match", "" ) == tag ) { + noExecution(); + setHTTPHeader( statusCode=304 ); + return true; + } + return false; +} + +/** + * Sets Last-Modified and checks it against an incoming If-Modified-Since. + * + * @value HTTP-date granularity is seconds - callers with sub-second timestamps + * should round down, never up, to avoid false negatives + * + * @return True if the request was short-circuited with a 304 + */ +boolean function lastModified( required date value ){ + var httpDate = dateTimeFormat( arguments.value, "ddd, dd mmm yyyy HH:mm:ss" ) & " GMT"; + setHTTPHeader( name="Last-Modified", value=httpDate ); + + var since = getHTTPHeader( "If-Modified-Since", "" ); + if ( len( since ) && isDate( since ) && parseDateTime( since ) >= arguments.value ) { + noExecution(); + setHTTPHeader( statusCode=304 ); + return true; + } + return false; +} + +/** + * Sets Cache-Control from a directive struct. Boolean values become bare + * directives ("public", "no-cache"); others become "key=value". + * + * @directives e.g. { "public" : true, "max-age" : 60, "stale-while-revalidate" : 30 } + */ +function cacheControl( struct directives = { "no-cache" : true } ){ + setHTTPHeader( + name = "Cache-Control", + value = arguments.directives + .reduce( ( acc, key, val ) => { + acc.append( ( isBoolean( val ) && val ) ? key : "#key#=#val#" ); + return acc; + }, [] ) + .toList( ", " ) + ); + return this; +} +``` + +Both `etag()` and `lastModified()` return `boolean` rather than throwing or rendering, so a +handler stays in control of the early-return: + +```java +function show( event, rc, prc ){ + prc.product = productService.get( rc.id ); + if ( event.etag( prc.product.getHash() ) ) { + return; + } + event.setView( "products/show" ); +} +``` + +### 3.2 `Response.cfc` + +`Response.cfc` has no header-specific fluent methods today — only the generic +`addHeader( name, value )` (`:217`), used internally by `RestHandler` to accumulate headers that +get flushed via `event.setHTTPHeader()` later. Two thin fluent wrappers, matching the existing +`withStatus()`/`withData()` naming (`:409`, `:383`): + +```java +Response function withETag( required string value, boolean weak = false ){ + addHeader( "ETag", ( arguments.weak ? "W/" : "" ) & '"#arguments.value#"' ); + return this; +} + +Response function withCacheControl( struct directives = { "no-cache" : true } ){ + // same directive-assembly logic as RequestContext.cacheControl() + addHeader( "Cache-Control", ... ); + return this; +} +``` + +Note `Response.cfc` headers are buffered and only actually written by `RestHandler.aroundHandler` +at `RestHandler.cfc:157-158` — see §6 for why a 304 must happen *before* that point, not through +this buffer. + +--- + +## 4. The automatic layer + +This is the part worth building carefully, because there are two genuinely different cost +profiles hiding under one word ("automatic"), and conflating them would make a false promise +about what the framework is actually saving. + +### 4.1 Annotations extend the existing cache metadata block + +Event Caching's annotation defaults live in `getNewMDEntry()` (`HandlerService.cfc:764-776`) and +are read in `getEventCachingMetadata()` (`:801-811`): + +```java +// Existing, unchanged +cache : false, +cacheTimeout : "", +cacheLastAccessTimeout : "", +cacheProvider : "template", +cacheInclude : "*", +cacheExclude : "", +cacheFilter : "", +``` + +New siblings, added to the same struct and read the same way (as function-level annotations): + +```java +// New +etag : false, // boolean, or "auto" — see tiers below +etagWeak : false, // boolean +lastModified : "", // "" (off), "true" (tier-1 only), or a private-method name (tier-2, mirrors cacheFilter's closure-by-name pattern) +cacheControl : "", // raw Cache-Control value, e.g. "public, max-age=60" +``` + +Declaration is unchanged CFML annotation-on-function syntax, identical in spirit to how +`cache`/`cacheTimeout` already read today: + +```java +function index( event, rc, prc ) + cache="true" + cacheTimeout="30" + etag="true" +{ + ... +} +``` + +### 4.2 Tier 1 — cache-integrated automatic ETag (the free win) + +This is the deep insight the rest of the section builds on: `Bootstrap.cfc:245-289` **already** +performs a pre-execution CacheBox lookup, keyed by `EventURLFacade.buildEventKey()` + +`getUniqueHash()` (`EventURLFacade.cfc:136-145`, `:44-96` — event name + module + a filtered hash +of RC params + host), *before* `runEvent()` is ever called. On a hit, the full `renderedContent`, +`statusCode`, `contentType`, and `responseHeaders` are already sitting in memory, about to be +replayed verbatim (`Bootstrap.cfc:361`). + +Piggy-backing an ETag onto that entry costs almost nothing, because the hash is computed **once, +at write time**, not on every subsequent request: + +**On cache write** (`Bootstrap.cfc:340-378`, alongside the existing `cacheBox...set(...)` call at +`:370-377`): if `etag="true"` is set on the action, compute `hash( renderedContent, "MD5" )` once +and store it as an `etag` field on the same cache entry struct that already holds +`renderedContent`/`contentType`/`statusCode`. + +**On cache hit** (`Bootstrap.cfc:245-289`, before the existing replay at `:290+`): if the stored +entry carries an `etag`, compare it against `getHTTPHeader( "If-None-Match", "" )` *before* +writing the body. + +- **Match** → skip the replay entirely. Send a bare `304` with just `ETag` (and `Cache-Control`, + if set) — no body write at all. This is strictly *cheaper* than what happens today on every + cache hit, for zero extra request-time cost, because the hash already existed. +- **No match / absent `If-None-Match`** → replay the full body exactly as today, but now also + emit the stored `ETag` header, so the *client's next* request can 304. + +``` +Request arrives + │ + ├─ eventCachingTest() (RequestService.cfc:145) says cacheable? + │ │ + │ ├─ NO → run handler + render normally (untouched by this spec) + │ │ + │ └─ YES → look up cache entry + │ │ + │ ├─ MISS → run handler + render + │ │ → on write: if etag="true", hash body once, store on entry + │ │ → send 200 + body (+ ETag header if etag="true") + │ │ + │ └─ HIT + │ ├─ etag NOT set on entry → replay body exactly as today (unchanged) + │ │ + │ └─ etag set on entry → compare If-None-Match + │ ├─ match → 304, no body (NEW — cheaper than today's replay) + │ └─ no match → replay body + ETag header (as before, now with ETag) +``` + +This only activates when `etag="true"` is *explicitly* opted into alongside `cache="true"` — +existing `cache="true"` handlers that never touch this new annotation see no behavior change at +all, on either the write or read path. + +### 4.3 Tier 2 — standalone automatic ETag (no Event Caching involved) + +Some handlers can't or shouldn't use Event Caching — output that's too per-user-specific for the +RC-hash cache key to be meaningful, or content that must always be freshly computed server-side +but still benefits from *client-side* conditional-GET. For these, `etag="true"` without +`cache="true"` computes the hash **after** rendering, on every request, and checks it against +`If-None-Match` before the body is written to the client. + +This needs a hook *after* the rendered body exists but *before* it's written to the wire. +Event Caching itself is not wired through an announced interception point — it's inline in +`Bootstrap.cfc` — so Tier 2 has the same structural choice Event Caching already made: either add +a small, explicit check inline in `Bootstrap.cfc`'s render path (mirroring how the cache-write +branch works, just without CacheBox), or introduce a new interception point +(`preResponseWrite`, firing after `renderedContent` is final and before `writeOutput()`) that a +core, conditionally-registered interceptor listens on. The latter is more consistent with how the +SSE work extended the interception-point ENUM (`InterceptorService.cfc:44-92`) rather than adding +inline branches, and is the recommended approach — left as an implementation decision, not a +design gap, since either is mechanically straightforward. + +**This tier's cost model is genuinely different, and must be documented as such**: the handler +and the full render *still run on every request* — Tier 2 saves the client a body download, but +saves the server nothing. Framework documentation and the annotation's own doc comment should say +this explicitly, so nobody enables `etag="true"` on a hot, expensive, uncached endpoint expecting +Event-Caching-level savings and is disappointed. + +### 4.4 Automatic Last-Modified + +Two sources, matching the two tiers: + +- **Tier 1 (free):** when `cache="true"` and `lastModified="true"` (boolean form) are both set, + the cache entry's write timestamp is exposed as `Last-Modified` for free. CacheBox object stores + already record a `created` timestamp on every entry as standard metadata (used for eviction + policies like `FIFO.cfc`, and retrievable via `getObjectMetadata()`) — this reuses that existing + value rather than tracking a new one, the same way §4.2 reuses a hash computed once at write + time rather than per-request. +- **Tier 2 (developer-supplied):** `lastModified="getProductModifiedDate"` names a private handler + method, mirroring the existing `cacheFilter` closure-by-name convention + (`HandlerService.cfc:824-851`), returning a `date`. Useful when the true "last changed" moment + is a database column, not "whenever this happened to render": + + ```java + function show( event, rc, prc ) + lastModified="getProductModifiedDate" + { + prc.product = productService.get( rc.id ) + event.setView( "products/show" ) + } + + private date function getProductModifiedDate( event, rc ){ + return productService.get( rc.id ).getModifiedDate() + } + ``` + + This form necessarily runs before the main action body (it needs `rc.id` to know *which* + product), so it participates in Tier 2's cost model even when combined with `cache="true"` — + unlike the boolean form, a closure-supplied `Last-Modified` cannot be deferred to cache-write + time because it depends on data the framework doesn't otherwise fetch. + +### 4.5 Automatic Cache-Control + +The simplest of the three — pure header assembly, no negotiation logic, no client round-trip +involved. If `cacheControl` is set, the framework attaches it verbatim. As a convenience default: +when `cache="true"` and `cacheTimeout` are set with no explicit `cacheControl`, default +`Cache-Control: private, max-age={cacheTimeout in seconds}` — "you already told me how long to +keep this server-side; telling the client the same number by default is a reasonable inference, +always overridable by setting `cacheControl` explicitly." + +**Correctness caveat, not optional:** any response whose `Cache-Control`/`ETag` genuinely differs +per requester (auth state, locale, `Accept`-negotiated format) must either use `private` rather +than `public`, or set `Vary` accordingly. This spec does not attempt to infer that automatically — +`private` is the conservative default in the auto-derivation above precisely to avoid a framework +default ever causing a cross-user cache leak. `public` is opt-in only. + +### 4.6 Annotation reference + +| Annotation | Type | Default | Tier | Requires | +|---|---|---|---|---| +| `etag` | `boolean` | `false` | 1 if paired with `cache="true"`, else 2 | — | +| `etagWeak` | `boolean` | `false` | — | `etag="true"` | +| `lastModified` | `boolean` \| method name | `""` | 1 (boolean form + `cache`) or 2 (method-name form) | — | +| `cacheControl` | `string` | `""` (falls back to the §4.5 default when `cache`+`cacheTimeout` set) | — | — | + +### 4.7 Settings block + +**Tier 1 needs no settings block of its own.** Every one of its annotations +(`etag`/`etagWeak`/`lastModified`/`cacheControl`) is only ever read inside the +same `getEventCachingMetadata()` branch that already requires `cache="true"` +*and* the existing global `this.coldbox.eventCaching` switch +(`Settings.cfc:35`) to be `true` (`HandlerService.cfc:186-190`). A separate +`this.httpCaching.enabled` toggle was drafted and then removed during +implementation - it could never independently disable anything the existing +`eventCaching` switch didn't already disable, since Tier 1 has no code path +that runs without both. Per-handler, simply not setting the annotations is +already the finest-grained control there is. + +A settings block **would** earn its place once Tier 2 (§4.3) is implemented, +since that tier runs independently of `cache="true"`/`eventCaching` entirely +and genuinely needs its own opt-in: + +```java +this.httpCaching = { + // Tier 2 only: enable an ETag automatically for every rendered GET/HEAD + // response that doesn't otherwise set an etag annotation. Off by default - + // this changes response bytes for every endpoint in the app. + "autoETag" : false, + "defaultCacheControl" : "private, no-cache", + "weakETagsByDefault" : false +}; +``` + +--- + +## 5. Route-level equivalent + +Out of scope for this spec's implementation, but the seam is worth naming: a future route-level +cache-rules feature (route-struct `cache`/`cacheTimeout`/`cacheKey`, à la Nitro's `routeRules`) +would declare `etag`/`lastModified`/`cacheControl` as route-struct keys the same way `sse` and +`sseCallback` were added to `initRouteDefinition()` — and would need those keys declared as +`addRoute()` parameters too, per the drift class fixed in `Router.cfc` (`ai`/`mcp`/`sse` all hit +this same footgun; see the `getRouteDefinitionKeys()` guard test added specifically to catch it +happening again). + +--- + +## 6. Interaction with `RestHandler` + +The same integration hazard class the SSE spec found (`docs/specs/sse-streaming.md §6`) applies +here, for the same underlying reason: `RestHandler.aroundHandler` (`RestHandler.cfc:40`) +unconditionally calls `event.renderData(...)` at `:142-150` whenever the action set no view, no +render data, and returned nothing — which describes a 304 short-circuit just as well as it +describes a stream. + +A `304` must happen **before** `aroundHandler` reaches that render step, not through the +`Response` object's buffered headers (§3.2), since those are only flushed *after* the render call. +The pattern: + +```java +function show( event, rc, prc ){ + prc.product = productService.get( rc.id ) + if ( event.etag( prc.product.getHash() ) ) { + return // noExecution() + 304 already set — aroundHandler must not marshal a body + } + prc.response.setData( prc.product ) +} +``` + +`event.etag()`/`event.lastModified()` already call `noExecution()` (§3.1), and +`RestHandler.aroundHandler` already has an `isSSE()`-style guard point (added by the SSE work, +`RestHandler.cfc` immediately after the response timer) that is the natural place to add a +parallel check. `isNoExecution` today is only a `property` (`RequestContext.cfc:38`) — the +accessor-generated getter is `getIsNoExecution()`, not a bare boolean predicate — so this spec +needs to **add** a small `isNoExecution()` method (mirroring `isSSE()`'s own existing shape) +rather than reuse something that already exists in that form: + +```java +// RequestContext.cfc — new +boolean function isNoExecution(){ + return variables.isNoExecution; +} +``` + +```java +// RestHandler.cfc — end timer +arguments.prc.response.setResponseTime( getTickCount() - stime ) + +// A 304 (or an SSE stream) has already committed the response - no marshalling. +if ( arguments.event.isSSE() || arguments.event.isNoExecution() ) { + return +} +``` + +--- + +## 7. Examples + +### 7.1 Manual ETag, plain handler + +```java +function show( event, rc, prc ){ + prc.product = productService.get( rc.id ) + if ( event.etag( prc.product.getHash() ) ) { + return + } + event.setView( "products/show" ) +} +``` + +### 7.2 Tier 1 — Event Caching + automatic ETag, for free + +```java +function index( event, rc, prc ) + cache="true" + cacheTimeout="300" + etag="true" +{ + prc.products = productService.list() + event.setView( "products/index" ) +} +``` + +First request: cache miss, handler runs, body hashed once at write time, `ETag` sent. +Every subsequent request within the 300s window: cache hit, hash comparison only — no handler +execution, no render, and (on a match) no body write either. + +### 7.3 Tier 2 — automatic Last-Modified via closure, no Event Caching + +```java +function show( event, rc, prc ) + lastModified="getArticleModifiedDate" +{ + prc.article = articleService.get( rc.id ) + event.setView( "articles/show" ) +} + +private date function getArticleModifiedDate( event, rc ){ + return articleService.get( rc.id ).getModifiedDate() +} +``` + +### 7.4 REST resource with conditional GET + +```java +component extends="coldbox.system.RestHandler" { + + function show( event, rc, prc ){ + prc.order = orderService.get( rc.id ) + if ( event.etag( prc.order.getVersion() ) ) { + return + } + prc.response.setData( prc.order ) + } + +} +``` + +### 7.5 App-wide Tier 2 opt-in + +```java +// config/Coldbox.cfc +this.httpCaching = { + "enabled" : true, + "autoETag" : true // every rendered GET/HEAD response gets a computed ETag, + // no per-handler annotation required +}; +``` + +--- + +## 8. Implementation notes + +### Files touched (anticipated) + +| File | Change | +|---|---| +| `system/web/context/RequestContext.cfc` | Add `etag()`, `lastModified()`, `cacheControl()` | +| `system/web/context/Response.cfc` | Add `withETag()`, `withCacheControl()` | +| `system/web/services/HandlerService.cfc` | Extend `getNewMDEntry()` defaults (`:764-776`) and `getEventCachingMetadata()` (`:801-811`) with the new annotations | +| `system/Bootstrap.cfc` | Extend the cache-write branch (`:340-378`) to compute+store the hash/timestamp when opted in; extend the cache-hit branch (`:245-289`) to check `If-None-Match`/`If-Modified-Since` before replay | +| `system/web/services/InterceptorService.cfc` | (If the interception-point approach is chosen for Tier 2) add `preResponseWrite` to the ENUM | +| `system/web/config/Settings.cfc` | Add `this.httpCaching` defaults block - **Tier 2 only**, see §4.7 | +| `system/web/config/ApplicationLoader.cfc` | Add `parseHTTPCaching()` to the parser chain - **Tier 2 only**, see §4.7 | +| `system/RestHandler.cfc` | Extend the existing `isSSE()` guard clause in `aroundHandler` to also check a new `isNoExecution()` predicate | +| `system/web/context/RequestContext.cfc` (guard addition) | Add `isNoExecution()` — `isNoExecution` is currently only a `property`, with no bare boolean-predicate accessor | + +### Safe-methods guard + +Both tiers, and the manual primitives, must refuse to apply to unsafe HTTP methods. A `304` (or +any cache-control guidance) on a `POST`/`PUT`/`PATCH`/`DELETE` is a specification violation and a +correctness hazard. `etag()`/`lastModified()` should check `event.getHTTPMethod()` (or equivalent) +internally and no-op (never short-circuit) on unsafe methods, regardless of annotation state — +this is a hard rule, not a configurable default. + +--- + +## 9. Testing strategy + +Following the pattern established for SSE (`tests/specs/web/context/RequestContextSSETest.cfc` +et al.), but with **no BoxLang gate** — this feature is pure HTTP header logic with no runtime +dependency, so specs run on every engine in the matrix. + +- **`RequestContextHTTPCachingTest.cfc`** — `etag()`/`lastModified()`/`cacheControl()` against a + mocked request context, covering: match → 304 + `noExecution()`; no-match → header set, request + proceeds; weak vs strong tag formatting; absent conditional header behaves as no-match; unsafe + HTTP methods never short-circuit. +- **Bootstrap-level integration test** — a `cache="true" etag="true"` handler, asserting: first + request executes and stores a hash; second identical request (no `If-None-Match`) still replays + the body but now carries `ETag`; third request with a matching `If-None-Match` gets a `304` with + an empty body and the handler does not re-execute (assert via a call-count spy on the handler, + matching the existing Event Caching test suite's approach). +- **`RestHandlerTest.cfc`** — extend to cover the `isNoExecution()` guard in `aroundHandler`, + mirroring the existing `isSSE()` coverage. + +--- + +## 10. Open questions + +- **Interception point vs. inline `Bootstrap.cfc` check for Tier 2** (§4.3) — leaning inline, for + consistency with how Event Caching itself is implemented, but a new `preResponseWrite` point + would be more consistent with how *this session's* SSE work extended the ENUM. Worth deciding + before implementation, not during it. +- **Hash algorithm for auto-ETag** — `MD5` is fast and collision-irrelevant for cache validation + (not a security context), but should this be configurable (`this.httpCaching.hashAlgorithm`) for + shops with a compliance policy against MD5 anywhere in the codebase, even non-cryptographic + uses? +- **`cacheControl` as a struct vs. raw string annotation** — §4.1's table declares it as a raw + string for simplicity of annotation syntax (CFML function annotations are string-valued). A + friendlier `cachePublic`/`cacheMaxAge`/`cacheSWR` multi-annotation alternative was considered + and rejected for the *annotation* surface (too many new keys) but might still be worth offering + on the `RequestContext.cacheControl()`/`Response.withCacheControl()` *method* surface, where a + struct argument is natural — the spec's method signatures in §3 already do this. +- **Does `autoETag=true` (global Tier 2) apply to `renderData()`/JSON responses, or only + view-rendered HTML?** Leaning "both — anything with a final response body," but JSON responses + from REST resources may already carry their own `Response`-level caching guidance (§7.4) that + should take precedence over a blanket global default. diff --git a/system/Bootstrap.cfc b/system/Bootstrap.cfc index 4332fdf18..70ce1bace 100644 --- a/system/Bootstrap.cfc +++ b/system/Bootstrap.cfc @@ -266,26 +266,53 @@ component serializable="false" accessors="true" { event.setHTTPHeader( name = key, value = value ); } ); - // Cached Status Code + // ****** HTTP CACHING - TIER 1 conditional-GET (docs/specs/http-caching.md §4.2) ****** + // Replay whatever conditional-GET headers were stored alongside this entry, reusing + // event.etag()/event.lastModified() for the actual header-set + match logic rather + // than re-implementing it here - same matching rules (weak comparison, If-None-Match + // lists/`*`, the If-Modified-Since-is-ignored-when-If-None-Match-is-present + // precedence) whether the tag was just computed or is being replayed from cache. + var cachedNotModified = false; + if ( structKeyExists( local.refResults.eventCaching, "etag" ) ) { + cachedNotModified = event.etag( + value = local.refResults.eventCaching.etag, + weak = local.refResults.eventCaching.etagWeak ?: false + ); + } + if ( structKeyExists( local.refResults.eventCaching, "lastModified" ) ) { + cachedNotModified = event.lastModified( local.refResults.eventCaching.lastModified ) || cachedNotModified; + } + if ( structKeyExists( local.refResults.eventCaching, "cacheControl" ) ) { + event.setHTTPHeader( + name = "Cache-Control", + value = local.refResults.eventCaching.cacheControl + ); + } + + // Cached Status Code - a conditional-GET match already set 304 via etag()/lastModified() above. if ( + !cachedNotModified && isNumeric( local.refResults.eventCaching.statusCode ) && local.refResults.eventCaching.statusCode > 0 ) { event.setHTTPHeader( statusCode = local.refResults.eventCaching.statusCode ); } - // Render Content as binary or just output - if ( local.refResults.eventCaching.isBinary ) { - cbController - .getDataMarshaller() - .renderContent( - type = "#local.refResults.eventCaching.contentType#", - variable = "#local.refResults.eventCaching.renderedContent#" - ); - } else { - cbController - .getDataMarshaller() - .renderContent( type = "#local.refResults.eventCaching.contentType#", reset = true ); - writeOutput( local.refResults.eventCaching.renderedContent ); + // Render Content as binary or just output - skipped entirely on a conditional-GET + // match, which is the whole point: no body write at all, not even a replay. + if ( !cachedNotModified ) { + if ( local.refResults.eventCaching.isBinary ) { + cbController + .getDataMarshaller() + .renderContent( + type = "#local.refResults.eventCaching.contentType#", + variable = "#local.refResults.eventCaching.renderedContent#" + ); + } else { + cbController + .getDataMarshaller() + .renderContent( type = "#local.refResults.eventCaching.contentType#", reset = true ); + writeOutput( local.refResults.eventCaching.renderedContent ); + } } } else { // ****** EXECUTE MAIN EVENT *******/ @@ -361,6 +388,43 @@ component serializable="false" accessors="true" { responseHeaders : event.getResponseHeaders() }; + // ****** HTTP CACHING - TIER 1 (docs/specs/http-caching.md §4.2/§4.4) ****** + // Opt-in via etag/lastModified/cacheControl annotations alongside cache=true. + // Computed once, right here at write time, and stored on the entry so every + // subsequent cache hit can compare against it for free - no per-request hashing. + if ( eCacheEntry.etag ) { + cacheEntry.etag = hash( renderedContent, "MD5" ); + cacheEntry.etagWeak = eCacheEntry.etagWeak; + event.setHTTPHeader( + name = "ETag", + value = ( eCacheEntry.etagWeak ? "W/" : "" ) & """#cacheEntry.etag#""" + ); + } + if ( eCacheEntry.lastModified ) { + cacheEntry.lastModified = now(); + event.setHTTPHeader( + name = "Last-Modified", + value = event.toHTTPDate( cacheEntry.lastModified ) + ); + } + if ( len( eCacheEntry.cacheControl ) ) { + cacheEntry.cacheControl = eCacheEntry.cacheControl; + } else if ( + ( eCacheEntry.etag || eCacheEntry.lastModified ) && + isNumeric( eCacheEntry.timeout ) + ) { + // No explicit directive, but the handler opted into conditional-GET + // support - default to telling the client the same lifetime the + // handler already told CacheBox (in minutes; Cache-Control wants + // seconds), rather than saying nothing at all. A blank cacheTimeout + // means "use the provider's default", which we can't translate to a + // max-age, so no default is inferred in that case. + cacheEntry.cacheControl = "private, max-age=#eCacheEntry.timeout * 60#"; + } + if ( structKeyExists( cacheEntry, "cacheControl" ) ) { + event.setHTTPHeader( name = "Cache-Control", value = cacheEntry.cacheControl ); + } + // is this a render data entry? If So, append data if ( !renderData.isEmpty() ) { structAppend( cacheEntry, renderData, true ); diff --git a/system/RestHandler.cfc b/system/RestHandler.cfc index 1a4d66dd0..713f592a8 100644 --- a/system/RestHandler.cfc +++ b/system/RestHandler.cfc @@ -114,9 +114,11 @@ component extends="EventHandler" { // end timer arguments.prc.response.setResponseTime( getTickCount() - stime ); - // SSE streams have already committed the response. Both the marshalling below and the - // header flush further down would be write-after-commit, so bail out entirely. - if ( arguments.event.isSSE() ) { + // SSE streams, and a conditional-GET already resolved with event.etag()/lastModified() + // (docs/specs/http-caching.md §6), have both already committed the response - the + // marshalling below and the header flush further down would be write-after-commit + // against either, so bail out entirely. + if ( arguments.event.isSSE() || arguments.event.isNoExecution() ) { if ( !isNull( local.actionResults ) ) { return local.actionResults; } diff --git a/system/web/context/RequestContext.cfc b/system/web/context/RequestContext.cfc index a63445d68..18ff0829a 100644 --- a/system/web/context/RequestContext.cfc +++ b/system/web/context/RequestContext.cfc @@ -1832,6 +1832,203 @@ component serializable="false" accessors="true" { return structKeyExists( variables.controller, "mockController" ); } + /** + * Is this request currently flagged to skip event execution? + * + * Set by `noExecution()`. Framework guard points (e.g. `RestHandler.aroundHandler`) use this + * to avoid a write-after-commit against a response that a conditional-GET already resolved + * with a bare status code, the same way `isSSE()` guards against writing to a committed stream. + */ + boolean function isNoExecution(){ + return variables.isNoExecution; + } + + /** + * Sets the ETag response header and checks it against an incoming If-None-Match. + * + * On a match, short-circuits the request: calls `noExecution()` and responds `304` with no + * body. Never short-circuits unsafe HTTP methods (anything but GET/HEAD), regardless of + * whether the entity tags match, since a conditional-GET result has no meaning for a mutation. + * + *
+ * function show( event, rc, prc ){
+ * prc.product = productService.get( rc.id )
+ * if( event.etag( prc.product.getHash() ) ){
+ * return
+ * }
+ * event.setView( "products/show" )
+ * }
+ *
+ *
+ * @value The entity tag value. Quoting is handled here - pass the raw value.
+ * @weak Mark as a weak validator (`W/"..."`) - use for a semantically-but-not-byte-identical representation.
+ *
+ * @return True if the request was short-circuited with a 304
+ */
+ boolean function etag( required string value, boolean weak = false ){
+ var tag = ( arguments.weak ? "W/" : "" ) & """#arguments.value#""";
+ setHTTPHeader( name = "ETag", value = tag );
+
+ if ( isSafeHTTPMethod() && matchesIfNoneMatch( tag ) ) {
+ noExecution();
+ setHTTPHeader( statusCode = 304 );
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Sets the Last-Modified response header and checks it against an incoming If-Modified-Since.
+ *
+ * On a match, short-circuits the request the same way `etag()` does. HTTP-date granularity is
+ * seconds - callers with sub-second timestamps should round down, never up, to avoid a false
+ * negative (reporting the resource as modified when it was not).
+ *
+ * Per RFC 7232 §3.3, a request carrying an If-None-Match header MUST have its If-Modified-Since
+ * ignored - the entity tag is the more precise signal, so a request with both never short-circuits
+ * here, even if the date matches (call `etag()` for that comparison instead).
+ *
+ * @value The last-modified timestamp of the resource
+ *
+ * @return True if the request was short-circuited with a 304
+ */
+ boolean function lastModified( required date value ){
+ setHTTPHeader( name = "Last-Modified", value = toHTTPDate( arguments.value ) );
+
+ var since = getHTTPHeader( "If-Modified-Since", "" );
+ if (
+ isSafeHTTPMethod() &&
+ !len( getHTTPHeader( "If-None-Match", "" ) ) &&
+ len( since ) &&
+ isDate( since ) &&
+ parseDateTime( since ) >= arguments.value
+ ) {
+ noExecution();
+ setHTTPHeader( statusCode = 304 );
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Sets the Cache-Control response header from a directive struct.
+ *
+ * Boolean `true` values become bare directives (`"public"`, `"no-cache"`); any other value
+ * becomes `"key=value"`.
+ *
+ * @directives e.g. `{ "public" : true, "max-age" : 60, "stale-while-revalidate" : 30 }`
+ *
+ * @return RequestContext
+ */
+ function cacheControl( struct directives = { "no-cache" : true } ){
+ setHTTPHeader(
+ name = "Cache-Control",
+ value = arguments.directives
+ .reduce( ( acc, key, val ) => {
+ // isBoolean() is loosely true for any castable value (isBoolean(60) is true in
+ // CFML/BoxLang), so numerics must be excluded explicitly or a directive like
+ // max-age=60 silently loses its value and becomes the bare token "max-age".
+ acc.append( ( isBoolean( val ) && !isNumeric( val ) && val ) ? key : "#key#=#val#" );
+ return acc;
+ }, [] )
+ .toList( ", " )
+ );
+ return this;
+ }
+
+ /**
+ * Is the current request's HTTP method safe to answer with a conditional-GET short-circuit?
+ *
+ * Only GET and HEAD are safe - a 304 in response to a POST/PUT/PATCH/DELETE would be a
+ * specification violation and a correctness hazard, so `etag()`/`lastModified()` refuse to
+ * short-circuit anything else regardless of whether the entity tags/dates match.
+ */
+ private boolean function isSafeHTTPMethod(){
+ return listFindNoCase( "GET,HEAD", getHTTPMethod() ) > 0;
+ }
+
+ /**
+ * Checks a fully-quoted (and, if weak, `W/`-prefixed) entity tag against the incoming
+ * If-None-Match header, per RFC 7232 §3.2/§2.3.2:
+ * - `*` always matches - a GET/HEAD that reached this point has *some* current representation,
+ * which is all `If-None-Match: *` asks about.
+ * - The header may be a comma-separated list of entity tags; a match against any one counts.
+ * - If-None-Match always uses *weak* comparison, so the `W/` prefix is stripped from both sides
+ * before comparing - a weak and a strong tag with the same opaque value are still a match.
+ *
+ * Splits on a bare comma rather than a quoted-string-aware parser - sufficient for the opaque
+ * hash-style values this framework generates and accepts, which never contain a literal comma.
+ *
+ * @tag The tag to check for a match
+ */
+ private boolean function matchesIfNoneMatch( required string tag ){
+ var header = trim( getHTTPHeader( "If-None-Match", "" ) );
+ if ( !len( header ) ) {
+ return false;
+ }
+ if ( header == "*" ) {
+ return true;
+ }
+
+ var normalizedTag = reReplace( arguments.tag, "^W/", "" );
+ for ( var candidate in listToArray( header, "," ) ) {
+ if ( reReplace( trim( candidate ), "^W/", "" ) == normalizedTag ) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Format a date as an RFC 7231 HTTP-date (e.g. `Sun, 06 Nov 1994 08:49:37 GMT`), for use in
+ * `Last-Modified`, `Expires` and similar headers.
+ *
+ * Built from individual date parts rather than a `dateTimeFormat()` mask: CFML's classic mask
+ * letters ("ddd" for an abbreviated weekday name) and Java's `DateTimeFormatter` pattern
+ * letters ("EEE" for the same thing) are not the same dialect, and which one a given engine's
+ * `dateTimeFormat()` actually implements is not something to gamble on in framework code that
+ * has to run identically on BoxLang, Lucee and Adobe.
+ *
+ * `now()` and date literals) - converted to UTC internally so the trailing "GMT" is accurate
+ * regardless of the server's own timezone.
+ *
+ * @value The date/time to format, as a local server-time value (the CFML/BoxLang default for
+ */
+ string function toHTTPDate( required date value ){
+ var utcValue = dateConvert( "local2utc", arguments.value );
+ var dayNames = [
+ "Sun",
+ "Mon",
+ "Tue",
+ "Wed",
+ "Thu",
+ "Fri",
+ "Sat"
+ ];
+ var monthNames = [
+ "Jan",
+ "Feb",
+ "Mar",
+ "Apr",
+ "May",
+ "Jun",
+ "Jul",
+ "Aug",
+ "Sep",
+ "Oct",
+ "Nov",
+ "Dec"
+ ];
+
+ return dayNames[ dayOfWeek( utcValue ) ] & ", " &
+ numberFormat( day( utcValue ), "00" ) & " " &
+ monthNames[ month( utcValue ) ] & " " &
+ year( utcValue ) & " " &
+ numberFormat( hour( utcValue ), "00" ) & ":" &
+ numberFormat( minute( utcValue ), "00" ) & ":" &
+ numberFormat( second( utcValue ), "00" ) & " GMT";
+ }
+
/**
* Get the routed structure of key-value pairs. What the ses interceptor could match.
*
diff --git a/system/web/context/Response.cfc b/system/web/context/Response.cfc
index 329807f28..da1bf12d4 100644
--- a/system/web/context/Response.cfc
+++ b/system/web/context/Response.cfc
@@ -299,6 +299,43 @@ component accessors="true" {
return this
}
+ /**
+ * Sets the ETag response header
+ *
+ * @value The entity tag value. Quoting is handled here - pass the raw value.
+ * @weak Mark as a weak validator (`W/"..."`)
+ *
+ * @return Returns the Response object for chaining
+ */
+ Response function withETag( required string value, boolean weak = false ){
+ return setHeader( "ETag", ( arguments.weak ? "W/" : "" ) & """#arguments.value#""" )
+ }
+
+ /**
+ * Sets the Cache-Control response header from a directive struct
+ *
+ * Boolean `true` values become bare directives (`"public"`, `"no-cache"`); any other value
+ * becomes `"key=value"`.
+ *
+ * @directives e.g. `{ "public" : true, "max-age" : 60, "stale-while-revalidate" : 30 }`
+ *
+ * @return Returns the Response object for chaining
+ */
+ Response function withCacheControl( struct directives = { "no-cache" : true } ){
+ return setHeader(
+ "Cache-Control",
+ arguments.directives
+ .reduce( ( acc, key, val ) => {
+ // isBoolean() is loosely true for any castable value (isBoolean(60) is true in
+ // CFML/BoxLang), so numerics must be excluded explicitly or a directive like
+ // max-age=60 silently loses its value and becomes the bare token "max-age".
+ acc.append( ( isBoolean( val ) && !isNumeric( val ) && val ) ? key : "#key#=#val#" )
+ return acc
+ }, [] )
+ .toList( ", " )
+ )
+ }
+
/**
* Set the pagination data
*
diff --git a/system/web/services/HandlerService.cfc b/system/web/services/HandlerService.cfc
index 86ad886d2..f4ba338eb 100644
--- a/system/web/services/HandlerService.cfc
+++ b/system/web/services/HandlerService.cfc
@@ -771,7 +771,13 @@ component extends="coldbox.system.web.services.BaseService" accessors="true" {
"provider" : "template",
"cacheInclude" : "*",
"cacheExclude" : "",
- "cacheFilter" : ""
+ "cacheFilter" : "",
+ // HTTP caching (docs/specs/http-caching.md) - "free" Tier 1 auto ETag/Last-Modified,
+ // piggybacked on this same cache entry by Bootstrap.cfc, only when cache=true
+ "etag" : false,
+ "etagWeak" : false,
+ "lastModified" : false,
+ "cacheControl" : ""
}
}
@@ -810,6 +816,18 @@ component extends="coldbox.system.web.services.BaseService" accessors="true" {
mdEntry.cacheExclude = arguments.ehBean.getActionMetadata( "cacheExclude", "" );
mdEntry.cacheFilter = arguments.ehBean.getActionMetadata( "cacheFilter", "" );
+ // HTTP caching (docs/specs/http-caching.md §4) - Tier 1 only: an ETag
+ // and/or Last-Modified computed once at cache-write time, reused on every
+ // hit until the entry expires. Deliberately opt-in, so an existing
+ // cache="true" handler that never sets these sees no behavior change. No
+ // separate on/off switch: this whole block already only runs when
+ // eventCaching is enabled, same as cacheInclude/cacheExclude/cacheFilter
+ // above.
+ mdEntry.etag = arguments.ehBean.getActionMetadata( "etag", false );
+ mdEntry.etagWeak = arguments.ehBean.getActionMetadata( "etagWeak", false );
+ mdEntry.lastModified = arguments.ehBean.getActionMetadata( "lastModified", false );
+ mdEntry.cacheControl = arguments.ehBean.getActionMetadata( "cacheControl", "" );
+
// Handler Event Cache Key Suffix, this is global to the event
if (
isClosure( arguments.oEventHandler.EVENT_CACHE_SUFFIX ) ||
diff --git a/test-harness/handlers/eventcaching.cfc b/test-harness/handlers/eventcaching.cfc
index 9d9bc3fba..200af6ec1 100644
--- a/test-harness/handlers/eventcaching.cfc
+++ b/test-harness/handlers/eventcaching.cfc
@@ -259,6 +259,32 @@
}
+ // HTTP Caching - Tier 1 (docs/specs/http-caching.md §4.2) - ETag computed once at
+ // cache-write time and reused on every hit
+ function withETag( event, rc, prc )
+ cache="true"
+ cacheTimeout="10"
+ etag="true"
+ {
+ prc.data = [
+ { id : "static-1", name : "luis" },
+ { id : "static-2", name : "lucas" }
+ ];
+
+ return prc.data;
+ }
+
+ // HTTP Caching - Tier 1 Last-Modified variant
+ function withLastModified( event, rc, prc )
+ cache="true"
+ cacheTimeout="10"
+ lastModified="true"
+ {
+ prc.data = [ { id : "static-1", name : "luis" } ];
+
+ return prc.data;
+ }
+
function cacheKeys( event, rc, prc ){
var keys = {
"template" : getCache( "template" ).getKeys(),
diff --git a/tests/specs/RestHandlerTest.cfc b/tests/specs/RestHandlerTest.cfc
index 15804082a..68d7e8701 100644
--- a/tests/specs/RestHandlerTest.cfc
+++ b/tests/specs/RestHandlerTest.cfc
@@ -70,6 +70,54 @@ component extends="coldbox.system.testing.BaseModelTest" {
expect( handler ).toBeComponent();
} );
+ it( "does not marshal a body or flush headers once a conditional-GET has already committed a 304", function(){
+ var event = mockRequestContext;
+ var prc = event.getPrivateCollection();
+ event.getResponse();
+
+ event.$( "isSSE", false );
+ event.$( "isNoExecution", true );
+ event.$( "renderData" );
+ event.$( "setHTTPHeader" );
+
+ handler.aroundHandler(
+ event = event,
+ rc = event.getCollection(),
+ prc = prc,
+ targetAction = function( event, rc, prc ){
+ },
+ eventArguments = {}
+ );
+
+ expect( event.$never( "renderData" ) ).toBeTrue();
+ expect( event.$never( "setHTTPHeader" ) ).toBeTrue();
+ } );
+
+ it( "still marshals normally when isNoExecution() is false", function(){
+ var event = mockRequestContext;
+ var prc = event.getPrivateCollection();
+ event.getResponse();
+
+ event.$( "isSSE", false );
+ event.$( "isNoExecution", false );
+ event.$( "renderData" );
+ // The header-flush loop further down aroundHandler() calls the real
+ // setHTTPHeader(), which needs a real servlet page context unavailable in this
+ // sandbox - stubbed here since it is not what this test is verifying.
+ event.$( "setHTTPHeader" );
+
+ handler.aroundHandler(
+ event = event,
+ rc = event.getCollection(),
+ prc = prc,
+ targetAction = function( event, rc, prc ){
+ },
+ eventArguments = {}
+ );
+
+ expect( event.$once( "renderData" ) ).toBeTrue();
+ } );
+
it( "can handle onExpectationFailed", function(){
makePublic( handler, "onExpectationFailed" );
handler.onExpectationFailed();
diff --git a/tests/specs/integration/EventCachingSpec.cfc b/tests/specs/integration/EventCachingSpec.cfc
index 16ec833b4..50a80d2d6 100755
--- a/tests/specs/integration/EventCachingSpec.cfc
+++ b/tests/specs/integration/EventCachingSpec.cfc
@@ -355,6 +355,50 @@
expect( prc1.cbox_eventCacheableEntry.cacheKey ).notToBe( prc2.cbox_eventCacheableEntry.cacheKey );
} );
+ // HTTP Caching - Tier 1 (docs/specs/http-caching.md §4.2/§4.4)
+ //
+ // execute() is a headless request simulator (system/testing/BaseTestCase.cfc) - it
+ // runs the handler and render steps directly rather than going through Bootstrap.cfc's
+ // actual onRequest cycle, so it never reaches the real event-caching *write* to
+ // CacheBox (every other test in this file only ever asserts against
+ // cbox_eventCacheableEntry for the same reason - none of them read the cache store
+ // back either). These specs are scoped to what execute() can actually observe: that
+ // the new annotations flow correctly into that same pre-execution metadata. The
+ // write-time hash computation and the conditional-GET short-circuit decision itself
+ // are covered directly against RequestContext in RequestContextHTTPCachingTest.cfc.
+
+ it( "flows the etag annotation into the cacheable entry metadata", function(){
+ var event = execute( event = "eventcaching.withETag", renderResults = true );
+ var prc = event.getPrivateCollection();
+
+ expect( prc.cbox_eventCacheableEntry ).toBeStruct().toHaveKey( "etag,etagWeak,cacheControl" );
+ expect( prc.cbox_eventCacheableEntry.etag ).toBeTrue();
+ // Neither annotation was set on this action, so both resolve to their defaults
+ expect( prc.cbox_eventCacheableEntry.etagWeak ).toBeFalse();
+ expect( prc.cbox_eventCacheableEntry.cacheControl ).toBeEmpty();
+ } );
+
+ it( "flows the lastModified annotation into the cacheable entry metadata", function(){
+ var event = execute( event = "eventcaching.withLastModified", renderResults = true );
+ var prc = event.getPrivateCollection();
+
+ expect( prc.cbox_eventCacheableEntry ).toBeStruct().toHaveKey( "lastModified" );
+ expect( prc.cbox_eventCacheableEntry.lastModified ).toBeTrue();
+ } );
+
+ it( "defaults etag/etagWeak/lastModified/cacheControl to off for handlers that never set them", function(){
+ var event = execute( event = "eventcaching", renderResults = true );
+ var prc = event.getPrivateCollection();
+
+ expect( prc.cbox_eventCacheableEntry )
+ .toBeStruct()
+ .toHaveKey( "etag,etagWeak,lastModified,cacheControl" );
+ expect( prc.cbox_eventCacheableEntry.etag ).toBeFalse();
+ expect( prc.cbox_eventCacheableEntry.etagWeak ).toBeFalse();
+ expect( prc.cbox_eventCacheableEntry.lastModified ).toBeFalse();
+ expect( prc.cbox_eventCacheableEntry.cacheControl ).toBeEmpty();
+ } );
+
var formats = [ "json", "xml", "pdf" ];
for ( var thisFormat in formats ) {
it(
diff --git a/tests/specs/web/context/RequestContextHTTPCachingTest.cfc b/tests/specs/web/context/RequestContextHTTPCachingTest.cfc
new file mode 100644
index 000000000..83cc610f8
--- /dev/null
+++ b/tests/specs/web/context/RequestContextHTTPCachingTest.cfc
@@ -0,0 +1,360 @@
+/**
+ * RequestContext HTTP Caching Tests — the conditional-GET primitives from
+ * docs/specs/http-caching.md §3.1.
+ *
+ * Pure HTTP header logic with no runtime dependency, so - unlike the SSE suites - this runs on
+ * every engine, not just BoxLang.
+ */
+component extends="coldbox.system.testing.BaseModelTest" {
+
+ /*********************************** LIFE CYCLE Methods ***********************************/
+
+ function beforeAll(){
+ super.beforeAll();
+ }
+
+ private function buildContext(){
+ var props = {
+ defaultLayout : "Main.cfm",
+ defaultView : "",
+ folderLayouts : structNew(),
+ viewLayouts : structNew(),
+ eventName : "event",
+ sesBaseURL : "http://localhost/index.cfm",
+ registeredLayouts : structNew(),
+ modules : {}
+ };
+
+ var mockController = getMockController();
+ prepareMock( mockController.getInterceptorService() );
+ prepareMock( mockController.getWireBox() );
+
+ return prepareMock( new coldbox.system.web.context.RequestContext( props, mockController ) );
+ }
+
+ /*********************************** BDD SUITES ***********************************/
+
+ function run( testResults, testBox ){
+ describe( "RequestContext HTTP caching", function(){
+ describe( "etag()", function(){
+ it( "sets the ETag header and returns false when there is no If-None-Match", function(){
+ var event = buildContext();
+ event.$( "getHTTPMethod", "GET" );
+ event
+ .$( "getHTTPHeader" )
+ .$args( "If-None-Match", "" )
+ .$results( "" );
+ event.$( "setHTTPHeader" );
+ event.$( "noExecution" );
+
+ var result = event.etag( "abc123" );
+
+ expect( result ).toBeFalse();
+ expect( event.$never( "noExecution" ) ).toBeTrue();
+ var headerCalls = event.$callLog().setHTTPHeader;
+ expect( headerCalls ).toHaveLength( 1 );
+ expect( headerCalls[ 1 ].name ).toBe( "ETag" );
+ expect( headerCalls[ 1 ].value ).toBe( """abc123""" );
+ } );
+
+ it( "quotes weak etags with a W/ prefix", function(){
+ var event = buildContext();
+ event.$( "getHTTPMethod", "GET" );
+ event
+ .$( "getHTTPHeader" )
+ .$args( "If-None-Match", "" )
+ .$results( "" );
+ event.$( "setHTTPHeader" );
+
+ event.etag( value = "abc123", weak = true );
+
+ expect( event.$callLog().setHTTPHeader[ 1 ].value ).toBe( "W/""abc123""" );
+ } );
+
+ it( "short-circuits with a 304 and no body when If-None-Match matches", function(){
+ var event = buildContext();
+ event.$( "getHTTPMethod", "GET" );
+ event
+ .$( "getHTTPHeader" )
+ .$args( "If-None-Match", "" )
+ .$results( """abc123""" );
+ event.$( "setHTTPHeader" );
+ event.$( "noExecution" );
+
+ var result = event.etag( "abc123" );
+
+ expect( result ).toBeTrue();
+ expect( event.$once( "noExecution" ) ).toBeTrue();
+ var headerCalls = event.$callLog().setHTTPHeader;
+ expect( headerCalls ).toHaveLength( 2 );
+ expect( headerCalls[ 2 ].statusCode ).toBe( 304 );
+ } );
+
+ it( "never short-circuits an unsafe HTTP method even on a match", function(){
+ var event = buildContext();
+ event.$( "getHTTPMethod", "POST" );
+ event
+ .$( "getHTTPHeader" )
+ .$args( "If-None-Match", "" )
+ .$results( """abc123""" );
+ event.$( "setHTTPHeader" );
+ event.$( "noExecution" );
+
+ var result = event.etag( "abc123" );
+
+ expect( result ).toBeFalse();
+ expect( event.$never( "noExecution" ) ).toBeTrue();
+ } );
+
+ it( "treats HEAD as a safe method", function(){
+ var event = buildContext();
+ event.$( "getHTTPMethod", "HEAD" );
+ event
+ .$( "getHTTPHeader" )
+ .$args( "If-None-Match", "" )
+ .$results( """abc123""" );
+ event.$( "setHTTPHeader" );
+ event.$( "noExecution" );
+
+ expect( event.etag( "abc123" ) ).toBeTrue();
+ } );
+
+ it( "matches a wildcard If-None-Match", function(){
+ var event = buildContext();
+ event.$( "getHTTPMethod", "GET" );
+ event
+ .$( "getHTTPHeader" )
+ .$args( "If-None-Match", "" )
+ .$results( "*" );
+ event.$( "setHTTPHeader" );
+ event.$( "noExecution" );
+
+ expect( event.etag( "abc123" ) ).toBeTrue();
+ } );
+
+ it( "matches any entry in a comma-separated If-None-Match list", function(){
+ var event = buildContext();
+ event.$( "getHTTPMethod", "GET" );
+ event
+ .$( "getHTTPHeader" )
+ .$args( "If-None-Match", "" )
+ .$results( """xyz789"", ""abc123"", ""other""" );
+ event.$( "setHTTPHeader" );
+ event.$( "noExecution" );
+
+ expect( event.etag( "abc123" ) ).toBeTrue();
+ } );
+
+ it( "matches a weak client tag against a strong server tag (weak comparison)", function(){
+ var event = buildContext();
+ event.$( "getHTTPMethod", "GET" );
+ event
+ .$( "getHTTPHeader" )
+ .$args( "If-None-Match", "" )
+ .$results( "W/""abc123""" );
+ event.$( "setHTTPHeader" );
+ event.$( "noExecution" );
+
+ expect( event.etag( "abc123" ) ).toBeTrue();
+ } );
+
+ it( "does not match a genuinely different tag in a list", function(){
+ var event = buildContext();
+ event.$( "getHTTPMethod", "GET" );
+ event
+ .$( "getHTTPHeader" )
+ .$args( "If-None-Match", "" )
+ .$results( """xyz789"", ""other""" );
+ event.$( "setHTTPHeader" );
+ event.$( "noExecution" );
+
+ expect( event.etag( "abc123" ) ).toBeFalse();
+ expect( event.$never( "noExecution" ) ).toBeTrue();
+ } );
+ } );
+
+ describe( "lastModified()", function(){
+ it( "sets Last-Modified and returns false when there is no If-Modified-Since", function(){
+ var event = buildContext();
+ event.$( "getHTTPMethod", "GET" );
+ event
+ .$( "getHTTPHeader" )
+ .$args( "If-None-Match", "" )
+ .$results( "" );
+ event
+ .$( "getHTTPHeader" )
+ .$args( "If-Modified-Since", "" )
+ .$results( "" );
+ event.$( "setHTTPHeader" );
+ event.$( "noExecution" );
+
+ var result = event.lastModified( now() );
+
+ expect( result ).toBeFalse();
+ expect( event.$never( "noExecution" ) ).toBeTrue();
+ expect( event.$callLog().setHTTPHeader[ 1 ].name ).toBe( "Last-Modified" );
+ } );
+
+ it( "short-circuits when If-Modified-Since is at or after the resource's timestamp", function(){
+ var event = buildContext();
+ var resourceDate = dateAdd( "h", -1, now() );
+ var clientKnowsAsOf = event.toHTTPDate( now() );
+ event.$( "getHTTPMethod", "GET" );
+ event
+ .$( "getHTTPHeader" )
+ .$args( "If-None-Match", "" )
+ .$results( "" );
+ event
+ .$( "getHTTPHeader" )
+ .$args( "If-Modified-Since", "" )
+ .$results( clientKnowsAsOf );
+ event.$( "setHTTPHeader" );
+ event.$( "noExecution" );
+
+ var result = event.lastModified( resourceDate );
+
+ expect( result ).toBeTrue();
+ expect( event.$once( "noExecution" ) ).toBeTrue();
+ } );
+
+ it( "does not short-circuit when the resource changed after If-Modified-Since", function(){
+ var event = buildContext();
+ var resourceDate = now();
+ var clientKnowsAsOf = event.toHTTPDate( dateAdd( "h", -1, now() ) );
+ event.$( "getHTTPMethod", "GET" );
+ event
+ .$( "getHTTPHeader" )
+ .$args( "If-None-Match", "" )
+ .$results( "" );
+ event
+ .$( "getHTTPHeader" )
+ .$args( "If-Modified-Since", "" )
+ .$results( clientKnowsAsOf );
+ event.$( "setHTTPHeader" );
+ event.$( "noExecution" );
+
+ var result = event.lastModified( resourceDate );
+
+ expect( result ).toBeFalse();
+ expect( event.$never( "noExecution" ) ).toBeTrue();
+ } );
+
+ it( "ignores a non-date If-Modified-Since rather than throwing", function(){
+ var event = buildContext();
+ event.$( "getHTTPMethod", "GET" );
+ event
+ .$( "getHTTPHeader" )
+ .$args( "If-None-Match", "" )
+ .$results( "" );
+ event
+ .$( "getHTTPHeader" )
+ .$args( "If-Modified-Since", "" )
+ .$results( "not-a-date" );
+ event.$( "setHTTPHeader" );
+ event.$( "noExecution" );
+
+ expect( () => event.lastModified( now() ) ).notToThrow();
+ expect( event.$never( "noExecution" ) ).toBeTrue();
+ } );
+
+ it( "ignores a matching If-Modified-Since when If-None-Match is also present", function(){
+ var event = buildContext();
+ var resourceDate = dateAdd( "h", -1, now() );
+ var clientKnowsAsOf = event.toHTTPDate( now() );
+ event.$( "getHTTPMethod", "GET" );
+ event
+ .$( "getHTTPHeader" )
+ .$args( "If-None-Match", "" )
+ .$results( """some-other-tag""" );
+ event
+ .$( "getHTTPHeader" )
+ .$args( "If-Modified-Since", "" )
+ .$results( clientKnowsAsOf );
+ event.$( "setHTTPHeader" );
+ event.$( "noExecution" );
+
+ // Per RFC 7232 §3.3: a request carrying If-None-Match ignores If-Modified-Since
+ // entirely, even though the date alone would have matched.
+ var result = event.lastModified( resourceDate );
+
+ expect( result ).toBeFalse();
+ expect( event.$never( "noExecution" ) ).toBeTrue();
+ } );
+ } );
+
+ describe( "cacheControl()", function(){
+ it( "assembles boolean directives as bare tokens and others as key=value", function(){
+ var event = buildContext();
+ event.$( "setHTTPHeader" );
+
+ event.cacheControl( { "public" : true, "max-age" : 60 } );
+
+ // Directive order is not guaranteed - plain CFML structs are not guaranteed
+ // insertion-ordered on every engine (Lucee in particular), and per RFC 9111
+ // Cache-Control's directive order carries no semantic meaning anyway.
+ var headerCall = event.$callLog().setHTTPHeader[ 1 ];
+ expect( headerCall.name ).toBe( "Cache-Control" );
+ expect( headerCall.value ).toInclude( "public" );
+ expect( headerCall.value ).toInclude( "max-age=60" );
+ } );
+
+ it( "defaults to no-cache", function(){
+ var event = buildContext();
+ event.$( "setHTTPHeader" );
+
+ event.cacheControl();
+
+ expect( event.$callLog().setHTTPHeader[ 1 ].value ).toBe( "no-cache" );
+ } );
+
+ it( "is fluent", function(){
+ var event = buildContext();
+ event.$( "setHTTPHeader" );
+
+ expect( event.cacheControl() ).toBe( event );
+ } );
+ } );
+
+ describe( "toHTTPDate()", function(){
+ it( "matches the RFC 7231 example date exactly", function(){
+ var event = buildContext();
+ // The canonical example from RFC 7231 §7.1.1.1, given as UTC. toHTTPDate()
+ // converts its input from local time, so feed it the local equivalent of that
+ // UTC instant - keeps the assertion stable regardless of the runner's timezone.
+ var rfcExampleDateUTC = createDateTime( 1994, 11, 6, 8, 49, 37 );
+ var localEquivalent = dateConvert( "utc2local", rfcExampleDateUTC );
+
+ expect( event.toHTTPDate( localEquivalent ) ).toBe( "Sun, 06 Nov 1994 08:49:37 GMT" );
+ } );
+ } );
+
+ describe( "isNoExecution()", function(){
+ it( "is false by default", function(){
+ expect( buildContext().isNoExecution() ).toBeFalse();
+ } );
+
+ it( "is true after noExecution() runs", function(){
+ var event = buildContext();
+ event.noExecution();
+
+ expect( event.isNoExecution() ).toBeTrue();
+ } );
+
+ it( "becomes true as a side effect of a matching etag() call", function(){
+ var event = buildContext();
+ event.$( "getHTTPMethod", "GET" );
+ event
+ .$( "getHTTPHeader" )
+ .$args( "If-None-Match", "" )
+ .$results( """abc123""" );
+ event.$( "setHTTPHeader" );
+
+ event.etag( "abc123" );
+
+ expect( event.isNoExecution() ).toBeTrue();
+ } );
+ } );
+ } );
+ }
+
+}
diff --git a/tests/specs/web/context/ResponseTest.cfc b/tests/specs/web/context/ResponseTest.cfc
index e020e304a..90ed550fe 100644
--- a/tests/specs/web/context/ResponseTest.cfc
+++ b/tests/specs/web/context/ResponseTest.cfc
@@ -58,6 +58,37 @@ component extends="coldbox.system.testing.BaseModelTest" {
expect( variables.response.getHeaders() ).toBeEmpty();
} );
+ it( "can set an ETag header fluently", function(){
+ variables.response.withETag( "abc123" );
+ expect( variables.response.getHeader( "ETag" ) ).toBe( """abc123""" );
+ } );
+
+ it( "can set a weak ETag header fluently", function(){
+ variables.response.withETag( value = "abc123", weak = true );
+ expect( variables.response.getHeader( "ETag" ) ).toBe( "W/""abc123""" );
+ } );
+
+ it( "replaces rather than duplicates an existing ETag header", function(){
+ variables.response.withETag( "first" ).withETag( "second" );
+ expect( variables.response.getHeaders().len() ).toBe( 1 );
+ expect( variables.response.getHeader( "ETag" ) ).toBe( """second""" );
+ } );
+
+ it( "can set a Cache-Control header fluently", function(){
+ variables.response.withCacheControl( { "public" : true, "max-age" : 60 } );
+ // Directive order is not guaranteed - plain CFML structs are not guaranteed
+ // insertion-ordered on every engine (Lucee in particular), and per RFC 9111
+ // Cache-Control's directive order carries no semantic meaning anyway.
+ var cacheControlHeader = variables.response.getHeader( "Cache-Control" );
+ expect( cacheControlHeader ).toInclude( "public" );
+ expect( cacheControlHeader ).toInclude( "max-age=60" );
+ } );
+
+ it( "defaults Cache-Control to no-cache", function(){
+ variables.response.withCacheControl();
+ expect( variables.response.getHeader( "Cache-Control" ) ).toBe( "no-cache" );
+ } );
+
it( "can handle pagination", function(){
response.setPagination( 0, 100, 1, 1000, 10 );