diff --git a/spring-web/src/main/java/org/springframework/http/ETag.java b/spring-web/src/main/java/org/springframework/http/ETag.java
new file mode 100644
index 000000000000..484a65bb6d7f
--- /dev/null
+++ b/spring-web/src/main/java/org/springframework/http/ETag.java
@@ -0,0 +1,77 @@
+/*
+ * Copyright 2002-2024 the original author or authors.
+ *
+ * Licensed 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
+ *
+ * https://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.springframework.http;
+
+import org.springframework.util.Assert;
+
+/**
+ * ETag header value holder.
+ *
+ * @author Riley Park
+ * @since TODO
+ * @param value value that uniquely represents the resource
+ * @param weak if weak validation should be used
+ * @see ETag Header
+ */
+public record ETag(
+ String value,
+ boolean weak
+) {
+
+ /**
+ * ETag prefix.
+ *
+ * @see ETag Header Directives
+ */
+ public static final String PREFIX = "\"";
+
+ /**
+ * ETag prefix, with a weak validator.
+ *
+ * @see ETag Header Directives
+ * @see Weak Validation
+ */
+ public static final String PREFIX_WEAK = "W/\"";
+
+ /**
+ * ETag suffix.
+ *
+ * @see ETag Header Directives
+ */
+ public static final String SUFFIX = "\"";
+
+ /**
+ * Parses an {@code ETag} header value as defined in RFC 7232.
+ * @param etag the {@literal ETag} header value
+ * @return the parsed content disposition
+ * @see #toString()
+ */
+ public static ETag parse(String etag) {
+ boolean weak = etag.startsWith(PREFIX_WEAK);
+ Assert.isTrue(etag.startsWith(PREFIX) || weak,
+ "Invalid ETag: does not start with " + PREFIX + " or " + PREFIX_WEAK);
+ Assert.isTrue(etag.endsWith(SUFFIX), "Invalid ETag: does not end with " + SUFFIX);
+ int start = (weak ? PREFIX_WEAK.length() : PREFIX.length());
+ String value = etag.substring(start, etag.length() - SUFFIX.length());
+ return new ETag(value, weak);
+ }
+
+ public String toHeaderValue() {
+ return (weak ? PREFIX_WEAK : PREFIX) + value + SUFFIX;
+ }
+
+}
diff --git a/spring-web/src/main/java/org/springframework/http/HttpHeaders.java b/spring-web/src/main/java/org/springframework/http/HttpHeaders.java
index 635453ab834d..9aa9565cd775 100644
--- a/spring-web/src/main/java/org/springframework/http/HttpHeaders.java
+++ b/spring-web/src/main/java/org/springframework/http/HttpHeaders.java
@@ -1072,11 +1072,16 @@ public long getDate() {
* Set the (new) entity tag of the body, as specified by the {@code ETag} header.
*/
public void setETag(@Nullable String etag) {
+ setETag((etag != null) ? ETag.parse(etag) : null);
+ }
+
+ /**
+ * Set the (new) entity tag of the body, as specified by the {@code ETag} header.
+ * @since TODO
+ */
+ public void setETag(@Nullable ETag etag) {
if (etag != null) {
- Assert.isTrue(etag.startsWith("\"") || etag.startsWith("W/"),
- "Invalid ETag: does not start with W/ or \"");
- Assert.isTrue(etag.endsWith("\""), "Invalid ETag: does not end with \"");
- set(ETAG, etag);
+ set(ETAG, etag.toHeaderValue());
}
else {
remove(ETAG);
diff --git a/spring-web/src/main/java/org/springframework/http/ResponseEntity.java b/spring-web/src/main/java/org/springframework/http/ResponseEntity.java
index 350e11a2aba0..805fec08e1b4 100644
--- a/spring-web/src/main/java/org/springframework/http/ResponseEntity.java
+++ b/spring-web/src/main/java/org/springframework/http/ResponseEntity.java
@@ -406,6 +406,15 @@ public interface HeadersBuilder> {
*/
B eTag(@Nullable String etag);
+ /**
+ * Set the entity tag of the body, as specified by the {@code ETag} header.
+ * @since TODO
+ * @param etag the new entity tag
+ * @return this builder
+ * @see HttpHeaders#setETag(ETag)
+ */
+ B eTag(@Nullable ETag etag);
+
/**
* Set the time the resource was last changed, as specified by the
* {@code Last-Modified} header.
@@ -569,14 +578,12 @@ public BodyBuilder contentType(MediaType contentType) {
@Override
public BodyBuilder eTag(@Nullable String etag) {
- if (etag != null) {
- if (!etag.startsWith("\"") && !etag.startsWith("W/\"")) {
- etag = "\"" + etag;
- }
- if (!etag.endsWith("\"")) {
- etag = etag + "\"";
- }
- }
+ this.headers.setETag(etag);
+ return this;
+ }
+
+ @Override
+ public BodyBuilder eTag(@Nullable ETag etag) {
this.headers.setETag(etag);
return this;
}
diff --git a/spring-web/src/main/java/org/springframework/web/server/adapter/DefaultServerWebExchange.java b/spring-web/src/main/java/org/springframework/web/server/adapter/DefaultServerWebExchange.java
index c17beb484b8b..e6afc4d59e79 100644
--- a/spring-web/src/main/java/org/springframework/web/server/adapter/DefaultServerWebExchange.java
+++ b/spring-web/src/main/java/org/springframework/web/server/adapter/DefaultServerWebExchange.java
@@ -33,6 +33,7 @@
import org.springframework.context.i18n.LocaleContext;
import org.springframework.core.ResolvableType;
import org.springframework.core.codec.Hints;
+import org.springframework.http.ETag;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
@@ -368,10 +369,10 @@ private String padEtagIfNecessary(@Nullable String etag) {
if (!StringUtils.hasLength(etag)) {
return etag;
}
- if ((etag.startsWith("\"") || etag.startsWith("W/\"")) && etag.endsWith("\"")) {
+ if ((etag.startsWith(ETag.PREFIX) || etag.startsWith(ETag.PREFIX_WEAK)) && etag.endsWith(ETag.SUFFIX)) {
return etag;
}
- return "\"" + etag + "\"";
+ return ETag.PREFIX + etag + ETag.SUFFIX;
}
private boolean eTagStrongMatch(@Nullable String first, @Nullable String second) {
diff --git a/spring-web/src/test/java/org/springframework/http/ETagTests.java b/spring-web/src/test/java/org/springframework/http/ETagTests.java
new file mode 100644
index 000000000000..3ed5e24640dd
--- /dev/null
+++ b/spring-web/src/test/java/org/springframework/http/ETagTests.java
@@ -0,0 +1,54 @@
+/*
+ * Copyright 2002-2024 the original author or authors.
+ *
+ * Licensed 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
+ *
+ * https://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.springframework.http;
+
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
+
+/**
+ * Tests for {@link ETag}.
+ * @author Riley Park
+ */
+class ETagTests {
+
+ @Test
+ void parse() {
+ String raw = "\"v2.6\"";
+ ETag etag = ETag.parse(raw);
+ assertThat(etag.value()).isEqualTo("v2.6");
+ assertThat(etag.weak()).isFalse();
+ assertThat(etag.toHeaderValue()).isEqualTo(raw);
+ }
+
+ @Test
+ void parseWeak() {
+ String raw = "W/\"v2.6\"";
+ ETag etag = ETag.parse(raw);
+ assertThat(etag.value()).isEqualTo("v2.6");
+ assertThat(etag.weak()).isTrue();
+ assertThat(etag.toHeaderValue()).isEqualTo(raw);
+ }
+
+ @Test
+ void illegalETagWithoutQuoteAfterWSlash() {
+ String raw = "W/v2.6\"";
+ assertThatIllegalArgumentException().isThrownBy(() -> ETag.parse(raw));
+ }
+
+}
diff --git a/spring-web/src/test/java/org/springframework/http/HttpHeadersTests.java b/spring-web/src/test/java/org/springframework/http/HttpHeadersTests.java
index 519a6c9d7834..21dfdc74af38 100644
--- a/spring-web/src/test/java/org/springframework/http/HttpHeadersTests.java
+++ b/spring-web/src/test/java/org/springframework/http/HttpHeadersTests.java
@@ -189,6 +189,14 @@ void eTag() {
assertThat(headers.getFirst("ETag")).as("Invalid ETag header").isEqualTo("\"v2.6\"");
}
+ @Test
+ void eTagW() {
+ String eTag = "W\"v2.6\"";
+ headers.setETag(eTag);
+ assertThat(headers.getETag()).as("Invalid ETag header").isEqualTo(eTag);
+ assertThat(headers.getFirst("ETag")).as("Invalid ETag header").isEqualTo("W\"v2.6\"");
+ }
+
@Test
void host() {
InetSocketAddress host = InetSocketAddress.createUnresolved("localhost", 8080);
@@ -219,6 +227,12 @@ void illegalETag() {
assertThatIllegalArgumentException().isThrownBy(() -> headers.setETag(eTag));
}
+ @Test
+ void illegalETagWithoutQuoteAfterWSlash() {
+ String eTag = "W/v2.6\"";
+ assertThatIllegalArgumentException().isThrownBy(() -> headers.setETag(eTag));
+ }
+
@Test
void ifMatch() {
String ifMatch = "\"v2.6\"";
diff --git a/spring-web/src/test/java/org/springframework/http/ResponseEntityTests.java b/spring-web/src/test/java/org/springframework/http/ResponseEntityTests.java
index 7c52e12edf19..e6657e05a023 100644
--- a/spring-web/src/test/java/org/springframework/http/ResponseEntityTests.java
+++ b/spring-web/src/test/java/org/springframework/http/ResponseEntityTests.java
@@ -223,7 +223,7 @@ void Etagheader() {
responseEntity = ResponseEntity.ok().eTag("W/\"foo\"").build();
assertThat(responseEntity.getHeaders().getETag()).isEqualTo("W/\"foo\"");
- responseEntity = ResponseEntity.ok().eTag(null).build();
+ responseEntity = ResponseEntity.ok().eTag((String) null).build();
assertThat(responseEntity.getHeaders().getETag()).isNull();
}
diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/DefaultEntityResponseBuilder.java b/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/DefaultEntityResponseBuilder.java
index 87697d0da1ef..6b69d4874f31 100644
--- a/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/DefaultEntityResponseBuilder.java
+++ b/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/DefaultEntityResponseBuilder.java
@@ -32,6 +32,7 @@
import org.springframework.core.codec.Hints;
import org.springframework.http.CacheControl;
+import org.springframework.http.ETag;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
@@ -148,12 +149,12 @@ public EntityResponse.Builder contentType(MediaType contentType) {
@Override
public EntityResponse.Builder eTag(String etag) {
- if (!etag.startsWith("\"") && !etag.startsWith("W/\"")) {
- etag = "\"" + etag;
- }
- if (!etag.endsWith("\"")) {
- etag = etag + "\"";
- }
+ this.headers.setETag(etag);
+ return this;
+ }
+
+ @Override
+ public EntityResponse.Builder eTag(ETag etag) {
this.headers.setETag(etag);
return this;
}
diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/DefaultServerResponseBuilder.java b/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/DefaultServerResponseBuilder.java
index 9f0df3b736de..a13f178c9689 100644
--- a/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/DefaultServerResponseBuilder.java
+++ b/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/DefaultServerResponseBuilder.java
@@ -37,6 +37,7 @@
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.core.codec.Hints;
import org.springframework.http.CacheControl;
+import org.springframework.http.ETag;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatusCode;
@@ -148,12 +149,13 @@ public ServerResponse.BodyBuilder contentType(MediaType contentType) {
@Override
public ServerResponse.BodyBuilder eTag(String etag) {
Assert.notNull(etag, "etag must not be null");
- if (!etag.startsWith("\"") && !etag.startsWith("W/\"")) {
- etag = "\"" + etag;
- }
- if (!etag.endsWith("\"")) {
- etag = etag + "\"";
- }
+ this.headers.setETag(etag);
+ return this;
+ }
+
+ @Override
+ public ServerResponse.BodyBuilder eTag(ETag etag) {
+ Assert.notNull(etag, "etag must not be null");
this.headers.setETag(etag);
return this;
}
diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/EntityResponse.java b/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/EntityResponse.java
index 8bbbb32320b1..8c1d3d3a7488 100644
--- a/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/EntityResponse.java
+++ b/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/EntityResponse.java
@@ -28,6 +28,7 @@
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.CacheControl;
+import org.springframework.http.ETag;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatusCode;
@@ -220,6 +221,15 @@ interface Builder {
*/
Builder eTag(String etag);
+ /**
+ * Set the entity tag of the body, as specified by the {@code ETag} header.
+ * @since TODO
+ * @param etag the new entity tag
+ * @return this builder
+ * @see HttpHeaders#setETag(ETag)
+ */
+ Builder eTag(ETag etag);
+
/**
* Set the time the resource was last changed, as specified by the
* {@code Last-Modified} header.
diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/ServerResponse.java b/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/ServerResponse.java
index f10d770ad2ff..179d69af6526 100644
--- a/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/ServerResponse.java
+++ b/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/ServerResponse.java
@@ -32,6 +32,7 @@
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.core.ReactiveAdapterRegistry;
import org.springframework.http.CacheControl;
+import org.springframework.http.ETag;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
@@ -300,6 +301,15 @@ interface HeadersBuilder> {
*/
B eTag(String eTag);
+ /**
+ * Set the entity tag of the body, as specified by the {@code ETag} header.
+ * @since TODO
+ * @param eTag the new entity tag
+ * @return this builder
+ * @see HttpHeaders#setETag(ETag)
+ */
+ B eTag(ETag eTag);
+
/**
* Set the time the resource was last changed, as specified by the
* {@code Last-Modified} header.
diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/resource/VersionResourceResolver.java b/spring-webflux/src/main/java/org/springframework/web/reactive/resource/VersionResourceResolver.java
index 674d6d256778..f0874e7b1238 100644
--- a/spring-webflux/src/main/java/org/springframework/web/reactive/resource/VersionResourceResolver.java
+++ b/spring-webflux/src/main/java/org/springframework/web/reactive/resource/VersionResourceResolver.java
@@ -34,6 +34,7 @@
import org.springframework.core.io.AbstractResource;
import org.springframework.core.io.Resource;
+import org.springframework.http.ETag;
import org.springframework.http.HttpHeaders;
import org.springframework.lang.Nullable;
import org.springframework.util.AntPathMatcher;
@@ -334,7 +335,7 @@ public String getDescription() {
public HttpHeaders getResponseHeaders() {
HttpHeaders headers = (this.original instanceof HttpResource httpResource ?
httpResource.getResponseHeaders() : new HttpHeaders());
- headers.setETag("W/\"" + this.version + "\"");
+ headers.setETag(new ETag(this.version, true));
return headers;
}
}
diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/function/DefaultEntityResponseBuilder.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/function/DefaultEntityResponseBuilder.java
index 135792c052f8..39067a5afe14 100644
--- a/spring-webmvc/src/main/java/org/springframework/web/servlet/function/DefaultEntityResponseBuilder.java
+++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/function/DefaultEntityResponseBuilder.java
@@ -46,6 +46,7 @@
import org.springframework.core.io.Resource;
import org.springframework.core.io.support.ResourceRegion;
import org.springframework.http.CacheControl;
+import org.springframework.http.ETag;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpRange;
@@ -166,12 +167,12 @@ public EntityResponse.Builder contentType(MediaType contentType) {
@Override
public EntityResponse.Builder eTag(String etag) {
- if (!etag.startsWith("\"") && !etag.startsWith("W/\"")) {
- etag = "\"" + etag;
- }
- if (!etag.endsWith("\"")) {
- etag = etag + "\"";
- }
+ this.headers.setETag(etag);
+ return this;
+ }
+
+ @Override
+ public EntityResponse.Builder eTag(ETag etag) {
this.headers.setETag(etag);
return this;
}
diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/function/DefaultServerResponseBuilder.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/function/DefaultServerResponseBuilder.java
index 54f5e83ff3d3..e2ae285331e4 100644
--- a/spring-webmvc/src/main/java/org/springframework/web/servlet/function/DefaultServerResponseBuilder.java
+++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/function/DefaultServerResponseBuilder.java
@@ -31,6 +31,7 @@
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.CacheControl;
+import org.springframework.http.ETag;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatusCode;
@@ -127,13 +128,13 @@ public ServerResponse.BodyBuilder contentType(MediaType contentType) {
@Override
public ServerResponse.BodyBuilder eTag(String etag) {
+ this.headers.setETag(etag);
+ return this;
+ }
+
+ @Override
+ public ServerResponse.BodyBuilder eTag(ETag etag) {
Assert.notNull(etag, "etag must not be null");
- if (!etag.startsWith("\"") && !etag.startsWith("W/\"")) {
- etag = "\"" + etag;
- }
- if (!etag.endsWith("\"")) {
- etag = etag + "\"";
- }
this.headers.setETag(etag);
return this;
}
diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/function/EntityResponse.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/function/EntityResponse.java
index 688b62a44c73..b731f8ba38bf 100644
--- a/spring-webmvc/src/main/java/org/springframework/web/servlet/function/EntityResponse.java
+++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/function/EntityResponse.java
@@ -26,6 +26,7 @@
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.CacheControl;
+import org.springframework.http.ETag;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatusCode;
@@ -155,6 +156,15 @@ interface Builder {
*/
Builder eTag(String etag);
+ /**
+ * Set the entity tag of the body, as specified by the {@code ETag} header.
+ * @since TODO
+ * @param etag the new entity tag
+ * @return this builder
+ * @see HttpHeaders#setETag(ETag)
+ */
+ Builder eTag(ETag etag);
+
/**
* Set the time the resource was last changed, as specified by the
* {@code Last-Modified} header.
diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/function/ServerResponse.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/function/ServerResponse.java
index 26dbd6a31ab0..b5749f1ef424 100644
--- a/spring-webmvc/src/main/java/org/springframework/web/servlet/function/ServerResponse.java
+++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/function/ServerResponse.java
@@ -38,6 +38,7 @@
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.core.ReactiveAdapterRegistry;
import org.springframework.http.CacheControl;
+import org.springframework.http.ETag;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
@@ -400,6 +401,15 @@ interface HeadersBuilder> {
*/
B eTag(String eTag);
+ /**
+ * Set the entity tag of the body, as specified by the {@code ETag} header.
+ * @since TODO
+ * @param eTag the new entity tag
+ * @return this builder
+ * @see HttpHeaders#setETag(ETag)
+ */
+ B eTag(ETag eTag);
+
/**
* Set the time the resource was last changed, as specified by the
* {@code Last-Modified} header.
diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/resource/VersionResourceResolver.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/resource/VersionResourceResolver.java
index e93efc46e697..d9904bcfec2e 100644
--- a/spring-webmvc/src/main/java/org/springframework/web/servlet/resource/VersionResourceResolver.java
+++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/resource/VersionResourceResolver.java
@@ -34,6 +34,7 @@
import org.springframework.core.io.AbstractResource;
import org.springframework.core.io.Resource;
+import org.springframework.http.ETag;
import org.springframework.http.HttpHeaders;
import org.springframework.lang.Nullable;
import org.springframework.util.AntPathMatcher;
@@ -332,7 +333,7 @@ public String getDescription() {
public HttpHeaders getResponseHeaders() {
HttpHeaders headers = (this.original instanceof HttpResource httpResource ?
httpResource.getResponseHeaders() : new HttpHeaders());
- headers.setETag("W/\"" + this.version + "\"");
+ headers.setETag(new ETag(this.version, true));
return headers;
}
}