From 6071e01168fe9ee5006cab09c632b19fca1fc359 Mon Sep 17 00:00:00 2001 From: Rossen Stoyanchev Date: Tue, 30 Aug 2016 14:36:19 -0400 Subject: [PATCH] Add checkNotModified support in ServerWebExchange Issue: SPR-14522 --- .../web/server/ServerWebExchange.java | 39 ++ .../adapter/DefaultServerWebExchange.java | 137 ++++++++ ...erverWebExchangeCheckNotModifiedTests.java | 332 ++++++++++++++++++ 3 files changed, 508 insertions(+) create mode 100644 spring-web/src/test/java/org/springframework/web/server/adapter/DefaultServerWebExchangeCheckNotModifiedTests.java diff --git a/spring-web/src/main/java/org/springframework/web/server/ServerWebExchange.java b/spring-web/src/main/java/org/springframework/web/server/ServerWebExchange.java index c3fc272e8fc..e865f227183 100644 --- a/spring-web/src/main/java/org/springframework/web/server/ServerWebExchange.java +++ b/spring-web/src/main/java/org/springframework/web/server/ServerWebExchange.java @@ -16,6 +16,7 @@ package org.springframework.web.server; +import java.time.Instant; import java.util.Map; import java.util.Optional; @@ -67,4 +68,42 @@ public interface ServerWebExchange { */ Mono getSession(); + /** + * An overloaded variant of {@link #checkNotModified(String, Instant)} with + * a last-modified timestamp only. + * @param lastModified the last-modified time + * @return whether the request qualifies as not modified + */ + boolean checkNotModified(Instant lastModified); + + /** + * An overloaded variant of {@link #checkNotModified(String, Instant)} with + * an {@code ETag} (entity tag) value only. + * @param etag the entity tag for the underlying resource. + * @return true if the request does not require further processing. + */ + boolean checkNotModified(String etag); + + /** + * Check whether the requested resource has been modified given the supplied + * {@code ETag} (entity tag) and last-modified timestamp as determined by + * the application. Also transparently prepares the response, setting HTTP + * status, and adding "ETag" and "Last-Modified" headers when applicable. + * This method works with conditional GET/HEAD requests as well as with + * conditional POST/PUT/DELETE requests. + * + *

Note: The HTTP specification recommends setting both + * ETag and Last-Modified values, but you can also use + * {@code #checkNotModified(String)} or + * {@link #checkNotModified(Instant)}. + * + * @param etag the entity tag that the application determined for the + * underlying resource. This parameter will be padded with quotes (") + * if necessary. + * @param lastModified the last-modified timestamp that the application + * determined for the underlying resource + * @return true if the request does not require further processing. + */ + boolean checkNotModified(String etag, Instant lastModified); + } 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 4f02084dbf5..568f526a3fb 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 @@ -16,15 +16,23 @@ package org.springframework.web.server.adapter; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Arrays; +import java.util.List; import java.util.Map; import java.util.Optional; import java.util.concurrent.ConcurrentHashMap; import reactor.core.publisher.Mono; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; import org.springframework.http.server.reactive.ServerHttpRequest; import org.springframework.http.server.reactive.ServerHttpResponse; import org.springframework.util.Assert; +import org.springframework.util.StringUtils; import org.springframework.web.server.ServerWebExchange; import org.springframework.web.server.WebSession; import org.springframework.web.server.session.WebSessionManager; @@ -37,6 +45,9 @@ import org.springframework.web.server.session.WebSessionManager; */ public class DefaultServerWebExchange implements ServerWebExchange { + private static final List SAFE_METHODS = Arrays.asList(HttpMethod.GET, HttpMethod.HEAD); + + private final ServerHttpRequest request; private final ServerHttpResponse response; @@ -45,6 +56,8 @@ public class DefaultServerWebExchange implements ServerWebExchange { private final Mono sessionMono; + private volatile boolean notModified; + public DefaultServerWebExchange(ServerHttpRequest request, ServerHttpResponse response, WebSessionManager sessionManager) { @@ -63,11 +76,19 @@ public class DefaultServerWebExchange implements ServerWebExchange { return this.request; } + private HttpHeaders getRequestHeaders() { + return getRequest().getHeaders(); + } + @Override public ServerHttpResponse getResponse() { return this.response; } + private HttpHeaders getResponseHeaders() { + return getResponse().getHeaders(); + } + @Override public Map getAttributes() { return this.attributes; @@ -83,4 +104,120 @@ public class DefaultServerWebExchange implements ServerWebExchange { return this.sessionMono; } + @Override + public boolean checkNotModified(Instant lastModified) { + return checkNotModified(null, lastModified); + } + + @Override + public boolean checkNotModified(String etag) { + return checkNotModified(etag, Instant.MIN); + } + + @Override + public boolean checkNotModified(String etag, Instant lastModified) { + HttpStatus status = getResponse().getStatusCode(); + if (this.notModified || (status != null && !HttpStatus.OK.equals(status))) { + return this.notModified; + } + + // Evaluate conditions in order of precedence. + // See https://tools.ietf.org/html/rfc7232#section-6 + + if (validateIfUnmodifiedSince(lastModified)) { + if (this.notModified) { + getResponse().setStatusCode(HttpStatus.PRECONDITION_FAILED); + } + return this.notModified; + } + + boolean validated = validateIfNoneMatch(etag); + + if (!validated) { + validateIfModifiedSince(lastModified); + } + + // Update response + + boolean isHttpGetOrHead = SAFE_METHODS.contains(getRequest().getMethod()); + if (this.notModified) { + getResponse().setStatusCode(isHttpGetOrHead ? + HttpStatus.NOT_MODIFIED : HttpStatus.PRECONDITION_FAILED); + } + if (isHttpGetOrHead) { + if (lastModified.isAfter(Instant.EPOCH) && getResponseHeaders().getLastModified() == -1) { + getResponseHeaders().setLastModified(lastModified.toEpochMilli()); + } + if (StringUtils.hasLength(etag) && getResponseHeaders().getETag() == null) { + getResponseHeaders().setETag(padEtagIfNecessary(etag)); + } + } + + return this.notModified; + } + + private boolean validateIfUnmodifiedSince(Instant lastModified) { + if (lastModified.isBefore(Instant.EPOCH)) { + return false; + } + long ifUnmodifiedSince = getRequestHeaders().getIfUnmodifiedSince(); + if (ifUnmodifiedSince == -1) { + return false; + } + // We will perform this validation... + Instant sinceInstant = Instant.ofEpochMilli(ifUnmodifiedSince); + this.notModified = sinceInstant.isBefore(lastModified.truncatedTo(ChronoUnit.SECONDS)); + return true; + } + + private boolean validateIfNoneMatch(String etag) { + if (!StringUtils.hasLength(etag)) { + return false; + } + List ifNoneMatch; + try { + ifNoneMatch = getRequestHeaders().getIfNoneMatch(); + } + catch (IllegalArgumentException ex) { + return false; + } + if (ifNoneMatch.isEmpty()) { + return false; + } + // We will perform this validation... + etag = padEtagIfNecessary(etag); + for (String clientETag : ifNoneMatch) { + // Compare weak/strong ETags as per https://tools.ietf.org/html/rfc7232#section-2.3 + if (StringUtils.hasLength(clientETag) && + clientETag.replaceFirst("^W/", "").equals(etag.replaceFirst("^W/", ""))) { + this.notModified = true; + break; + } + } + return true; + } + + private String padEtagIfNecessary(String etag) { + if (!StringUtils.hasLength(etag)) { + return etag; + } + if ((etag.startsWith("\"") || etag.startsWith("W/\"")) && etag.endsWith("\"")) { + return etag; + } + return "\"" + etag + "\""; + } + + private boolean validateIfModifiedSince(Instant lastModified) { + if (lastModified.isBefore(Instant.EPOCH)) { + return false; + } + long ifModifiedSince = getRequestHeaders().getIfModifiedSince(); + if (ifModifiedSince == -1) { + return false; + } + // We will perform this validation... + this.notModified = ChronoUnit.SECONDS.between(lastModified, Instant.ofEpochMilli(ifModifiedSince)) >= 0; + return true; + } + } diff --git a/spring-web/src/test/java/org/springframework/web/server/adapter/DefaultServerWebExchangeCheckNotModifiedTests.java b/spring-web/src/test/java/org/springframework/web/server/adapter/DefaultServerWebExchangeCheckNotModifiedTests.java new file mode 100644 index 00000000000..fcf88a4c720 --- /dev/null +++ b/spring-web/src/test/java/org/springframework/web/server/adapter/DefaultServerWebExchangeCheckNotModifiedTests.java @@ -0,0 +1,332 @@ +/* + * Copyright 2002-2016 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 + * + * 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.springframework.web.server.adapter; + +import java.net.URI; +import java.net.URISyntaxException; +import java.text.SimpleDateFormat; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Arrays; +import java.util.Locale; +import java.util.TimeZone; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; +import org.junit.runners.Parameterized.Parameter; +import org.junit.runners.Parameterized.Parameters; + +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.server.reactive.MockServerHttpRequest; +import org.springframework.http.server.reactive.MockServerHttpResponse; +import org.springframework.web.server.session.MockWebSessionManager; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +/** + * Parameterized tests for ServletWebRequest + * @author Rossen Stoyanchev + */ +@RunWith(Parameterized.class) +public class DefaultServerWebExchangeCheckNotModifiedTests { + + private static final String CURRENT_TIME = "Wed, 09 Apr 2014 09:57:42 GMT"; + + + private SimpleDateFormat dateFormat; + + private MockServerHttpRequest request; + + private MockServerHttpResponse response; + + private DefaultServerWebExchange exchange; + + private Instant currentDate; + + @Parameter + public HttpMethod method; + + @Parameters(name = "{0}") + static public Iterable safeMethods() { + return Arrays.asList(new Object[][] { + {HttpMethod.GET}, + {HttpMethod.HEAD} + }); + } + + + @Before + public void setUp() throws URISyntaxException { + currentDate = Instant.now().truncatedTo(ChronoUnit.SECONDS); + dateFormat = new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss z", Locale.US); + dateFormat.setTimeZone(TimeZone.getTimeZone("GMT")); + request = new MockServerHttpRequest(method, new URI("http://example.org")); + response = new MockServerHttpResponse(); + exchange = new DefaultServerWebExchange(request, response, new MockWebSessionManager()); + } + + @Test + public void checkNotModifiedNon2xxStatus() { + request.getHeaders().setIfModifiedSince(this.currentDate.toEpochMilli()); + response.setStatusCode(HttpStatus.NOT_MODIFIED); + + assertFalse(exchange.checkNotModified(this.currentDate)); + assertEquals(304, response.getStatusCode().value()); + assertEquals(-1, response.getHeaders().getLastModified()); + } + + @Test // SPR-14559 + public void checkNotModifiedInvalidIfNoneMatchHeader() { + String eTag = "\"etagvalue\""; + request.getHeaders().setIfNoneMatch("missingquotes"); + assertFalse(exchange.checkNotModified(eTag)); + assertNull(response.getStatusCode()); + assertEquals(eTag, response.getHeaders().getETag()); + } + + @Test + public void checkNotModifiedHeaderAlreadySet() { + request.getHeaders().setIfModifiedSince(currentDate.toEpochMilli()); + response.getHeaders().add("Last-Modified", CURRENT_TIME); + + assertTrue(exchange.checkNotModified(currentDate)); + assertEquals(304, response.getStatusCode().value()); + assertEquals(1, response.getHeaders().get("Last-Modified").size()); + assertEquals(CURRENT_TIME, response.getHeaders().getFirst("Last-Modified")); + } + + @Test + public void checkNotModifiedTimestamp() throws Exception { + request.getHeaders().setIfModifiedSince(currentDate.toEpochMilli()); + + assertTrue(exchange.checkNotModified(currentDate)); + + assertEquals(304, response.getStatusCode().value()); + assertEquals(currentDate.toEpochMilli(), response.getHeaders().getLastModified()); + } + + @Test + public void checkModifiedTimestamp() { + Instant oneMinuteAgo = currentDate.minusSeconds(60); + request.getHeaders().setIfModifiedSince(oneMinuteAgo.toEpochMilli()); + + assertFalse(exchange.checkNotModified(currentDate)); + + assertNull(response.getStatusCode()); + assertEquals(currentDate.toEpochMilli(), response.getHeaders().getLastModified()); + } + + @Test + public void checkNotModifiedETag() { + String eTag = "\"Foo\""; + request.getHeaders().setIfNoneMatch(eTag); + + assertTrue(exchange.checkNotModified(eTag)); + + assertEquals(304, response.getStatusCode().value()); + assertEquals(eTag, response.getHeaders().getETag()); + } + + @Test + public void checkNotModifiedETagWithSeparatorChars() { + String eTag = "\"Foo, Bar\""; + request.getHeaders().setIfNoneMatch(eTag); + + assertTrue(exchange.checkNotModified(eTag)); + + assertEquals(304, response.getStatusCode().value()); + assertEquals(eTag, response.getHeaders().getETag()); + } + + + @Test + public void checkModifiedETag() { + String currentETag = "\"Foo\""; + String oldEtag = "Bar"; + request.getHeaders().setIfNoneMatch(oldEtag); + + assertFalse(exchange.checkNotModified(currentETag)); + + assertNull(response.getStatusCode()); + assertEquals(currentETag, response.getHeaders().getETag()); + } + + @Test + public void checkNotModifiedUnpaddedETag() { + String eTag = "Foo"; + String paddedEtag = String.format("\"%s\"", eTag); + request.getHeaders().setIfNoneMatch(paddedEtag); + + assertTrue(exchange.checkNotModified(eTag)); + + assertEquals(304, response.getStatusCode().value()); + assertEquals(paddedEtag, response.getHeaders().getETag()); + } + + @Test + public void checkModifiedUnpaddedETag() { + String currentETag = "Foo"; + String oldEtag = "Bar"; + request.getHeaders().setIfNoneMatch(oldEtag); + + assertFalse(exchange.checkNotModified(currentETag)); + + assertNull(response.getStatusCode()); + assertEquals(String.format("\"%s\"", currentETag), response.getHeaders().getETag()); + } + + @Test + public void checkNotModifiedWildcardIsIgnored() { + String eTag = "\"Foo\""; + request.getHeaders().setIfNoneMatch("*"); + + assertFalse(exchange.checkNotModified(eTag)); + + assertNull(response.getStatusCode()); + assertEquals(eTag, response.getHeaders().getETag()); + } + + @Test + public void checkNotModifiedETagAndTimestamp() { + String eTag = "\"Foo\""; + request.getHeaders().setIfNoneMatch(eTag); + request.getHeaders().setIfModifiedSince(currentDate.toEpochMilli()); + + assertTrue(exchange.checkNotModified(eTag, currentDate)); + + assertEquals(304, response.getStatusCode().value()); + assertEquals(eTag, response.getHeaders().getETag()); + assertEquals(currentDate.toEpochMilli(), response.getHeaders().getLastModified()); + } + + // SPR-14224 + @Test + public void checkNotModifiedETagAndModifiedTimestamp() { + String eTag = "\"Foo\""; + request.getHeaders().setIfNoneMatch(eTag); + Instant oneMinuteAgo = currentDate.minusSeconds(60); + request.getHeaders().setIfModifiedSince(oneMinuteAgo.toEpochMilli()); + + assertTrue(exchange.checkNotModified(eTag, currentDate)); + + assertEquals(304, response.getStatusCode().value()); + assertEquals(eTag, response.getHeaders().getETag()); + assertEquals(currentDate.toEpochMilli(), response.getHeaders().getLastModified()); + } + + @Test + public void checkModifiedETagAndNotModifiedTimestamp() throws Exception { + String currentETag = "\"Foo\""; + String oldEtag = "\"Bar\""; + request.getHeaders().setIfNoneMatch(oldEtag); + request.getHeaders().setIfModifiedSince(currentDate.toEpochMilli()); + + assertFalse(exchange.checkNotModified(currentETag, currentDate)); + + assertNull(response.getStatusCode()); + assertEquals(currentETag, response.getHeaders().getETag()); + assertEquals(currentDate.toEpochMilli(), response.getHeaders().getLastModified()); + } + + @Test + public void checkNotModifiedETagWeakStrong() { + String eTag = "\"Foo\""; + String weakEtag = String.format("W/%s", eTag); + request.getHeaders().setIfNoneMatch(eTag); + + assertTrue(exchange.checkNotModified(weakEtag)); + + assertEquals(304, response.getStatusCode().value()); + assertEquals(weakEtag, response.getHeaders().getETag()); + } + + @Test + public void checkNotModifiedETagStrongWeak() { + String eTag = "\"Foo\""; + request.getHeaders().setIfNoneMatch(String.format("W/%s", eTag)); + + assertTrue(exchange.checkNotModified(eTag)); + + assertEquals(304, response.getStatusCode().value()); + assertEquals(eTag, response.getHeaders().getETag()); + } + + @Test + public void checkNotModifiedMultipleETags() { + String eTag = "\"Bar\""; + String multipleETags = String.format("\"Foo\", %s", eTag); + request.getHeaders().setIfNoneMatch(multipleETags); + + assertTrue(exchange.checkNotModified(eTag)); + + assertEquals(304, response.getStatusCode().value()); + assertEquals(eTag, response.getHeaders().getETag()); + } + + @Test + public void checkNotModifiedTimestampWithLengthPart() throws Exception { + long epochTime = dateFormat.parse(CURRENT_TIME).getTime(); + request.setHttpMethod(HttpMethod.GET); + request.getHeaders().add("If-Modified-Since", "Wed, 09 Apr 2014 09:57:42 GMT; length=13774"); + + assertTrue(exchange.checkNotModified(Instant.ofEpochMilli(epochTime))); + + assertEquals(304, response.getStatusCode().value()); + assertEquals(epochTime, response.getHeaders().getLastModified()); + } + + @Test + public void checkModifiedTimestampWithLengthPart() throws Exception { + long epochTime = dateFormat.parse(CURRENT_TIME).getTime(); + request.setHttpMethod(HttpMethod.GET); + request.getHeaders().add("If-Modified-Since", "Tue, 08 Apr 2014 09:57:42 GMT; length=13774"); + + assertFalse(exchange.checkNotModified(Instant.ofEpochMilli(epochTime))); + + assertNull(response.getStatusCode()); + assertEquals(epochTime, response.getHeaders().getLastModified()); + } + + @Test + public void checkNotModifiedTimestampConditionalPut() throws Exception { + Instant oneMinuteAgo = currentDate.minusSeconds(60); + request.setHttpMethod(HttpMethod.PUT); + request.getHeaders().setIfUnmodifiedSince(currentDate.toEpochMilli()); + + assertFalse(exchange.checkNotModified(oneMinuteAgo)); + assertNull(response.getStatusCode()); + assertEquals(-1, response.getHeaders().getLastModified()); + } + + @Test + public void checkNotModifiedTimestampConditionalPutConflict() throws Exception { + Instant oneMinuteAgo = currentDate.minusSeconds(60); + request.setHttpMethod(HttpMethod.PUT); + request.getHeaders().setIfUnmodifiedSince(oneMinuteAgo.toEpochMilli()); + + assertTrue(exchange.checkNotModified(currentDate)); + assertEquals(412, response.getStatusCode().value()); + assertEquals(-1, response.getHeaders().getLastModified()); + } + +}