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; } }