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 <[email protected]>
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>// => "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> -> {
<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 -> { 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`.