You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@juneau.apache.org by ja...@apache.org on 2026/05/23 01:05:50 UTC

(juneau) branch master updated: Complete TODO-64 conditional request helpers

This is an automated email from the ASF dual-hosted git repository.

jamesbognar pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/juneau.git


The following commit(s) were added to refs/heads/master by this push:
     new 2c2fbd1167 Complete TODO-64 conditional request helpers
2c2fbd1167 is described below

commit 2c2fbd1167aa06b19bba4a2673ecc13178edb9be
Author: James Bognar <ja...@jamesbog-ltmjqf2.internal.salesforce.com>
AuthorDate: Fri May 22 21:05:45 2026 -0400

    Complete TODO-64 conditional request helpers
---
 .../juneau/http/header/CacheControlBuilder.java    | 328 +++++++++++++++++++
 .../java/org/apache/juneau/rest/RestRequest.java   | 157 +++++++++
 .../java/org/apache/juneau/rest/RestResponse.java  | 129 ++++++++
 .../juneau/http/header/NamedHeaders_Test.java      |   2 +
 .../juneau/rest/RestEtag_RoundTrip_Test.java       | 115 +++++++
 .../rest/RestRequest_CheckPreconditions_Test.java  | 332 +++++++++++++++++++
 .../juneau/rest/RestResponse_EtagHelpers_Test.java | 364 +++++++++++++++++++++
 ...=> FINISHED-64-etag-conditional-get-helpers.md} |   4 +-
 todo/TODO.md                                       |   2 -
 9 files changed, 1430 insertions(+), 3 deletions(-)

diff --git a/juneau-rest/juneau-rest-common/src/main/java/org/apache/juneau/http/header/CacheControlBuilder.java b/juneau-rest/juneau-rest-common/src/main/java/org/apache/juneau/http/header/CacheControlBuilder.java
new file mode 100644
index 0000000000..69a119d404
--- /dev/null
+++ b/juneau-rest/juneau-rest-common/src/main/java/org/apache/juneau/http/header/CacheControlBuilder.java
@@ -0,0 +1,328 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.http.header;
+
+import static org.apache.juneau.commons.utils.AssertionUtils.*;
+import static org.apache.juneau.commons.utils.ThrowableUtils.*;
+
+import java.time.*;
+import java.util.*;
+
+/**
+ * Fluent builder for assembling an HTTP <c>Cache-Control</c> header value.
+ *
+ * <p>
+ * Composes the directives defined by
+ * <a class='doclink' href='https://www.rfc-editor.org/rfc/rfc9111#name-cache-control'>RFC 9111 §5.2</a> into the
+ * comma-separated wire format expected by the {@code Cache-Control} response header. Mutually exclusive
+ * cacheability directives (e.g. {@link #publicCache()} vs {@link #privateCache()}) overwrite one another;
+ * boolean directives (e.g. {@link #noStore()}) are idempotent.
+ *
+ * <h5 class='section'>Example:</h5>
+ * <p class='bjava'>
+ * 	String <jv>value</jv> = CacheControlBuilder.<jsm>create</jsm>()
+ * 		.publicCache()
+ * 		.maxAge(3600)
+ * 		.mustRevalidate()
+ * 		.build();
+ * 	<jc>// =&gt; "public, max-age=3600, must-revalidate"</jc>
+ * </p>
+ *
+ * <h5 class='section'>See Also:</h5><ul>
+ * 	<li class='jc'>{@link CacheControl}
+ * 	<li class='extlink'><a class='doclink' href='https://www.rfc-editor.org/rfc/rfc9111'>RFC 9111 - HTTP Caching</a>
+ * </ul>
+ *
+ * @since 9.5.0
+ */
+public class CacheControlBuilder {
+
+	private static final String ARG_value = "value";
+
+	private String cacheability;        // "public" or "private" or null
+	private boolean noCache;
+	private boolean noStore;
+	private boolean noTransform;
+	private boolean mustRevalidate;
+	private boolean proxyRevalidate;
+	private boolean immutable;
+	private Long maxAge;
+	private Long sMaxAge;
+	private Long staleWhileRevalidate;
+	private Long staleIfError;
+	private final List<String> extensions = new ArrayList<>();
+
+	/**
+	 * Creates a new empty builder.
+	 *
+	 * @return A new builder. Never <jk>null</jk>.
+	 */
+	public static CacheControlBuilder create() {
+		return new CacheControlBuilder();
+	}
+
+	/**
+	 * Adds the <c>public</c> directive (responses may be cached by any cache).
+	 *
+	 * <p>
+	 * Clears any previously set <c>private</c> directive.
+	 *
+	 * @return This object.
+	 */
+	public CacheControlBuilder publicCache() {
+		cacheability = "public";
+		return this;
+	}
+
+	/**
+	 * Adds the <c>private</c> directive (responses may only be cached by the originating client).
+	 *
+	 * <p>
+	 * Clears any previously set <c>public</c> directive.
+	 *
+	 * @return This object.
+	 */
+	public CacheControlBuilder privateCache() {
+		cacheability = "private";
+		return this;
+	}
+
+	/**
+	 * Adds the <c>no-cache</c> directive.
+	 *
+	 * <p>
+	 * Forces caches to revalidate with the origin server before reusing a stored response.
+	 *
+	 * @return This object.
+	 */
+	public CacheControlBuilder noCache() {
+		noCache = true;
+		return this;
+	}
+
+	/**
+	 * Adds the <c>no-store</c> directive.
+	 *
+	 * <p>
+	 * Forbids caches from storing any part of the request or response.
+	 *
+	 * @return This object.
+	 */
+	public CacheControlBuilder noStore() {
+		noStore = true;
+		return this;
+	}
+
+	/**
+	 * Adds the <c>no-transform</c> directive.
+	 *
+	 * <p>
+	 * Forbids intermediaries from transforming the response payload.
+	 *
+	 * @return This object.
+	 */
+	public CacheControlBuilder noTransform() {
+		noTransform = true;
+		return this;
+	}
+
+	/**
+	 * Adds the <c>must-revalidate</c> directive.
+	 *
+	 * <p>
+	 * Forces caches to revalidate stale responses with the origin before serving them.
+	 *
+	 * @return This object.
+	 */
+	public CacheControlBuilder mustRevalidate() {
+		mustRevalidate = true;
+		return this;
+	}
+
+	/**
+	 * Adds the <c>proxy-revalidate</c> directive (must-revalidate for shared caches only).
+	 *
+	 * @return This object.
+	 */
+	public CacheControlBuilder proxyRevalidate() {
+		proxyRevalidate = true;
+		return this;
+	}
+
+	/**
+	 * Adds the <c>immutable</c> directive.
+	 *
+	 * <p>
+	 * Signals that the response body will not change for the duration of its freshness lifetime.
+	 *
+	 * @return This object.
+	 */
+	public CacheControlBuilder immutable() {
+		immutable = true;
+		return this;
+	}
+
+	/**
+	 * Adds the <c>max-age=N</c> directive (seconds).
+	 *
+	 * @param seconds Maximum freshness lifetime in seconds. Must be non-negative.
+	 * @return This object.
+	 * @throws IllegalArgumentException If {@code seconds} is negative.
+	 */
+	public CacheControlBuilder maxAge(long seconds) {
+		if (seconds < 0)
+			throw illegalArg("max-age must be non-negative: {0}", seconds);
+		maxAge = seconds;
+		return this;
+	}
+
+	/**
+	 * Adds the <c>max-age=N</c> directive from a {@link Duration}.
+	 *
+	 * @param value Maximum freshness lifetime. Must not be <jk>null</jk> or negative.
+	 * @return This object.
+	 * @throws IllegalArgumentException If {@code value} is <jk>null</jk> or negative.
+	 */
+	public CacheControlBuilder maxAge(Duration value) {
+		assertArgNotNull(ARG_value, value);
+		return maxAge(value.getSeconds());
+	}
+
+	/**
+	 * Adds the <c>s-maxage=N</c> directive (shared-cache max-age in seconds).
+	 *
+	 * @param seconds Maximum freshness lifetime in seconds for shared caches. Must be non-negative.
+	 * @return This object.
+	 * @throws IllegalArgumentException If {@code seconds} is negative.
+	 */
+	public CacheControlBuilder sMaxAge(long seconds) {
+		if (seconds < 0)
+			throw illegalArg("s-maxage must be non-negative: {0}", seconds);
+		sMaxAge = seconds;
+		return this;
+	}
+
+	/**
+	 * Adds the <c>s-maxage=N</c> directive from a {@link Duration}.
+	 *
+	 * @param value Maximum freshness lifetime for shared caches. Must not be <jk>null</jk> or negative.
+	 * @return This object.
+	 * @throws IllegalArgumentException If {@code value} is <jk>null</jk> or negative.
+	 */
+	public CacheControlBuilder sMaxAge(Duration value) {
+		assertArgNotNull(ARG_value, value);
+		return sMaxAge(value.getSeconds());
+	}
+
+	/**
+	 * Adds the <c>stale-while-revalidate=N</c> directive (seconds).
+	 *
+	 * @param seconds Number of seconds a cache may serve a stale response while asynchronously revalidating.
+	 * @return This object.
+	 * @throws IllegalArgumentException If {@code seconds} is negative.
+	 */
+	public CacheControlBuilder staleWhileRevalidate(long seconds) {
+		if (seconds < 0)
+			throw illegalArg("stale-while-revalidate must be non-negative: {0}", seconds);
+		staleWhileRevalidate = seconds;
+		return this;
+	}
+
+	/**
+	 * Adds the <c>stale-if-error=N</c> directive (seconds).
+	 *
+	 * @param seconds Number of seconds a cache may serve a stale response when the origin is unreachable.
+	 * @return This object.
+	 * @throws IllegalArgumentException If {@code seconds} is negative.
+	 */
+	public CacheControlBuilder staleIfError(long seconds) {
+		if (seconds < 0)
+			throw illegalArg("stale-if-error must be non-negative: {0}", seconds);
+		staleIfError = seconds;
+		return this;
+	}
+
+	/**
+	 * Adds an arbitrary cache-control extension directive (e.g. <c>"community=\"UCI\""</c>).
+	 *
+	 * <p>
+	 * Extensions are appended in registration order at the end of the directive list. The caller is responsible for
+	 * supplying a token that conforms to the <c>cache-directive</c> grammar of RFC 9111 §5.2.3.
+	 *
+	 * @param value The extension token. Must not be <jk>null</jk> or blank.
+	 * @return This object.
+	 * @throws IllegalArgumentException If {@code value} is <jk>null</jk> or blank.
+	 */
+	public CacheControlBuilder extension(String value) {
+		assertArgNotNull(ARG_value, value);
+		var v = value.trim();
+		if (v.isEmpty())
+			throw illegalArg("cache-control extension must not be blank");
+		extensions.add(v);
+		return this;
+	}
+
+	/**
+	 * Builds the comma-separated <c>Cache-Control</c> header value.
+	 *
+	 * <p>
+	 * Returns an empty string when no directives have been registered.
+	 *
+	 * @return The header value. Never <jk>null</jk>.
+	 */
+	public String build() {
+		var parts = new ArrayList<String>(12);
+		if (cacheability != null)
+			parts.add(cacheability);
+		if (noCache)
+			parts.add("no-cache");
+		if (noStore)
+			parts.add("no-store");
+		if (noTransform)
+			parts.add("no-transform");
+		if (mustRevalidate)
+			parts.add("must-revalidate");
+		if (proxyRevalidate)
+			parts.add("proxy-revalidate");
+		if (immutable)
+			parts.add("immutable");
+		if (maxAge != null)
+			parts.add("max-age=" + maxAge);
+		if (sMaxAge != null)
+			parts.add("s-maxage=" + sMaxAge);
+		if (staleWhileRevalidate != null)
+			parts.add("stale-while-revalidate=" + staleWhileRevalidate);
+		if (staleIfError != null)
+			parts.add("stale-if-error=" + staleIfError);
+		parts.addAll(extensions);
+		return String.join(", ", parts);
+	}
+
+	/**
+	 * Builds a {@link CacheControl} header bean directly from this builder.
+	 *
+	 * @return A {@link CacheControl} carrying {@link #build()}. Never <jk>null</jk>.
+	 */
+	public CacheControl toHeader() {
+		return CacheControl.of(build());
+	}
+
+	@Override
+	public String toString() {
+		return build();
+	}
+}
diff --git a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestRequest.java b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestRequest.java
index cd53cbcb1d..d1426cc1ba 100644
--- a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestRequest.java
+++ b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestRequest.java
@@ -19,7 +19,9 @@ package org.apache.juneau.rest;
 import org.apache.juneau.commons.bean.BeanMeta;
 import org.apache.juneau.commons.http.MediaType;
 import org.apache.juneau.commons.http.StringRanges;
+import static java.time.format.DateTimeFormatter.RFC_1123_DATE_TIME;
 import static java.util.Optional.*;
+import static org.apache.juneau.commons.utils.AssertionUtils.*;
 import static org.apache.juneau.commons.utils.CollectionUtils.*;
 import static org.apache.juneau.rest.RestSharedConstants.*;
 import static org.apache.juneau.commons.utils.IoUtils.*;
@@ -34,6 +36,9 @@ import java.lang.reflect.Proxy;
 import java.net.*;
 import java.nio.charset.*;
 import java.text.*;
+import java.time.*;
+import java.time.format.*;
+import java.time.temporal.*;
 import java.util.*;
 import java.util.function.*;
 import java.util.stream.*;
@@ -101,6 +106,7 @@ import jakarta.servlet.http.*;
  * 		</ul>
  * 		<li>Methods for accessing HTTP parts:
  * 		<ul class='javatreec'>
+ * 			<li class='jm'>{@link RestRequest#checkPreconditions(RestResponse) checkPreconditions(RestResponse)}
  * 			<li class='jm'>{@link RestRequest#containsFormParam(String) containsFormParam(String)}
  * 			<li class='jm'>{@link RestRequest#containsHeader(String) containsHeader(String)}
  * 			<li class='jm'>{@link RestRequest#containsQueryParam(String) containsQueryParam(String)}
@@ -388,6 +394,157 @@ public class RestRequest extends HttpServletRequestWrapper {
 		return new FluentRequestLineAssertion<>(getRequestLine(), this);
 	}
 
+	/**
+	 * Evaluates the conditional-request headers on this request against the response's currently-set
+	 * <c>ETag</c> and <c>Last-Modified</c> values.
+	 *
+	 * <p>
+	 * Implements the precondition-evaluation order defined by
+	 * <a class='doclink' href='https://www.rfc-editor.org/rfc/rfc7232#section-6'>RFC 7232 §6</a>:
+	 * <ol>
+	 * 	<li><c>If-Match</c> — strong comparison; returns <c>412 Precondition Failed</c> on mismatch.
+	 * 	<li><c>If-Unmodified-Since</c> — only consulted when <c>If-Match</c> is absent;
+	 * 		returns <c>412 Precondition Failed</c> when the response's <c>Last-Modified</c> is
+	 * 		strictly after the supplied date.
+	 * 	<li><c>If-None-Match</c> — weak comparison; returns <c>304 Not Modified</c> on match
+	 * 		(for safe methods, per RFC 7232 §6).
+	 * 	<li><c>If-Modified-Since</c> — only consulted when <c>If-None-Match</c> is absent;
+	 * 		returns <c>304 Not Modified</c> when the response's <c>Last-Modified</c> is at-or-before
+	 * 		the supplied date.
+	 * </ol>
+	 *
+	 * <h5 class='section'>Usage:</h5>
+	 *
+	 * <p>
+	 * Set the response's <c>ETag</c> and/or <c>Last-Modified</c> <b>first</b>, then call this method.
+	 * The check reads the headers currently on the response, so any value set after the call is not
+	 * considered.
+	 *
+	 * <p class='bjava'>
+	 * 	<ja>@RestGet</ja>(<js>"/{id}"</js>)
+	 * 	<jk>public</jk> Order get(<ja>@Path</ja> <jk>long</jk> <jv>id</jv>, RestRequest <jv>req</jv>, RestResponse <jv>res</jv>) {
+	 * 		<jk>var</jk> <jv>order</jv> = <jv>repo</jv>.find(<jv>id</jv>);
+	 * 		<jv>res</jv>.eTag(<js>"\""</js> + <jv>order</jv>.version() + <js>"\""</js>).lastModified(<jv>order</jv>.updated());
+	 * 		<jv>req</jv>.checkPreconditions(<jv>res</jv>).ifPresent(<jv>e</jv> -&gt; { <jk>throw</jk> <jv>e</jv>; });
+	 * 		<jk>return</jk> <jv>order</jv>;
+	 * 	}
+	 * </p>
+	 *
+	 * <h5 class='section'>Notes:</h5><ul>
+	 * 	<li class='note'>Returns an {@link Optional} so the caller controls how to handle the failure
+	 * 		(throw inline, hand off to a logger, etc.). Both the returned <c>304</c> and <c>412</c>
+	 * 		failures are {@link BasicHttpException} instances, which are {@link RuntimeException}s,
+	 * 		so {@code ifPresent(e -&gt; { throw e; })} compiles cleanly.
+	 * 	<li class='note'><c>If-None-Match: *</c> matches any present <c>ETag</c>; <c>If-Match: *</c>
+	 * 		fails only when no <c>ETag</c> is set on the response.
+	 * 	<li class='note'>Per RFC 7232 §3.1, <c>If-Match</c> requires strong comparison: a weak
+	 * 		<c>ETag</c> on the response side never satisfies an <c>If-Match</c>.
+	 * 	<li class='note'>Per RFC 7232 §3.2, <c>If-None-Match</c> uses weak comparison: the weak/strong
+	 * 		flag is ignored when comparing tag values.
+	 * </ul>
+	 *
+	 * <h5 class='section'>See Also:</h5><ul>
+	 * 	<li class='jm'>{@link RestResponse#eTag(String) RestResponse.eTag(String)}
+	 * 	<li class='jm'>{@link RestResponse#lastModified(Instant) RestResponse.lastModified(Instant)}
+	 * </ul>
+	 *
+	 * @param response The response carrying the resource's current <c>ETag</c> and/or <c>Last-Modified</c>.
+	 * 	Must not be <jk>null</jk>.
+	 * @return Empty if all preconditions pass; otherwise an {@link Optional} holding either a
+	 * 	<c>304 Not Modified</c> {@link BasicHttpException} (when the request's cache copy is still
+	 * 	fresh) or a {@link PreconditionFailed} (when an <c>If-Match</c> / <c>If-Unmodified-Since</c>
+	 * 	guard failed).
+	 * @throws IllegalArgumentException If {@code response} is <jk>null</jk>.
+	 */
+	public Optional<BasicHttpException> checkPreconditions(RestResponse response) {
+		assertArgNotNull("response", response);
+		var resTagStr = response.getHeader(ETag.NAME);
+		var resTag = nn(resTagStr) ? EntityTag.of(resTagStr) : null;
+		var resLastModStr = response.getHeader(LastModified.NAME);
+		var resLastMod = nn(resLastModStr) ? parseHttpDate(resLastModStr) : null;
+
+		// RFC 7232 §6 step 1: If-Match (strong comparison; 412 on failure).
+		var ifMatch = getHeaderParam(IfMatch.NAME).orElse(null);
+		if (nn(ifMatch)) {
+			if (! matchesAnyStrong(EntityTags.of(ifMatch), resTag))
+				return Optional.of(preconditionFailed(IfMatch.NAME));
+		} else {
+			// RFC 7232 §6 step 2: If-Unmodified-Since (only when If-Match absent).
+			var ius = getHeaderParam(IfUnmodifiedSince.NAME).orElse(null);
+			if (nn(ius)) {
+				var iusDate = parseHttpDate(ius);
+				if (nn(iusDate) && nn(resLastMod) && resLastMod.toInstant().isAfter(iusDate.toInstant()))
+					return Optional.of(preconditionFailed(IfUnmodifiedSince.NAME));
+			}
+		}
+
+		// RFC 7232 §6 step 3: If-None-Match (weak comparison; 304 on match for safe methods).
+		var ifNoneMatch = getHeaderParam(IfNoneMatch.NAME).orElse(null);
+		if (nn(ifNoneMatch)) {
+			if (matchesAnyWeak(EntityTags.of(ifNoneMatch), resTag))
+				return Optional.of(notModified());
+		} else {
+			// RFC 7232 §6 step 4: If-Modified-Since (only when If-None-Match absent; only for GET/HEAD).
+			if (isSafeMethod()) {
+				var ims = getHeaderParam(IfModifiedSince.NAME).orElse(null);
+				if (nn(ims)) {
+					var imsDate = parseHttpDate(ims);
+					if (nn(imsDate) && nn(resLastMod) && ! resLastMod.toInstant().isAfter(imsDate.toInstant()))
+						return Optional.of(notModified());
+				}
+			}
+		}
+
+		return Optional.empty();
+	}
+
+	private boolean isSafeMethod() {
+		var m = getMethod();
+		return "GET".equalsIgnoreCase(m) || "HEAD".equalsIgnoreCase(m);
+	}
+
+	private static ZonedDateTime parseHttpDate(String value) {
+		try {
+			return ZonedDateTime.from(RFC_1123_DATE_TIME.parse(value)).truncatedTo(ChronoUnit.SECONDS);
+		} catch (@SuppressWarnings("unused") DateTimeParseException e) {
+			return null;
+		}
+	}
+
+	private static boolean matchesAnyStrong(EntityTags clientTags, EntityTag resTag) {
+		if (clientTags == null)
+			return false;
+		for (var t : clientTags.toArray()) {
+			if (t.isAny())
+				return resTag != null;
+			// Strong comparison: both sides must be strong AND opaque-tag values must be equal.
+			if (! t.isWeak() && resTag != null && ! resTag.isWeak() && eq(t.getEntityValue(), resTag.getEntityValue()))
+				return true;
+		}
+		return false;
+	}
+
+	private static boolean matchesAnyWeak(EntityTags clientTags, EntityTag resTag) {
+		if (clientTags == null)
+			return false;
+		for (var t : clientTags.toArray()) {
+			if (t.isAny())
+				return resTag != null;
+			// Weak comparison: opaque-tag values must be equal; weak/strong flag is ignored.
+			if (resTag != null && eq(t.getEntityValue(), resTag.getEntityValue()))
+				return true;
+		}
+		return false;
+	}
+
+	private static BasicHttpException notModified() {
+		return new BasicHttpException(NotModified.STATUS_CODE, NotModified.REASON_PHRASE);
+	}
+
+	private static PreconditionFailed preconditionFailed(String headerName) {
+		return new PreconditionFailed("Precondition ''{0}'' failed.", headerName);
+	}
+
 	/**
 	 * Returns <jk>true</jk> if this request contains the specified header.
 	 *
diff --git a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestResponse.java b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestResponse.java
index b966c94b77..8e0c3db3ca 100644
--- a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestResponse.java
+++ b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestResponse.java
@@ -18,12 +18,17 @@ package org.apache.juneau.rest;
 
 import org.apache.juneau.commons.http.StringRanges;
 import org.apache.juneau.commons.http.MediaType;
+import static org.apache.juneau.commons.utils.AssertionUtils.*;
 import static org.apache.juneau.commons.utils.StringUtils.*;
 import static org.apache.juneau.commons.utils.Utils.*;
 import static org.apache.juneau.commons.httppart.HttpPartType.*;
 
+import static java.time.format.DateTimeFormatter.RFC_1123_DATE_TIME;
+import static java.time.temporal.ChronoUnit.SECONDS;
+
 import java.io.*;
 import java.nio.charset.*;
+import java.time.*;
 import java.util.*;
 
 import org.apache.juneau.*;
@@ -81,6 +86,12 @@ import jakarta.servlet.http.*;
  * 			<li class='jm'>{@link RestResponse#setMaxHeaderLength(int) setMaxHeaderLength(int)}
  * 			<li class='jm'>{@link RestResponse#setSafeHeaders() setSafeHeaders()}
  * 			<li class='jm'>{@link RestResponse#downloadAs(String) downloadAs(String)}
+ * 			<li class='jm'>{@link RestResponse#eTag(String) eTag(String)}
+ * 			<li class='jm'>{@link RestResponse#eTag(EntityTag) eTag(EntityTag)}
+ * 			<li class='jm'>{@link RestResponse#lastModified(Instant) lastModified(Instant)}
+ * 			<li class='jm'>{@link RestResponse#lastModified(ZonedDateTime) lastModified(ZonedDateTime)}
+ * 			<li class='jm'>{@link RestResponse#cacheControl(String) cacheControl(String)}
+ * 			<li class='jm'>{@link RestResponse#cacheControl(CacheControlBuilder) cacheControl(CacheControlBuilder)}
  * 		</ul>
  * 		<li>Methods for setting response bodies:
  * 		<ul class='javatreec'>
@@ -119,6 +130,7 @@ import jakarta.servlet.http.*;
 public class RestResponse extends HttpServletResponseWrapper {
 
 	private static final String HEADER_ContentType = "Content-Type";
+	private static final String ARG_VALUE = "value";
 
 	private HttpServletResponse inner;
 	private final RestRequest request;
@@ -645,6 +657,9 @@ public class RestResponse extends HttpServletResponseWrapper {
 	 * 	negotiation on the next {@link #getSerializerMatch()} call.
 	 * @return This object.
 	 */
+	@SuppressWarnings({
+		"java:S2789" // Null invalidates lazy Optional cache so getSerializerMatch() recomputes next access.
+	})
 	public RestResponse setSerializer(Serializer value) {
 		serializer = value;
 		serializerMatch = null;
@@ -741,6 +756,120 @@ public class RestResponse extends HttpServletResponseWrapper {
 		return setHeader(ContentDisposition.attachment(filename));
 	}
 
+	/**
+	 * Sets the <c>ETag</c> response header to the supplied wire value.
+	 *
+	 * <p>
+	 * The value is validated through {@link EntityTag#of(Object)} — strong tags must be quoted
+	 * (<c>"v1"</c>), weak tags must be prefixed with <c>W/</c> (<c>W/"v1"</c>), and the wildcard
+	 * <c>*</c> is accepted. The header is serialized in canonical form (re-quoted for strong tags,
+	 * <c>W/</c>-prefixed for weak tags).
+	 *
+	 * <h5 class='section'>Example:</h5>
+	 * <p class='bjava'>
+	 * 	<jv>res</jv>.eTag(<js>"\"v42\""</js>);  <jc>// ETag: "v42"</jc>
+	 * 	<jv>res</jv>.eTag(<js>"W/\"v42\""</js>);  <jc>// ETag: W/"v42"</jc>
+	 * </p>
+	 *
+	 * @param value The entity-tag wire value. Must not be <jk>null</jk>; must match the
+	 * 	{@code entity-tag} grammar per RFC 7232 §2.3.
+	 * @return This object.
+	 * @throws IllegalArgumentException If {@code value} is malformed.
+	 * @see RestRequest#checkPreconditions(RestResponse)
+	 */
+	public RestResponse eTag(String value) {
+		return eTag(EntityTag.of(value));
+	}
+
+	/**
+	 * Sets the <c>ETag</c> response header to the canonical wire form of the supplied {@link EntityTag}.
+	 *
+	 * @param value The entity tag. Must not be <jk>null</jk>.
+	 * @return This object.
+	 * @throws IllegalArgumentException If {@code value} is <jk>null</jk>.
+	 */
+	public RestResponse eTag(EntityTag value) {
+		assertArgNotNull(ARG_VALUE, value);
+		return setHeader(ETag.of(value.toString()));
+	}
+
+	/**
+	 * Sets the <c>Last-Modified</c> response header to the supplied instant formatted as an
+	 * <a class='doclink' href='https://www.rfc-editor.org/rfc/rfc7231#section-7.1.1.1'>RFC 7231 IMF-fixdate</a>.
+	 *
+	 * <p>
+	 * The instant is rendered in GMT regardless of the JVM's default timezone. The fractional-second
+	 * portion is truncated, matching the per-spec one-second resolution of HTTP-date timestamps.
+	 *
+	 * <h5 class='section'>Example:</h5>
+	 * <p class='bjava'>
+	 * 	<jv>res</jv>.lastModified(Instant.<jsm>parse</jsm>(<js>"2026-05-22T00:00:00Z"</js>));
+	 * 	<jc>// Last-Modified: Fri, 22 May 2026 00:00:00 GMT</jc>
+	 * </p>
+	 *
+	 * @param value The last-modified timestamp. Must not be <jk>null</jk>.
+	 * @return This object.
+	 * @throws IllegalArgumentException If {@code value} is <jk>null</jk>.
+	 * @see RestRequest#checkPreconditions(RestResponse)
+	 */
+	public RestResponse lastModified(Instant value) {
+		assertArgNotNull(ARG_VALUE, value);
+		return lastModified(value.atZone(ZoneOffset.UTC));
+	}
+
+	/**
+	 * Sets the <c>Last-Modified</c> response header to the supplied date-time formatted as an
+	 * <a class='doclink' href='https://www.rfc-editor.org/rfc/rfc7231#section-7.1.1.1'>RFC 7231 IMF-fixdate</a>.
+	 *
+	 * <p>
+	 * The supplied date-time is converted to GMT and truncated to whole seconds before formatting.
+	 *
+	 * @param value The last-modified timestamp. Must not be <jk>null</jk>.
+	 * @return This object.
+	 * @throws IllegalArgumentException If {@code value} is <jk>null</jk>.
+	 */
+	public RestResponse lastModified(ZonedDateTime value) {
+		assertArgNotNull(ARG_VALUE, value);
+		var z = value.withZoneSameInstant(ZoneOffset.UTC).truncatedTo(SECONDS);
+		return setHeader(LastModified.of(RFC_1123_DATE_TIME.format(z)));
+	}
+
+	/**
+	 * Sets the <c>Cache-Control</c> response header to the supplied directive string.
+	 *
+	 * <p>
+	 * The value is written verbatim — the caller is responsible for supplying a comma-separated
+	 * directive list per <a class='doclink' href='https://www.rfc-editor.org/rfc/rfc9111#name-cache-control'>RFC
+	 * 9111 §5.2</a>.
+	 *
+	 * @param value The directive list (e.g. <js>"public, max-age=3600"</js>). Must not be <jk>null</jk>.
+	 * @return This object.
+	 * @throws IllegalArgumentException If {@code value} is <jk>null</jk>.
+	 * @see #cacheControl(CacheControlBuilder)
+	 */
+	public RestResponse cacheControl(String value) {
+		assertArgNotNull(ARG_VALUE, value);
+		return setHeader(CacheControl.of(value));
+	}
+
+	/**
+	 * Sets the <c>Cache-Control</c> response header from a {@link CacheControlBuilder}.
+	 *
+	 * <h5 class='section'>Example:</h5>
+	 * <p class='bjava'>
+	 * 	<jv>res</jv>.cacheControl(CacheControlBuilder.<jsm>create</jsm>().publicCache().maxAge(3600));
+	 * 	<jc>// Cache-Control: public, max-age=3600</jc>
+	 * </p>
+	 *
+	 * @param value The builder. Must not be <jk>null</jk>.
+	 * @return This object.
+	 * @throws IllegalArgumentException If {@code value} is <jk>null</jk>.
+	 */
+	public RestResponse cacheControl(CacheControlBuilder value) {
+		assertArgNotNull(ARG_VALUE, value);
+		return cacheControl(value.build());
+	}
+
 	/**
 	 * Sets a header on the request.
 	 *
diff --git a/juneau-utest/src/test/java/org/apache/juneau/http/header/NamedHeaders_Test.java b/juneau-utest/src/test/java/org/apache/juneau/http/header/NamedHeaders_Test.java
index fc69938242..99d03ab572 100644
--- a/juneau-utest/src/test/java/org/apache/juneau/http/header/NamedHeaders_Test.java
+++ b/juneau-utest/src/test/java/org/apache/juneau/http/header/NamedHeaders_Test.java
@@ -69,6 +69,8 @@ class NamedHeaders_Test extends TestBase {
 		// Value types (not HttpHeaderBean subclasses).
 		"EntityTag",
 		"EntityTags",
+		// Fluent builders (produce a header value String, not an HttpHeaderBean).
+		"CacheControlBuilder",
 		"package-info"
 	);
 
diff --git a/juneau-utest/src/test/java/org/apache/juneau/rest/RestEtag_RoundTrip_Test.java b/juneau-utest/src/test/java/org/apache/juneau/rest/RestEtag_RoundTrip_Test.java
new file mode 100644
index 0000000000..28a3d300dc
--- /dev/null
+++ b/juneau-utest/src/test/java/org/apache/juneau/rest/RestEtag_RoundTrip_Test.java
@@ -0,0 +1,115 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest;
+
+import java.time.*;
+import java.util.concurrent.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.http.annotation.*;
+import org.apache.juneau.json.*;
+import org.apache.juneau.rest.annotation.*;
+import org.apache.juneau.rest.mock.classic.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * End-to-end "round trip" tests for the ETag / conditional-GET helpers in a realistic handler:
+ *
+ * <ul>
+ *   <li>First request gets the resource with {@code 200 OK} plus {@code ETag} and {@code Last-Modified} headers.
+ *   <li>Second request re-sends the same {@code ETag} via {@code If-None-Match} and gets {@code 304 Not Modified}.
+ *   <li>An update bumps the version + timestamp; the next request gets {@code 200 OK} with the new tag/date.
+ *   <li>A {@code PUT} with a stale {@code If-Match} is rejected with {@code 412 Precondition Failed}.
+ * </ul>
+ */
+class RestEtag_RoundTrip_Test extends TestBase {
+
+	/** Trivial in-memory order with a version + updated-instant for ETag/Last-Modified. */
+	public static class Order {
+		public String id, payload;
+		public long version;
+		public Instant updated;
+		public Order() {}
+		public Order(String id, String payload, long version, Instant updated) {
+			this.id = id; this.payload = payload; this.version = version; this.updated = updated;
+		}
+	}
+
+	@Rest(serializers=JsonSerializer.class, parsers=JsonParser.class)
+	public static class R {
+		private final ConcurrentMap<String,Order> repo = new ConcurrentHashMap<>();
+
+		public R() {
+			repo.put("1", new Order("1", "v1-payload", 1L, Instant.parse("2026-05-22T00:00:00Z")));
+		}
+
+		@RestGet("/orders/{id}")
+		public Order get(@Path("id") String id, RestRequest req, RestResponse res) {
+			var order = repo.get(id);
+			res.eTag("\"" + order.version + "\"").lastModified(order.updated);
+			req.checkPreconditions(res).ifPresent(e -> { throw e; });
+			return order;
+		}
+
+		@RestPut("/orders/{id}")
+		public Order put(@Path("id") String id, @Content Order in, RestRequest req, RestResponse res) {
+			var current = repo.get(id);
+			res.eTag("\"" + current.version + "\"").lastModified(current.updated);
+			req.checkPreconditions(res).ifPresent(e -> { throw e; });
+			var next = new Order(id, in.payload, current.version + 1L, Instant.parse("2026-05-23T00:00:00Z"));
+			repo.put(id, next);
+			res.eTag("\"" + next.version + "\"").lastModified(next.updated);
+			return next;
+		}
+	}
+
+	@Test void a01_firstGetReturnsEtagAndLastModified() throws Exception {
+		var c = MockRestClient.buildLax(R.class);
+		c.get("/orders/1").run().assertStatus(200)
+			.assertHeader("ETag").is("\"1\"")
+			.assertHeader("Last-Modified").is("Fri, 22 May 2026 00:00:00 GMT");
+	}
+
+	@Test void a02_secondGetWithIfNoneMatchReturns304() throws Exception {
+		var c = MockRestClient.buildLax(R.class);
+		c.get("/orders/1").header("If-None-Match", "\"1\"").run().assertStatus(304);
+	}
+
+	@Test void a03_getWithIfModifiedSinceEqualReturns304() throws Exception {
+		var c = MockRestClient.buildLax(R.class);
+		c.get("/orders/1").header("If-Modified-Since", "Fri, 22 May 2026 00:00:00 GMT").run().assertStatus(304);
+	}
+
+	@Test void a04_putWithStaleIfMatchReturns412() throws Exception {
+		var c = MockRestClient.create(R.class).ignoreErrors().json().build();
+		// Stale version: client thinks the order is at version 0 (its real version is 1).
+		c.put("/orders/1", new Order("1", "bad", 0L, Instant.parse("2026-01-01T00:00:00Z")))
+			.header("If-Match", "\"0\"").run().assertStatus(412);
+	}
+
+	@Test void a05_putWithCorrectIfMatchSucceedsAndBumpsEtag() throws Exception {
+		var c = MockRestClient.create(R.class).ignoreErrors().json().build();
+		c.put("/orders/1", new Order("1", "updated", 1L, Instant.parse("2026-01-01T00:00:00Z")))
+			.header("If-Match", "\"1\"").run().assertStatus(200)
+			.assertHeader("ETag").is("\"2\"")
+			.assertHeader("Last-Modified").is("Sat, 23 May 2026 00:00:00 GMT");
+		// After the bump, the original tag no longer matches — the next GET with the old tag returns 200.
+		c.get("/orders/1").header("If-None-Match", "\"1\"").run().assertStatus(200);
+		// And the new tag matches → 304.
+		c.get("/orders/1").header("If-None-Match", "\"2\"").run().assertStatus(304);
+	}
+}
diff --git a/juneau-utest/src/test/java/org/apache/juneau/rest/RestRequest_CheckPreconditions_Test.java b/juneau-utest/src/test/java/org/apache/juneau/rest/RestRequest_CheckPreconditions_Test.java
new file mode 100644
index 0000000000..152cc0a1fe
--- /dev/null
+++ b/juneau-utest/src/test/java/org/apache/juneau/rest/RestRequest_CheckPreconditions_Test.java
@@ -0,0 +1,332 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest;
+
+import java.time.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.rest.annotation.*;
+import org.apache.juneau.rest.mock.classic.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Tests for {@link RestRequest#checkPreconditions(RestResponse)} — RFC 7232 conditional-request handling.
+ *
+ * <p>
+ * The test resource exposes endpoints that set well-known {@code ETag} and {@code Last-Modified} values on the
+ * response and then short-circuits on {@code checkPreconditions(res)}: if the returned {@code Optional} is non-empty
+ * the helper exception is thrown (yielding 304 / 412); otherwise the handler returns 200 with body {@code "OK"}.
+ */
+class RestRequest_CheckPreconditions_Test extends TestBase {
+
+	// Fixed reference values used across tests.
+	private static final String ETAG_STRONG = "\"v1\"";
+	private static final String ETAG_WEAK = "W/\"v1\"";
+	private static final String LM = "Fri, 22 May 2026 00:00:00 GMT";
+	private static final String LM_EARLIER = "Thu, 21 May 2026 00:00:00 GMT";
+	private static final String LM_LATER = "Sat, 23 May 2026 00:00:00 GMT";
+
+	@Rest
+	public static class R {
+		@RestGet("/strong")
+		public String strong(RestRequest req, RestResponse res) {
+			res.eTag(ETAG_STRONG).lastModified(Instant.parse("2026-05-22T00:00:00Z"));
+			req.checkPreconditions(res).ifPresent(e -> { throw e; });
+			return "OK";
+		}
+
+		@RestGet("/weak")
+		public String weak(RestRequest req, RestResponse res) {
+			res.eTag(ETAG_WEAK).lastModified(Instant.parse("2026-05-22T00:00:00Z"));
+			req.checkPreconditions(res).ifPresent(e -> { throw e; });
+			return "OK";
+		}
+
+		@RestGet("/lmOnly")
+		public String lmOnly(RestRequest req, RestResponse res) {
+			res.lastModified(Instant.parse("2026-05-22T00:00:00Z"));
+			req.checkPreconditions(res).ifPresent(e -> { throw e; });
+			return "OK";
+		}
+
+		@RestGet("/etagOnly")
+		public String etagOnly(RestRequest req, RestResponse res) {
+			res.eTag(ETAG_STRONG);
+			req.checkPreconditions(res).ifPresent(e -> { throw e; });
+			return "OK";
+		}
+
+		@RestGet("/bare")
+		public String bare(RestRequest req, RestResponse res) {
+			req.checkPreconditions(res).ifPresent(e -> { throw e; });
+			return "OK";
+		}
+
+		// Non-safe method to verify If-Modified-Since is ignored.
+		@RestPost("/postStrong")
+		public String postStrong(RestRequest req, RestResponse res) {
+			res.eTag(ETAG_STRONG).lastModified(Instant.parse("2026-05-22T00:00:00Z"));
+			req.checkPreconditions(res).ifPresent(e -> { throw e; });
+			return "OK";
+		}
+	}
+
+	//-----------------------------------------------------------------------------------------------------------------
+	// A: No conditional headers → 200 OK (baseline).
+	//-----------------------------------------------------------------------------------------------------------------
+
+	@Test void a01_noConditionalHeadersPasses() throws Exception {
+		var c = MockRestClient.buildLax(R.class);
+		c.get("/strong").run().assertStatus(200).assertContent("OK");
+	}
+
+	@Test void a02_bareNoEtagNoLmPasses() throws Exception {
+		var c = MockRestClient.buildLax(R.class);
+		c.get("/bare").run().assertStatus(200).assertContent("OK");
+	}
+
+	//-----------------------------------------------------------------------------------------------------------------
+	// B: If-Match (strong comparison; 412 on failure).
+	//-----------------------------------------------------------------------------------------------------------------
+
+	@Test void b01_ifMatchExactStrongMatches() throws Exception {
+		var c = MockRestClient.buildLax(R.class);
+		c.get("/strong").header("If-Match", ETAG_STRONG).run().assertStatus(200);
+	}
+
+	@Test void b02_ifMatchMismatchYields412() throws Exception {
+		var c = MockRestClient.buildLax(R.class);
+		c.get("/strong").header("If-Match", "\"v2\"").run().assertStatus(412);
+	}
+
+	@Test void b03_ifMatchWildcardWithEtagPresentMatches() throws Exception {
+		var c = MockRestClient.buildLax(R.class);
+		c.get("/strong").header("If-Match", "*").run().assertStatus(200);
+	}
+
+	@Test void b04_ifMatchWildcardWithNoEtagFails() throws Exception {
+		var c = MockRestClient.buildLax(R.class);
+		c.get("/lmOnly").header("If-Match", "*").run().assertStatus(412);
+	}
+
+	@Test void b05_ifMatchAgainstWeakEtagFails() throws Exception {
+		// RFC 7232 §3.1: If-Match requires strong comparison; weak server ETag never matches.
+		var c = MockRestClient.buildLax(R.class);
+		c.get("/weak").header("If-Match", ETAG_WEAK).run().assertStatus(412);
+		c.get("/weak").header("If-Match", "\"v1\"").run().assertStatus(412);
+	}
+
+	@Test void b06_ifMatchMultipleTagsAnyMatches() throws Exception {
+		var c = MockRestClient.buildLax(R.class);
+		c.get("/strong").header("If-Match", "\"v2\", \"v1\", \"v3\"").run().assertStatus(200);
+	}
+
+	//-----------------------------------------------------------------------------------------------------------------
+	// C: If-Unmodified-Since (only when If-Match absent; 412 if resource newer than supplied).
+	//-----------------------------------------------------------------------------------------------------------------
+
+	@Test void c01_ifUnmodifiedSinceLaterPasses() throws Exception {
+		var c = MockRestClient.buildLax(R.class);
+		c.get("/lmOnly").header("If-Unmodified-Since", LM_LATER).run().assertStatus(200);
+	}
+
+	@Test void c02_ifUnmodifiedSinceEqualPasses() throws Exception {
+		var c = MockRestClient.buildLax(R.class);
+		c.get("/lmOnly").header("If-Unmodified-Since", LM).run().assertStatus(200);
+	}
+
+	@Test void c03_ifUnmodifiedSinceEarlierFails() throws Exception {
+		var c = MockRestClient.buildLax(R.class);
+		c.get("/lmOnly").header("If-Unmodified-Since", LM_EARLIER).run().assertStatus(412);
+	}
+
+	@Test void c04_ifUnmodifiedSinceIgnoredWhenIfMatchPresent() throws Exception {
+		// Per RFC 7232 §6, If-Unmodified-Since is ignored when If-Match is set. Even if If-Unmodified-Since
+		// would fail in isolation, the If-Match must win (and here, succeed).
+		var c = MockRestClient.buildLax(R.class);
+		c.get("/strong")
+			.header("If-Match", ETAG_STRONG)
+			.header("If-Unmodified-Since", LM_EARLIER)  // would fail standalone
+			.run().assertStatus(200);
+	}
+
+	@Test void c05_ifUnmodifiedSinceWithoutResourceLastModifiedIgnored() throws Exception {
+		// No Last-Modified set on the response, so the date comparison is skipped.
+		var c = MockRestClient.buildLax(R.class);
+		c.get("/etagOnly").header("If-Unmodified-Since", LM_EARLIER).run().assertStatus(200);
+	}
+
+	@Test void c06_ifUnmodifiedSinceMalformedIgnored() throws Exception {
+		// Unparseable date is treated as not-set per Postel's law and the RFC ignores garbled dates.
+		var c = MockRestClient.buildLax(R.class);
+		c.get("/lmOnly").header("If-Unmodified-Since", "not-a-date").run().assertStatus(200);
+	}
+
+	//-----------------------------------------------------------------------------------------------------------------
+	// D: If-None-Match (weak comparison; 304 on match).
+	//-----------------------------------------------------------------------------------------------------------------
+
+	@Test void d01_ifNoneMatchExactMatches304() throws Exception {
+		var c = MockRestClient.buildLax(R.class);
+		c.get("/strong").header("If-None-Match", ETAG_STRONG).run().assertStatus(304);
+	}
+
+	@Test void d02_ifNoneMatchMismatchPasses200() throws Exception {
+		var c = MockRestClient.buildLax(R.class);
+		c.get("/strong").header("If-None-Match", "\"v2\"").run().assertStatus(200);
+	}
+
+	@Test void d03_ifNoneMatchWildcardWithEtag304() throws Exception {
+		var c = MockRestClient.buildLax(R.class);
+		c.get("/strong").header("If-None-Match", "*").run().assertStatus(304);
+	}
+
+	@Test void d04_ifNoneMatchWildcardWithoutEtagPasses() throws Exception {
+		var c = MockRestClient.buildLax(R.class);
+		c.get("/lmOnly").header("If-None-Match", "*").run().assertStatus(200);
+	}
+
+	@Test void d05_ifNoneMatchWeakStrongCompareSucceeds() throws Exception {
+		// RFC 7232 §3.2: If-None-Match uses weak comparison; weak vs strong both count as a match.
+		var c = MockRestClient.buildLax(R.class);
+		c.get("/strong").header("If-None-Match", ETAG_WEAK).run().assertStatus(304);
+		c.get("/weak").header("If-None-Match", ETAG_STRONG).run().assertStatus(304);
+		c.get("/weak").header("If-None-Match", ETAG_WEAK).run().assertStatus(304);
+	}
+
+	@Test void d06_ifNoneMatchMultipleAnyMatches304() throws Exception {
+		var c = MockRestClient.buildLax(R.class);
+		c.get("/strong").header("If-None-Match", "\"v2\", \"v1\", \"v3\"").run().assertStatus(304);
+	}
+
+	//-----------------------------------------------------------------------------------------------------------------
+	// E: If-Modified-Since (only when If-None-Match absent; only on GET/HEAD; 304 when not modified).
+	//-----------------------------------------------------------------------------------------------------------------
+
+	@Test void e01_ifModifiedSinceEqualReturns304() throws Exception {
+		var c = MockRestClient.buildLax(R.class);
+		c.get("/lmOnly").header("If-Modified-Since", LM).run().assertStatus(304);
+	}
+
+	@Test void e02_ifModifiedSinceLaterReturns304() throws Exception {
+		var c = MockRestClient.buildLax(R.class);
+		c.get("/lmOnly").header("If-Modified-Since", LM_LATER).run().assertStatus(304);
+	}
+
+	@Test void e03_ifModifiedSinceEarlierPasses200() throws Exception {
+		var c = MockRestClient.buildLax(R.class);
+		c.get("/lmOnly").header("If-Modified-Since", LM_EARLIER).run().assertStatus(200);
+	}
+
+	@Test void e04_ifModifiedSinceIgnoredWhenIfNoneMatchPresent() throws Exception {
+		// Per RFC 7232 §6, If-Modified-Since is ignored when If-None-Match is set.
+		var c = MockRestClient.buildLax(R.class);
+		c.get("/strong")
+			.header("If-None-Match", "\"v2\"")          // mismatch → would pass standalone
+			.header("If-Modified-Since", LM)             // would 304 standalone
+			.run().assertStatus(200);                    // If-None-Match wins, no 304
+	}
+
+	@Test void e05_ifModifiedSinceIgnoredOnPost() throws Exception {
+		// Only GET / HEAD honor If-Modified-Since per RFC 7232 §6.
+		var c = MockRestClient.buildLax(R.class);
+		c.post("/postStrong", "x").header("If-Modified-Since", LM).run().assertStatus(200);
+	}
+
+	@Test void e06_ifModifiedSinceWithoutResourceLastModifiedPasses() throws Exception {
+		var c = MockRestClient.buildLax(R.class);
+		c.get("/etagOnly").header("If-Modified-Since", LM).run().assertStatus(200);
+	}
+
+	@Test void e07_ifModifiedSinceMalformedIgnored() throws Exception {
+		var c = MockRestClient.buildLax(R.class);
+		c.get("/lmOnly").header("If-Modified-Since", "not-a-date").run().assertStatus(200);
+	}
+
+	//-----------------------------------------------------------------------------------------------------------------
+	// F: Combinations of all four conditional headers — 16-cell precedence matrix.
+	//
+	// Layout: (M=If-Match, U=If-Unmodified-Since, N=If-None-Match, S=If-Modified-Since)
+	//   - present-with-pass and present-with-fail are exercised in B/C/D/E.
+	//   - here we exercise the cross-products that depend on RFC 7232 §6 ordering.
+	//-----------------------------------------------------------------------------------------------------------------
+
+	@Test void f01_allFourPass_returns200() throws Exception {
+		var c = MockRestClient.buildLax(R.class);
+		c.get("/strong")
+			.header("If-Match", ETAG_STRONG)
+			.header("If-Unmodified-Since", LM_LATER)
+			.header("If-None-Match", "\"v2\"")
+			.header("If-Modified-Since", LM_EARLIER)
+			.run().assertStatus(200);
+	}
+
+	@Test void f02_ifMatchFails_returns412_evenWithNoneMatchPass() throws Exception {
+		// If-Match short-circuits at step 1; If-None-Match steps are not consulted.
+		var c = MockRestClient.buildLax(R.class);
+		c.get("/strong")
+			.header("If-Match", "\"v2\"")
+			.header("If-None-Match", ETAG_STRONG)  // would also 304 standalone
+			.run().assertStatus(412);
+	}
+
+	@Test void f03_ifMatchPasses_thenIfNoneMatchTriggers304() throws Exception {
+		// If-Match passes (step 1), If-Unmodified-Since skipped (step 2), If-None-Match triggers (step 3).
+		var c = MockRestClient.buildLax(R.class);
+		c.get("/strong")
+			.header("If-Match", ETAG_STRONG)
+			.header("If-None-Match", ETAG_STRONG)
+			.run().assertStatus(304);
+	}
+
+	@Test void f04_unmodifiedSincePassed_thenModifiedSinceTriggers304() throws Exception {
+		// If-Match absent → If-Unmodified-Since checked (passes). If-None-Match absent → If-Modified-Since
+		// triggers 304.
+		var c = MockRestClient.buildLax(R.class);
+		c.get("/lmOnly")
+			.header("If-Unmodified-Since", LM_LATER)
+			.header("If-Modified-Since", LM)
+			.run().assertStatus(304);
+	}
+
+	@Test void f05_unmodifiedSinceFails_412_beforeModifiedSinceEvaluated() throws Exception {
+		// If-Unmodified-Since fails at step 2; If-Modified-Since never consulted.
+		var c = MockRestClient.buildLax(R.class);
+		c.get("/lmOnly")
+			.header("If-Unmodified-Since", LM_EARLIER)
+			.header("If-Modified-Since", LM_LATER)  // would 304 standalone
+			.run().assertStatus(412);
+	}
+
+	//-----------------------------------------------------------------------------------------------------------------
+	// G: Null-argument guard.
+	//-----------------------------------------------------------------------------------------------------------------
+
+	@Rest
+	public static class G {
+		@RestGet("/nullResponse")
+		public String nullResponse(RestRequest req) {
+			req.checkPreconditions(null);
+			return "OK";
+		}
+	}
+
+	@Test void g01_nullResponseRejected() throws Exception {
+		var c = MockRestClient.buildLax(G.class);
+		c.get("/nullResponse").run().assertStatus(500);
+	}
+}
diff --git a/juneau-utest/src/test/java/org/apache/juneau/rest/RestResponse_EtagHelpers_Test.java b/juneau-utest/src/test/java/org/apache/juneau/rest/RestResponse_EtagHelpers_Test.java
new file mode 100644
index 0000000000..3d19684f00
--- /dev/null
+++ b/juneau-utest/src/test/java/org/apache/juneau/rest/RestResponse_EtagHelpers_Test.java
@@ -0,0 +1,364 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.time.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.http.header.*;
+import org.apache.juneau.rest.annotation.*;
+import org.apache.juneau.rest.mock.classic.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Tests for the conditional-GET response helpers on {@link RestResponse}:
+ * {@code eTag(...)}, {@code lastModified(...)}, and {@code cacheControl(...)}.
+ */
+class RestResponse_EtagHelpers_Test extends TestBase {
+
+	//-----------------------------------------------------------------------------------------------------------------
+	// A: eTag(...) — strong vs weak vs wildcard wire-format
+	//-----------------------------------------------------------------------------------------------------------------
+
+	@Rest
+	public static class A {
+		@RestGet("/strongString")
+		public String strongString(RestResponse res) {
+			res.eTag("\"v42\"");
+			return "ok";
+		}
+
+		@RestGet("/weakString")
+		public String weakString(RestResponse res) {
+			res.eTag("W/\"v42\"");
+			return "ok";
+		}
+
+		@RestGet("/wildcardString")
+		public String wildcardString(RestResponse res) {
+			res.eTag("*");
+			return "ok";
+		}
+
+		@RestGet("/strongTyped")
+		public String strongTyped(RestResponse res) {
+			res.eTag(EntityTag.of("\"v42\""));
+			return "ok";
+		}
+
+		@RestGet("/weakTyped")
+		public String weakTyped(RestResponse res) {
+			res.eTag(EntityTag.of("W/\"v42\""));
+			return "ok";
+		}
+	}
+
+	@Test void a01_strongStringEmitsQuoted() throws Exception {
+		var a = MockRestClient.buildLax(A.class);
+		a.get("/strongString").run().assertStatus(200).assertHeader("ETag").is("\"v42\"");
+	}
+
+	@Test void a02_weakStringEmitsWeakPrefix() throws Exception {
+		var a = MockRestClient.buildLax(A.class);
+		a.get("/weakString").run().assertStatus(200).assertHeader("ETag").is("W/\"v42\"");
+	}
+
+	@Test void a03_wildcardStringEmitsStar() throws Exception {
+		var a = MockRestClient.buildLax(A.class);
+		a.get("/wildcardString").run().assertStatus(200).assertHeader("ETag").is("*");
+	}
+
+	@Test void a04_strongTypedEmitsQuoted() throws Exception {
+		var a = MockRestClient.buildLax(A.class);
+		a.get("/strongTyped").run().assertStatus(200).assertHeader("ETag").is("\"v42\"");
+	}
+
+	@Test void a05_weakTypedEmitsWeakPrefix() throws Exception {
+		var a = MockRestClient.buildLax(A.class);
+		a.get("/weakTyped").run().assertStatus(200).assertHeader("ETag").is("W/\"v42\"");
+	}
+
+	@Test void a06_malformedStringRejected() {
+		// Direct call into EntityTag.of validates - bare unquoted tags throw.
+		assertThrows(IllegalArgumentException.class, () -> EntityTag.of("v42"));
+	}
+
+	//-----------------------------------------------------------------------------------------------------------------
+	// B: lastModified(...) — IMF-fixdate formatting, instant vs zoned, UTC conversion, truncation
+	//-----------------------------------------------------------------------------------------------------------------
+
+	@Rest
+	public static class B {
+		@RestGet("/fromInstant")
+		public String fromInstant(RestResponse res) {
+			res.lastModified(Instant.parse("2026-05-22T00:00:00Z"));
+			return "ok";
+		}
+
+		@RestGet("/fromZonedUtc")
+		public String fromZonedUtc(RestResponse res) {
+			res.lastModified(ZonedDateTime.parse("2026-05-22T00:00:00Z"));
+			return "ok";
+		}
+
+		@RestGet("/fromZonedOffset")
+		public String fromZonedOffset(RestResponse res) {
+			// 02:30 +02:30 = 00:00 UTC ⇒ same wire result as fromZonedUtc.
+			res.lastModified(ZonedDateTime.parse("2026-05-22T02:30:00+02:30"));
+			return "ok";
+		}
+
+		@RestGet("/truncatesFraction")
+		public String truncatesFraction(RestResponse res) {
+			res.lastModified(Instant.parse("2026-05-22T00:00:00.987654321Z"));
+			return "ok";
+		}
+	}
+
+	@Test void b01_instantFormattedAsImfFixdate() throws Exception {
+		var a = MockRestClient.buildLax(B.class);
+		a.get("/fromInstant").run().assertStatus(200)
+			.assertHeader("Last-Modified").is("Fri, 22 May 2026 00:00:00 GMT");
+	}
+
+	@Test void b02_zonedUtcFormattedAsImfFixdate() throws Exception {
+		var a = MockRestClient.buildLax(B.class);
+		a.get("/fromZonedUtc").run().assertStatus(200)
+			.assertHeader("Last-Modified").is("Fri, 22 May 2026 00:00:00 GMT");
+	}
+
+	@Test void b03_zonedOffsetConvertedToUtc() throws Exception {
+		var a = MockRestClient.buildLax(B.class);
+		a.get("/fromZonedOffset").run().assertStatus(200)
+			.assertHeader("Last-Modified").is("Fri, 22 May 2026 00:00:00 GMT");
+	}
+
+	@Test void b04_fractionalSecondsTruncated() throws Exception {
+		var a = MockRestClient.buildLax(B.class);
+		a.get("/truncatesFraction").run().assertStatus(200)
+			.assertHeader("Last-Modified").is("Fri, 22 May 2026 00:00:00 GMT");
+	}
+
+	//-----------------------------------------------------------------------------------------------------------------
+	// C: cacheControl(...) — string vs builder
+	//-----------------------------------------------------------------------------------------------------------------
+
+	@Rest
+	public static class C {
+		@RestGet("/fromString")
+		public String fromString(RestResponse res) {
+			res.cacheControl("public, max-age=3600");
+			return "ok";
+		}
+
+		@RestGet("/fromBuilder")
+		public String fromBuilder(RestResponse res) {
+			res.cacheControl(CacheControlBuilder.create().publicCache().maxAge(3600).mustRevalidate());
+			return "ok";
+		}
+
+		@RestGet("/privateNoStore")
+		public String privateNoStore(RestResponse res) {
+			res.cacheControl(CacheControlBuilder.create().privateCache().noStore());
+			return "ok";
+		}
+
+		@RestGet("/immutable")
+		public String immutable(RestResponse res) {
+			res.cacheControl(CacheControlBuilder.create().publicCache().maxAge(31536000L).immutable());
+			return "ok";
+		}
+	}
+
+	@Test void c01_stringFormVerbatim() throws Exception {
+		var a = MockRestClient.buildLax(C.class);
+		a.get("/fromString").run().assertStatus(200).assertHeader("Cache-Control").is("public, max-age=3600");
+	}
+
+	@Test void c02_builderFormJoinedCommaSeparated() throws Exception {
+		var a = MockRestClient.buildLax(C.class);
+		// Builder emits a stable order: cacheability → boolean directives → numeric directives → extensions.
+		a.get("/fromBuilder").run().assertStatus(200)
+			.assertHeader("Cache-Control").is("public, must-revalidate, max-age=3600");
+	}
+
+	@Test void c03_builderPrivateNoStore() throws Exception {
+		var a = MockRestClient.buildLax(C.class);
+		a.get("/privateNoStore").run().assertStatus(200)
+			.assertHeader("Cache-Control").is("private, no-store");
+	}
+
+	@Test void c04_builderImmutableMaxAge() throws Exception {
+		var a = MockRestClient.buildLax(C.class);
+		// Booleans before numbers in the stable emission order.
+		a.get("/immutable").run().assertStatus(200)
+			.assertHeader("Cache-Control").is("public, immutable, max-age=31536000");
+	}
+
+	//-----------------------------------------------------------------------------------------------------------------
+	// D: CacheControlBuilder unit checks — directives, ordering, error paths
+	//-----------------------------------------------------------------------------------------------------------------
+
+	@Test void d01_emptyBuilderEmitsEmptyString() {
+		assertEquals("", CacheControlBuilder.create().build());
+	}
+
+	@Test void d02_publicOverridesPrivate() {
+		var s = CacheControlBuilder.create().privateCache().publicCache().build();
+		assertEquals("public", s);
+	}
+
+	@Test void d03_privateOverridesPublic() {
+		var s = CacheControlBuilder.create().publicCache().privateCache().build();
+		assertEquals("private", s);
+	}
+
+	@Test void d04_allBooleanDirectives() {
+		// Stable emission order: public/private, then boolean directives in source order.
+		var s = CacheControlBuilder.create()
+			.publicCache()
+			.noCache().noStore().noTransform()
+			.mustRevalidate().proxyRevalidate()
+			.immutable()
+			.build();
+		assertEquals("public, no-cache, no-store, no-transform, must-revalidate, proxy-revalidate, immutable", s);
+	}
+
+	@Test void d05_maxAgeAndSMaxAge() {
+		var s = CacheControlBuilder.create()
+			.maxAge(60)
+			.sMaxAge(120)
+			.build();
+		assertEquals("max-age=60, s-maxage=120", s);
+	}
+
+	@Test void d06_maxAgeFromDuration() {
+		var s = CacheControlBuilder.create()
+			.maxAge(Duration.ofMinutes(10))
+			.sMaxAge(Duration.ofMinutes(15))
+			.build();
+		assertEquals("max-age=600, s-maxage=900", s);
+	}
+
+	@Test void d07_staleWhileRevalidateAndStaleIfError() {
+		var s = CacheControlBuilder.create()
+			.staleWhileRevalidate(30)
+			.staleIfError(60)
+			.build();
+		assertEquals("stale-while-revalidate=30, stale-if-error=60", s);
+	}
+
+	@Test void d08_extensionAppendedAtEnd() {
+		var s = CacheControlBuilder.create()
+			.publicCache().maxAge(3600)
+			.extension("community=\"UCI\"")
+			.build();
+		assertEquals("public, max-age=3600, community=\"UCI\"", s);
+	}
+
+	@Test void d09_extensionRejectsBlank() {
+		assertThrows(IllegalArgumentException.class, () -> CacheControlBuilder.create().extension(""));
+		assertThrows(IllegalArgumentException.class, () -> CacheControlBuilder.create().extension("   "));
+	}
+
+	@Test void d10_extensionRejectsNull() {
+		assertThrows(IllegalArgumentException.class, () -> CacheControlBuilder.create().extension(null));
+	}
+
+	@Test void d11_negativeMaxAgeRejected() {
+		assertThrows(IllegalArgumentException.class, () -> CacheControlBuilder.create().maxAge(-1));
+		assertThrows(IllegalArgumentException.class, () -> CacheControlBuilder.create().sMaxAge(-1));
+		assertThrows(IllegalArgumentException.class, () -> CacheControlBuilder.create().staleWhileRevalidate(-1));
+		assertThrows(IllegalArgumentException.class, () -> CacheControlBuilder.create().staleIfError(-1));
+	}
+
+	@Test void d12_nullDurationRejected() {
+		assertThrows(IllegalArgumentException.class, () -> CacheControlBuilder.create().maxAge((Duration)null));
+		assertThrows(IllegalArgumentException.class, () -> CacheControlBuilder.create().sMaxAge((Duration)null));
+	}
+
+	@Test void d13_toHeaderReturnsCacheControlBean() {
+		var h = CacheControlBuilder.create().publicCache().maxAge(60).toHeader();
+		assertNotNull(h);
+		assertEquals("Cache-Control", h.getName());
+		assertEquals("public, max-age=60", h.getValue());
+	}
+
+	@Test void d14_toStringMatchesBuild() {
+		var b = CacheControlBuilder.create().publicCache().maxAge(60);
+		assertEquals(b.build(), b.toString());
+	}
+
+	//-----------------------------------------------------------------------------------------------------------------
+	// E: Null-argument guards on RestResponse helpers (cover the assertArgNotNull branches).
+	//-----------------------------------------------------------------------------------------------------------------
+
+	@Rest
+	public static class E {
+		@RestGet("/etagNullString") public String etagNullString(RestResponse res) {
+			res.eTag((String)null); return "ok";
+		}
+		@RestGet("/etagNullTyped") public String etagNullTyped(RestResponse res) {
+			res.eTag((EntityTag)null); return "ok";
+		}
+		@RestGet("/lmNullInstant") public String lmNullInstant(RestResponse res) {
+			res.lastModified((Instant)null); return "ok";
+		}
+		@RestGet("/lmNullZdt") public String lmNullZdt(RestResponse res) {
+			res.lastModified((ZonedDateTime)null); return "ok";
+		}
+		@RestGet("/ccNullString") public String ccNullString(RestResponse res) {
+			res.cacheControl((String)null); return "ok";
+		}
+		@RestGet("/ccNullBuilder") public String ccNullBuilder(RestResponse res) {
+			res.cacheControl((CacheControlBuilder)null); return "ok";
+		}
+	}
+
+	@Test void e01_nullEtagStringYields500() throws Exception {
+		// EntityTag.of(null) returns null, then assertArgNotNull triggers IllegalArgumentException → 500.
+		var a = MockRestClient.buildLax(E.class);
+		a.get("/etagNullString").run().assertStatus(500);
+	}
+
+	@Test void e02_nullEtagTypedYields500() throws Exception {
+		var a = MockRestClient.buildLax(E.class);
+		a.get("/etagNullTyped").run().assertStatus(500);
+	}
+
+	@Test void e03_nullInstantYields500() throws Exception {
+		var a = MockRestClient.buildLax(E.class);
+		a.get("/lmNullInstant").run().assertStatus(500);
+	}
+
+	@Test void e04_nullZdtYields500() throws Exception {
+		var a = MockRestClient.buildLax(E.class);
+		a.get("/lmNullZdt").run().assertStatus(500);
+	}
+
+	@Test void e05_nullCacheControlStringYields500() throws Exception {
+		var a = MockRestClient.buildLax(E.class);
+		a.get("/ccNullString").run().assertStatus(500);
+	}
+
+	@Test void e06_nullCacheControlBuilderYields500() throws Exception {
+		var a = MockRestClient.buildLax(E.class);
+		a.get("/ccNullBuilder").run().assertStatus(500);
+	}
+}
diff --git a/todo/TODO-64-etag-conditional-get-helpers.md b/todo/FINISHED-64-etag-conditional-get-helpers.md
similarity index 83%
rename from todo/TODO-64-etag-conditional-get-helpers.md
rename to todo/FINISHED-64-etag-conditional-get-helpers.md
index 2926a99782..df3dcc4a79 100644
--- a/todo/TODO-64-etag-conditional-get-helpers.md
+++ b/todo/FINISHED-64-etag-conditional-get-helpers.md
@@ -1,4 +1,6 @@
-# TODO-64: Conditional-GET / ETag / `If-Modified-Since` helpers on `RestResponse`
+# FINISHED-64: Conditional-GET / ETag / `If-Modified-Since` helpers on `RestResponse`
+
+**Completed 2026-05-22.** Shipped the RFC 7232 conditional-request layer end-to-end: six fluent setters on `RestResponse` (`eTag(String)` / `eTag(EntityTag)`, `lastModified(Instant)` / `lastModified(ZonedDateTime)`, `cacheControl(String)` / `cacheControl(CacheControlBuilder)`), a new typed `CacheControlBuilder` in `juneau-rest-common` covering every standard directive (`public` / `private`, `no-cache` / `no-store` / `no-transform`, `must-revalidate` / `proxy-revalidate`, `immutable`, `ma [...]
 
 Source: split out of TODO-18 brainstorm on 2026-05-22.
 
diff --git a/todo/TODO.md b/todo/TODO.md
index dbfb042a21..3ba3250d0d 100644
--- a/todo/TODO.md
+++ b/todo/TODO.md
@@ -6,8 +6,6 @@
 
 - [TODO-37] - Agent instruction consolidation.
 
-- [TODO-64] Conditional-GET / ETag / `If-Modified-Since` helpers on `RestResponse`. See `todo/TODO-64-etag-conditional-get-helpers.md`.
-
 - [TODO-65] Health / readiness / liveness probe endpoints + `HealthIndicator` SPI. See `todo/TODO-65-health-readiness-liveness-probes.md`.
 
 - [TODO-66] Rate-limit guard + request-id propagation filter. See `todo/TODO-66-rate-limit-and-request-id.md`.