Conditional-GET / ETag Helpers
Juneau REST servers ship a minimal but complete RFC 7232 — Conditional Requests helper layer so handlers no longer need to hand-roll ETag / Last-Modified / If-None-Match / If-Match plumbing. The feature couples three pieces:
- A handful of fluent setters on RestResponse —
eTag(...),lastModified(...),cacheControl(...). - A typed CacheControl.Builder in
juneau-rest-commonfor typo-resistantCache-Controldirective construction. - A
checkPreconditions(...)helper on RestRequest that evaluates the four conditional request headers in the exact order RFC 7232 §6 specifies, and returns anOptional<BasicHttpException>carrying the right304 Not Modifiedor412 Precondition Failedfor the caller to throw.
The wiring is purely additive — existing handlers continue to work unchanged.
Motivation
A textbook conditional-GET handler looks like this:
@RestGet("/{id}")
public Order get(@Path long id, RestRequest req, RestResponse res) {
var order = repo.find(id);
var tag = "\"" + order.version() + "\"";
res.eTag(tag).lastModified(order.updated());
req.checkPreconditions(res).ifPresent(e -> { throw e; });
return order;
}
Without the helpers, every handler that wants conditional-GET support has to:
- Format
ETag/Last-Modifiedheaders correctly (quoting, IMF-fixdate, UTC). - Read four request headers (
If-Match,If-Unmodified-Since,If-None-Match,If-Modified-Since). - Implement RFC 7232 §6 ordering (which header wins when more than one is present).
- Implement RFC 7232 §2.3.2 ETag matching rules (strong vs weak comparison).
- Pick the right status code (
304vs412) and short-circuit cleanly.
That's a lot of fiddly RFC bookkeeping. The helpers compress all of it into a one-line call.
Response-side setters on RestResponse
Six fluent setters, all chainable:
| Method | Sets | Notes |
|---|---|---|
eTag(String) | ETag | Delegates to EntityTag.of(...) so quoting (strong: "v1"; weak: W/"v1") is validated up front. |
eTag(EntityTag) | ETag | When the caller already has a typed EntityTag. |
lastModified(Instant) | Last-Modified | Truncated to second precision; formatted as RFC 7231 IMF-fixdate in UTC. |
lastModified(ZonedDateTime) | Last-Modified | Converted to UTC before formatting. |
cacheControl(String) | Cache-Control | Raw header value (e.g. "public, max-age=3600"). |
cacheControl(CacheControl.Builder) | Cache-Control | Typed builder form — see below. |
res.eTag("\"v42\"")
.lastModified(Instant.parse("2026-05-22T00:00:00Z"))
.cacheControl("public, max-age=60");
Produces:
ETag: "v42"
Last-Modified: Fri, 22 May 2026 00:00:00 GMT
Cache-Control: public, max-age=60
CacheControl.Builder — typed directive construction
CacheControl.Builder (in juneau-rest-common) is a typo-resistant fluent API over the standard directive set:
| Method | Directive |
|---|---|
publicCache() / privateCache() | public / private |
noCache() | no-cache |
noStore() | no-store |
noTransform() | no-transform |
mustRevalidate() | must-revalidate |
proxyRevalidate() | proxy-revalidate |
immutable() | immutable |
maxAge(long) / maxAge(Duration) | max-age=N |
sMaxAge(long) / sMaxAge(Duration) | s-maxage=N |
staleWhileRevalidate(long) | stale-while-revalidate=N |
staleIfError(long) | stale-if-error=N |
extension(String) | Arbitrary extension directive |
Directives are emitted in a stable order — cacheability (public / private) first, then boolean directives in source order (no-cache / no-store / no-transform / must-revalidate / proxy-revalidate / immutable), then numeric directives (max-age / s-maxage / stale-while-revalidate / stale-if-error), then extensions. This makes snapshot-based testing deterministic.
res.cacheControl(CacheControl.create()
.publicCache()
.maxAge(Duration.ofMinutes(5))
.mustRevalidate()
.build());
// → Cache-Control: public, must-revalidate, max-age=300
Request-side checkPreconditions(RestResponse)
RestRequest.checkPreconditions(RestResponse) evaluates the four conditional request headers against the ETag / Last-Modified already set on the response and returns an Optional<BasicHttpException>:
| Return | Meaning |
|---|---|
Optional.empty() | No precondition matched — handler may proceed. |
Optional<BasicHttpException> (304) | A BasicHttpException carrying 304 Not Modified — throw to short-circuit. |
Optional<BasicHttpException> (412) | A BasicHttpException carrying 412 Precondition Failed — throw to short-circuit. |
The handler then chooses how to react:
// Optional.ifPresent — short-circuit by throwing the carried exception:
req.checkPreconditions(res).ifPresent(e -> { throw e; });
// Or unwrap and re-shape the response yourself:
var opt = req.checkPreconditions(res);
if (opt.isPresent()) {
var ex = opt.get();
res.setStatus(ex.getStatusCode());
return null;
}
Ordering rule
checkPreconditions(...) reads from the response's already-set ETag and Last-Modified headers. Set those before calling the check, so the helper compares against the values the handler intends to send:
res.eTag("\"v42\"").lastModified(order.updated()); // 1. tag the response first
req.checkPreconditions(res).ifPresent(e -> { throw e; }); // 2. then check
return order; // 3. handler body
Setting the headers after the check produces stale comparisons.
RFC 7232 §6 precedence
The helper applies the four conditional headers in the order RFC 7232 mandates:
If-Match— strong comparison. On mismatch →412 Precondition Failed. If present,If-Unmodified-Sinceis ignored.If-Unmodified-Since— only consulted whenIf-Matchis absent. If the resource'sLast-Modifiedis strictly later than the supplied date →412 Precondition Failed.If-None-Match— weak comparison. On match →304 Not Modified(for safe methods) or412 Precondition Failed(for unsafe methods). If present,If-Modified-Sinceis ignored.If-Modified-Since— only consulted whenIf-None-Matchis absent and the method isGETorHEAD. If the resource'sLast-Modifiedis not strictly later than the supplied date →304 Not Modified.
ETag matching rules
| Comparison | Used by | Rule |
|---|---|---|
| Strong | If-Match | Both ETags must be strong (no W/ prefix) and have identical opaque tags. A weak response ETag never matches an If-Match. |
| Weak | If-None-Match | Tags match if their opaque parts are identical, regardless of weak/strong markers. |
Wildcard * | both | If-Match: * matches when the response has any ETag (else 412). If-None-Match: * matches when any ETag is set on the response (else passes through). |
Malformed dates
If-Modified-Since / If-Unmodified-Since values that cannot be parsed as HTTP-date are silently ignored — they are treated as if the header were absent. This follows Postel's law and matches what mainstream servers (nginx, Apache httpd, Tomcat) do in practice.
Worked example — round trip
A trivial repository-backed resource that demonstrates the full lifecycle:
@Rest(path="/orders")
public class OrderResource {
@RestGet("/{id}")
public Order get(@Path long id, RestRequest req, RestResponse res) {
var order = repo.find(id);
res.eTag("\"" + order.version() + "\"")
.lastModified(order.updated())
.cacheControl(CacheControl.create().publicCache().maxAge(60).build());
req.checkPreconditions(res).ifPresent(e -> { throw e; });
return order;
}
@RestPut("/{id}")
public Order put(@Path long id, @Content Order in, RestRequest req, RestResponse res) {
var current = repo.find(id);
res.eTag("\"" + current.version() + "\"").lastModified(current.updated());
req.checkPreconditions(res).ifPresent(e -> { throw e; }); // 412 if If-Match is stale
var next = repo.save(in.withVersion(current.version() + 1));
res.eTag("\"" + next.version() + "\"").lastModified(next.updated());
return next;
}
}
Wire-level interaction:
# 1. First GET — full response with ETag + Last-Modified
GET /orders/1
→ 200 OK
ETag: "1"
Last-Modified: Fri, 22 May 2026 00:00:00 GMT
Cache-Control: public, max-age=60
# 2. Re-fetch using If-None-Match — short-circuits to 304
GET /orders/1
If-None-Match: "1"
→ 304 Not Modified
# 3. PUT with stale If-Match — rejected
PUT /orders/1
If-Match: "0"
{ ... }
→ 412 Precondition Failed
# 4. PUT with correct If-Match — succeeds and bumps ETag
PUT /orders/1
If-Match: "1"
{ ... }
→ 200 OK
ETag: "2"
Last-Modified: Sat, 23 May 2026 00:00:00 GMT
Scope notes
The helpers cover the wire-level conditional-GET layer. Out of scope for this release:
- Server-side response caching. The helpers turn an
If-None-Matchmatch into a304, but the handler still computes the resource. A separate response-cache layer (compute-once / re-serve) is out of scope. - Weak vs strong ETag policy. EntityTag already models both via
isWeak(); the helpers honor whatever the caller produced. Varyheader automation. Callers still setVaryexplicitly when appropriate.If-Range/206 Partial Content. Range-request support is out of scope.
See also
- REST Server — HTTP Status Codes — the framework defaults for non-OK statuses, including
304and412. - Java Method Throwable Types — what exceptions Juneau handlers can throw and how they map to HTTP statuses.
Resources
- RFC 7232 — Conditional Requests
- RFC 7234 — Caching (obsoleted by RFC 9111)
- RFC 9111 — HTTP Caching