Skip to main content

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-common for typo-resistant Cache-Control directive construction.
  • A checkPreconditions(...) helper on RestRequest that evaluates the four conditional request headers in the exact order RFC 7232 §6 specifies, and returns an Optional<BasicHttpException> carrying the right 304 Not Modified or 412 Precondition Failed for 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:

  1. Format ETag / Last-Modified headers correctly (quoting, IMF-fixdate, UTC).
  2. Read four request headers (If-Match, If-Unmodified-Since, If-None-Match, If-Modified-Since).
  3. Implement RFC 7232 §6 ordering (which header wins when more than one is present).
  4. Implement RFC 7232 §2.3.2 ETag matching rules (strong vs weak comparison).
  5. Pick the right status code (304 vs 412) 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:

MethodSetsNotes
eTag(String)ETagDelegates to EntityTag.of(...) so quoting (strong: "v1"; weak: W/"v1") is validated up front.
eTag(EntityTag)ETagWhen the caller already has a typed EntityTag.
lastModified(Instant)Last-ModifiedTruncated to second precision; formatted as RFC 7231 IMF-fixdate in UTC.
lastModified(ZonedDateTime)Last-ModifiedConverted to UTC before formatting.
cacheControl(String)Cache-ControlRaw header value (e.g. "public, max-age=3600").
cacheControl(CacheControl.Builder)Cache-ControlTyped 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:

MethodDirective
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>:

ReturnMeaning
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:

  1. If-Match — strong comparison. On mismatch → 412 Precondition Failed. If present, If-Unmodified-Since is ignored.
  2. If-Unmodified-Since — only consulted when If-Match is absent. If the resource's Last-Modified is strictly later than the supplied date → 412 Precondition Failed.
  3. If-None-Match — weak comparison. On match → 304 Not Modified (for safe methods) or 412 Precondition Failed (for unsafe methods). If present, If-Modified-Since is ignored.
  4. If-Modified-Since — only consulted when If-None-Match is absent and the method is GET or HEAD. If the resource's Last-Modified is not strictly later than the supplied date → 304 Not Modified.

ETag matching rules​

ComparisonUsed byRule
StrongIf-MatchBoth ETags must be strong (no W/ prefix) and have identical opaque tags. A weak response ETag never matches an If-Match.
WeakIf-None-MatchTags match if their opaque parts are identical, regardless of weak/strong markers.
Wildcard *bothIf-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-Match match into a 304, 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.
  • Vary header automation. Callers still set Vary explicitly when appropriate.
  • If-Range / 206 Partial Content. Range-request support is out of scope.

See also​

Resources​