From 0dc1c7eb8b4ca17996567406f3e16267ff544a6d Mon Sep 17 00:00:00 2001 From: Arjen Poutsma Date: Wed, 11 Mar 2020 12:05:07 +0100 Subject: [PATCH] Add ServerRequest::checkNotModified This commit adds the checkNotModified method to ServerRequest in both WebFlux.fn and WebMvc.fn. Unlike other checkNotModified methods found in the framework, this method does not return a boolean, but rather a response wrapped in a Mono/Optional. If the resource has not been changed, the not-modified response can be returned directly; if the resource has changed, the user can create a corresponding response using switchIfEmpty/orElse(Get). Closes gh-24173 --- .../function/server/DefaultServerRequest.java | 21 +- .../function/server/ServerRequest.java | 104 +++++++ .../server/DefaultServerRequestTests.java | 265 ++++++++++++++++++ .../function/DefaultServerRequest.java | 233 ++++++++++++++- .../servlet/function/RequestPredicates.java | 16 ++ .../web/servlet/function/ServerRequest.java | 106 +++++++ .../function/DefaultServerRequestTests.java | 197 +++++++++++++ 7 files changed, 940 insertions(+), 2 deletions(-) diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/DefaultServerRequest.java b/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/DefaultServerRequest.java index 74a6a71093..e1b12d8858 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/DefaultServerRequest.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/DefaultServerRequest.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2019 the original author or authors. + * Copyright 2002-2020 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. @@ -20,6 +20,7 @@ import java.net.InetSocketAddress; import java.net.URI; import java.nio.charset.Charset; import java.security.Principal; +import java.time.Instant; import java.util.ArrayList; import java.util.Collections; import java.util.List; @@ -44,6 +45,7 @@ import org.springframework.http.codec.multipart.Part; import org.springframework.http.server.PathContainer; import org.springframework.http.server.reactive.ServerHttpRequest; import org.springframework.http.server.reactive.ServerHttpResponse; +import org.springframework.lang.Nullable; import org.springframework.util.MultiValueMap; import org.springframework.web.reactive.function.BodyExtractor; import org.springframework.web.reactive.function.BodyExtractors; @@ -86,6 +88,23 @@ class DefaultServerRequest implements ServerRequest { this.headers = new DefaultHeaders(); } + static Mono checkNotModified(ServerWebExchange exchange, @Nullable Instant lastModified, + @Nullable String etag) { + + if (lastModified == null) { + lastModified = Instant.MIN; + } + + if (exchange.checkNotModified(etag, lastModified)) { + Integer statusCode = exchange.getResponse().getRawStatusCode(); + return ServerResponse.status(statusCode != null ? statusCode : 200) + .headers(headers -> headers.addAll(exchange.getResponse().getHeaders())) + .build(); + } + else { + return Mono.empty(); + } + } @Override public String methodName() { diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/ServerRequest.java b/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/ServerRequest.java index 56f98e19b3..20ee3944c8 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/ServerRequest.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/ServerRequest.java @@ -20,6 +20,7 @@ import java.net.InetSocketAddress; import java.net.URI; import java.nio.charset.Charset; import java.security.Principal; +import java.time.Instant; import java.util.List; import java.util.Locale; import java.util.Map; @@ -43,6 +44,7 @@ import org.springframework.http.codec.multipart.Part; import org.springframework.http.server.PathContainer; import org.springframework.http.server.reactive.ServerHttpRequest; import org.springframework.lang.Nullable; +import org.springframework.util.Assert; import org.springframework.util.CollectionUtils; import org.springframework.util.MultiValueMap; import org.springframework.web.reactive.function.BodyExtractor; @@ -287,6 +289,108 @@ public interface ServerRequest { */ ServerWebExchange exchange(); + /** + * Check whether the requested resource has been modified given the + * supplied last-modified timestamp (as determined by the application). + * If not modified, this method returns a response with corresponding + * status code and headers, otherwise an empty result. + *

Typical usage: + *

+	 * public Mono<ServerResponse> myHandleMethod(ServerRequest request) {
+	 *   Instant lastModified = // application-specific calculation
+	 *	 return request.checkNotModified(lastModified)
+	 *	   .switchIfEmpty(Mono.defer(() -> {
+	 *	     // further request processing, actually building content
+	 *		 return ServerResponse.ok().body(...);
+	 *	   }));
+	 * }
+ *

This method works with conditional GET/HEAD requests, but + * also with conditional POST/PUT/DELETE requests. + *

Note: you can use either + * this {@code #checkNotModified(Instant)} method; or + * {@link #checkNotModified(String)}. If you want enforce both + * a strong entity tag and a Last-Modified value, + * as recommended by the HTTP specification, + * then you should use {@link #checkNotModified(Instant, String)}. + * @param lastModified the last-modified timestamp that the + * application determined for the underlying resource + * @return a corresponding response if the request qualifies as not + * modified, or an empty result otherwise. + * @since 5.2.5 + */ + default Mono checkNotModified(Instant lastModified) { + Assert.notNull(lastModified, "LastModified must not be null"); + return DefaultServerRequest.checkNotModified(exchange(), lastModified, null); + } + + /** + * Check whether the requested resource has been modified given the + * supplied {@code ETag} (entity tag), as determined by the application. + * If not modified, this method returns a response with corresponding + * status code and headers, otherwise an empty result. + *

Typical usage: + *

+	 * public Mono<ServerResponse> myHandleMethod(ServerRequest request) {
+	 *   String eTag = // application-specific calculation
+	 *	 return request.checkNotModified(eTag)
+	 *	   .switchIfEmpty(Mono.defer(() -> {
+	 *	     // further request processing, actually building content
+	 *		 return ServerResponse.ok().body(...);
+	 *	   }));
+	 * }
+ *

This method works with conditional GET/HEAD requests, but + * also with conditional POST/PUT/DELETE requests. + *

Note: you can use either + * this {@link #checkNotModified(Instant)} method; or + * {@code #checkNotModified(String)}. If you want enforce both + * a strong entity tag and a Last-Modified value, + * as recommended by the HTTP specification, + * then you should use {@link #checkNotModified(Instant, String)}. + * @param etag the entity tag that the application determined + * for the underlying resource. This parameter will be padded + * with quotes (") if necessary. + * @return a corresponding response if the request qualifies as not + * modified, or an empty result otherwise. + * @since 5.2.5 + */ + default Mono checkNotModified(String etag) { + Assert.notNull(etag, "Etag must not be null"); + return DefaultServerRequest.checkNotModified(exchange(), null, 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. + * If not modified, this method returns a response with corresponding + * status code and headers, otherwise an empty result. + *

Typical usage: + *

+	 * public Mono<ServerResponse> myHandleMethod(ServerRequest request) {
+	 *   Instant lastModified = // application-specific calculation
+	 *   String eTag = // application-specific calculation
+	 *	 return request.checkNotModified(lastModified, eTag)
+	 *	   .switchIfEmpty(Mono.defer(() -> {
+	 *	     // further request processing, actually building content
+	 *		 return ServerResponse.ok().body(...);
+	 *	   }));
+	 * }
+ *

This method works with conditional GET/HEAD requests, but + * also with conditional POST/PUT/DELETE requests. + * @param lastModified the last-modified timestamp that the + * application determined for the underlying resource + * @param etag the entity tag that the application determined + * for the underlying resource. This parameter will be padded + * with quotes (") if necessary. + * @return a corresponding response if the request qualifies as not + * modified, or an empty result otherwise. + * @since 5.2.5 + */ + default Mono checkNotModified(Instant lastModified, String etag) { + Assert.notNull(lastModified, "LastModified must not be null"); + Assert.notNull(etag, "Etag must not be null"); + return DefaultServerRequest.checkNotModified(exchange(), lastModified, etag); + } // Static builder methods diff --git a/spring-webflux/src/test/java/org/springframework/web/reactive/function/server/DefaultServerRequestTests.java b/spring-webflux/src/test/java/org/springframework/web/reactive/function/server/DefaultServerRequestTests.java index 2ebd457f0c..7c1d65cfd8 100644 --- a/spring-webflux/src/test/java/org/springframework/web/reactive/function/server/DefaultServerRequestTests.java +++ b/spring-webflux/src/test/java/org/springframework/web/reactive/function/server/DefaultServerRequestTests.java @@ -16,12 +16,18 @@ package org.springframework.web.reactive.function.server; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; import java.net.InetSocketAddress; import java.net.URI; import java.net.URISyntaxException; import java.nio.ByteBuffer; import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.time.temporal.ChronoUnit; import java.util.Arrays; import java.util.Collections; import java.util.List; @@ -30,6 +36,8 @@ import java.util.Optional; import java.util.OptionalLong; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import reactor.test.StepVerifier; @@ -43,6 +51,7 @@ import org.springframework.http.HttpCookie; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; import org.springframework.http.HttpRange; +import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.codec.DecoderHttpMessageReader; import org.springframework.http.codec.HttpMessageReader; @@ -438,4 +447,260 @@ public class DefaultServerRequestTests { .verifyComplete(); } + @ParameterizedHttpMethodTest + void checkNotModifiedTimestamp(String method) throws Exception { + Instant now = Instant.now().truncatedTo(ChronoUnit.SECONDS); + HttpHeaders headers = new HttpHeaders(); + headers.setIfModifiedSince(now); + MockServerHttpRequest mockRequest = MockServerHttpRequest + .method(HttpMethod.valueOf(method), "/") + .headers(headers) + .build(); + + DefaultServerRequest request = + new DefaultServerRequest(MockServerWebExchange.from(mockRequest), Collections.emptyList()); + + Mono result = request.checkNotModified(now); + + StepVerifier.create(result) + .assertNext(serverResponse -> { + assertThat(serverResponse.statusCode()).isEqualTo(HttpStatus.NOT_MODIFIED); + assertThat(serverResponse.headers().getLastModified()).isEqualTo(now.toEpochMilli()); + }) + .verifyComplete(); + } + + @ParameterizedHttpMethodTest + void checkModifiedTimestamp(String method) { + Instant now = Instant.now().truncatedTo(ChronoUnit.SECONDS); + Instant oneMinuteAgo = now.minus(1, ChronoUnit.MINUTES); + HttpHeaders headers = new HttpHeaders(); + headers.setIfModifiedSince(oneMinuteAgo); + MockServerHttpRequest mockRequest = MockServerHttpRequest + .method(HttpMethod.valueOf(method), "/") + .headers(headers) + .build(); + + DefaultServerRequest request = + new DefaultServerRequest(MockServerWebExchange.from(mockRequest), Collections.emptyList()); + + Mono result = request.checkNotModified(now); + + StepVerifier.create(result) + .verifyComplete(); + } + + @ParameterizedHttpMethodTest + void checkNotModifiedETag(String method) { + String eTag = "\"Foo\""; + HttpHeaders headers = new HttpHeaders(); + headers.setIfNoneMatch(eTag); + MockServerHttpRequest mockRequest = MockServerHttpRequest + .method(HttpMethod.valueOf(method), "/") + .headers(headers) + .build(); + + DefaultServerRequest request = + new DefaultServerRequest(MockServerWebExchange.from(mockRequest), Collections.emptyList()); + + Mono result = request.checkNotModified(eTag); + + StepVerifier.create(result) + .assertNext(serverResponse -> { + assertThat(serverResponse.statusCode()).isEqualTo(HttpStatus.NOT_MODIFIED); + assertThat(serverResponse.headers().getETag()).isEqualTo(eTag); + }) + .verifyComplete(); + } + + @ParameterizedHttpMethodTest + void checkNotModifiedETagWithSeparatorChars(String method) { + String eTag = "\"Foo, Bar\""; + HttpHeaders headers = new HttpHeaders(); + headers.setIfNoneMatch(eTag); + MockServerHttpRequest mockRequest = MockServerHttpRequest + .method(HttpMethod.valueOf(method), "/") + .headers(headers) + .build(); + + DefaultServerRequest request = + new DefaultServerRequest(MockServerWebExchange.from(mockRequest), Collections.emptyList()); + + Mono result = request.checkNotModified(eTag); + + StepVerifier.create(result) + .assertNext(serverResponse -> { + assertThat(serverResponse.statusCode()).isEqualTo(HttpStatus.NOT_MODIFIED); + assertThat(serverResponse.headers().getETag()).isEqualTo(eTag); + }) + .verifyComplete(); + } + + @ParameterizedHttpMethodTest + void checkModifiedETag(String method) { + String currentETag = "\"Foo\""; + String oldEtag = "Bar"; + HttpHeaders headers = new HttpHeaders(); + headers.setIfNoneMatch(oldEtag); + MockServerHttpRequest mockRequest = MockServerHttpRequest + .method(HttpMethod.valueOf(method), "/") + .headers(headers) + .build(); + + DefaultServerRequest request = + new DefaultServerRequest(MockServerWebExchange.from(mockRequest), Collections.emptyList()); + + Mono result = request.checkNotModified(currentETag); + + StepVerifier.create(result) + .verifyComplete(); + } + + @ParameterizedHttpMethodTest + void checkNotModifiedUnpaddedETag(String method) { + String eTag = "Foo"; + String paddedEtag = String.format("\"%s\"", eTag); + HttpHeaders headers = new HttpHeaders(); + headers.setIfNoneMatch(paddedEtag); + MockServerHttpRequest mockRequest = MockServerHttpRequest + .method(HttpMethod.valueOf(method), "/") + .headers(headers) + .build(); + + DefaultServerRequest request = + new DefaultServerRequest(MockServerWebExchange.from(mockRequest), Collections.emptyList()); + + Mono result = request.checkNotModified(eTag); + + StepVerifier.create(result) + .assertNext(serverResponse -> { + assertThat(serverResponse.statusCode()).isEqualTo(HttpStatus.NOT_MODIFIED); + assertThat(serverResponse.headers().getETag()).isEqualTo(paddedEtag); + }) + .verifyComplete(); + } + + @ParameterizedHttpMethodTest + void checkModifiedUnpaddedETag(String method) { + String currentETag = "Foo"; + String oldEtag = "Bar"; + HttpHeaders headers = new HttpHeaders(); + headers.setIfNoneMatch(oldEtag); + MockServerHttpRequest mockRequest = MockServerHttpRequest + .method(HttpMethod.valueOf(method), "/") + .headers(headers) + .build(); + + DefaultServerRequest request = + new DefaultServerRequest(MockServerWebExchange.from(mockRequest), Collections.emptyList()); + + Mono result = request.checkNotModified(currentETag); + + StepVerifier.create(result) + .verifyComplete(); + } + + @ParameterizedHttpMethodTest + void checkNotModifiedWildcardIsIgnored(String method) { + String eTag = "\"Foo\""; + HttpHeaders headers = new HttpHeaders(); + headers.setIfNoneMatch("*"); + MockServerHttpRequest mockRequest = MockServerHttpRequest + .method(HttpMethod.valueOf(method), "/") + .headers(headers) + .build(); + + DefaultServerRequest request = + new DefaultServerRequest(MockServerWebExchange.from(mockRequest), Collections.emptyList()); + + Mono result = request.checkNotModified(eTag); + + StepVerifier.create(result) + .verifyComplete(); + } + + @ParameterizedHttpMethodTest + void checkNotModifiedETagAndTimestamp(String method) { + String eTag = "\"Foo\""; + Instant now = Instant.now().truncatedTo(ChronoUnit.SECONDS); + HttpHeaders headers = new HttpHeaders(); + headers.setIfNoneMatch(eTag); + headers.setIfModifiedSince(now); + + MockServerHttpRequest mockRequest = MockServerHttpRequest + .method(HttpMethod.valueOf(method), "/") + .headers(headers) + .build(); + + DefaultServerRequest request = + new DefaultServerRequest(MockServerWebExchange.from(mockRequest), Collections.emptyList()); + + Mono result = request.checkNotModified(now, eTag); + + StepVerifier.create(result) + .assertNext(serverResponse -> { + assertThat(serverResponse.statusCode()).isEqualTo(HttpStatus.NOT_MODIFIED); + assertThat(serverResponse.headers().getETag()).isEqualTo(eTag); + assertThat(serverResponse.headers().getLastModified()).isEqualTo(now.toEpochMilli()); + }) + .verifyComplete(); + } + + @ParameterizedHttpMethodTest + void checkNotModifiedETagAndModifiedTimestamp(String method) { + String eTag = "\"Foo\""; + Instant now = Instant.now().truncatedTo(ChronoUnit.SECONDS); + Instant oneMinuteAgo = now.minus(1, ChronoUnit.MINUTES); + HttpHeaders headers = new HttpHeaders(); + headers.setIfNoneMatch(eTag); + headers.setIfModifiedSince(oneMinuteAgo); + MockServerHttpRequest mockRequest = MockServerHttpRequest + .method(HttpMethod.valueOf(method), "/") + .headers(headers) + .build(); + + DefaultServerRequest request = + new DefaultServerRequest(MockServerWebExchange.from(mockRequest), Collections.emptyList()); + + Mono result = request.checkNotModified(now, eTag); + + StepVerifier.create(result) + .assertNext(serverResponse -> { + assertThat(serverResponse.statusCode()).isEqualTo(HttpStatus.NOT_MODIFIED); + assertThat(serverResponse.headers().getETag()).isEqualTo(eTag); + assertThat(serverResponse.headers().getLastModified()).isEqualTo(now.toEpochMilli()); + }) + .verifyComplete(); + } + + @ParameterizedHttpMethodTest + void checkModifiedETagAndNotModifiedTimestamp(String method) throws Exception { + String currentETag = "\"Foo\""; + String oldEtag = "\"Bar\""; + Instant now = Instant.now().truncatedTo(ChronoUnit.SECONDS); + HttpHeaders headers = new HttpHeaders(); + headers.setIfNoneMatch(oldEtag); + headers.setIfModifiedSince(now); + MockServerHttpRequest mockRequest = MockServerHttpRequest + .method(HttpMethod.valueOf(method), "/") + .headers(headers) + .build(); + + DefaultServerRequest request = + new DefaultServerRequest(MockServerWebExchange.from(mockRequest), Collections.emptyList()); + + Mono result = request.checkNotModified(now, currentETag); + + StepVerifier.create(result) + .verifyComplete(); + } + + @Retention(RetentionPolicy.RUNTIME) + @Target(ElementType.METHOD) + @ParameterizedTest(name = "[{index}] {0}") + @ValueSource(strings = {"GET", "HEAD"}) + @interface ParameterizedHttpMethodTest { + + } + } diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/function/DefaultServerRequest.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/function/DefaultServerRequest.java index 65b65d4423..99e690afd7 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/function/DefaultServerRequest.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/function/DefaultServerRequest.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2019 the original author or authors. + * Copyright 2002-2020 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. @@ -17,15 +17,18 @@ package org.springframework.web.servlet.function; import java.io.IOException; +import java.io.PrintWriter; import java.lang.reflect.ParameterizedType; import java.lang.reflect.Type; import java.net.InetSocketAddress; import java.net.URI; import java.nio.charset.Charset; import java.security.Principal; +import java.time.Instant; import java.util.AbstractMap; import java.util.ArrayList; import java.util.Arrays; +import java.util.Collection; import java.util.Collections; import java.util.List; import java.util.Locale; @@ -36,8 +39,10 @@ import java.util.Set; import java.util.stream.Collectors; import javax.servlet.ServletException; +import javax.servlet.ServletOutputStream; import javax.servlet.http.Cookie; import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpSession; import org.springframework.core.ParameterizedTypeReference; @@ -47,11 +52,14 @@ import org.springframework.http.MediaType; import org.springframework.http.converter.GenericHttpMessageConverter; import org.springframework.http.converter.HttpMessageConverter; import org.springframework.http.server.ServletServerHttpRequest; +import org.springframework.lang.Nullable; import org.springframework.util.CollectionUtils; import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; import org.springframework.util.ObjectUtils; import org.springframework.web.HttpMediaTypeNotSupportedException; +import org.springframework.web.context.request.ServletWebRequest; +import org.springframework.web.context.request.WebRequest; import org.springframework.web.servlet.HandlerMapping; import org.springframework.web.servlet.support.ServletUriComponentsBuilder; import org.springframework.web.util.UriBuilder; @@ -246,6 +254,26 @@ class DefaultServerRequest implements ServerRequest { return Optional.ofNullable(this.serverHttpRequest.getPrincipal()); } + static Optional checkNotModified(HttpServletRequest servletRequest, @Nullable Instant lastModified, + @Nullable String etag) { + + long lastModifiedTimestamp = -1; + if (lastModified != null && lastModified.isAfter(Instant.EPOCH)) { + lastModifiedTimestamp = lastModified.toEpochMilli(); + } + + CheckNotModifiedResponse response = new CheckNotModifiedResponse(); + WebRequest webRequest = new ServletWebRequest(servletRequest, response); + if (webRequest.checkNotModified(etag, lastModifiedTimestamp)) { + return Optional.of(ServerResponse.status(response.status). + headers(headers -> headers.addAll(response.headers)) + .build()); + } + else { + return Optional.empty(); + } + } + /** * Default implementation of {@link Headers}. */ @@ -419,4 +447,207 @@ class DefaultServerRequest implements ServerRequest { } + /** + * Simple implementation of {@link HttpServletResponse} used by + * {@link #checkNotModified(HttpServletRequest, Instant, String)} to record status and headers set by + * {@link ServletWebRequest#checkNotModified(String, long)}. Throws an {@code UnsupportedOperationException} + * for other methods. + */ + @SuppressWarnings("deprecation") + private static final class CheckNotModifiedResponse implements HttpServletResponse { + + private final HttpHeaders headers = new HttpHeaders(); + + private int status = 200; + + @Override + public boolean containsHeader(String name) { + return this.headers.containsKey(name); + } + + @Override + public void setDateHeader(String name, long date) { + this.headers.setDate(name, date); + } + + @Override + public void setHeader(String name, String value) { + this.headers.set(name, value); + } + + @Override + public void addHeader(String name, String value) { + this.headers.add(name, value); + } + + @Override + public void setStatus(int sc) { + this.status = sc; + } + + @Override + public void setStatus(int sc, String sm) { + this.status = sc; + } + + @Override + public int getStatus() { + return this.status; + } + + @Override + public String getHeader(String name) { + return this.headers.getFirst(name); + } + + @Override + public Collection getHeaders(String name) { + List result = this.headers.get(name); + return result != null ? result : Collections.emptyList(); + } + + @Override + public Collection getHeaderNames() { + return this.headers.keySet(); + } + + + // Unsupported + @Override + public void addCookie(Cookie cookie) { + throw new UnsupportedOperationException(); + } + + @Override + public String encodeURL(String url) { + throw new UnsupportedOperationException(); + } + + @Override + public String encodeRedirectURL(String url) { + throw new UnsupportedOperationException(); + } + + @Override + public String encodeUrl(String url) { + throw new UnsupportedOperationException(); + } + + @Override + public String encodeRedirectUrl(String url) { + throw new UnsupportedOperationException(); + } + + @Override + public void sendError(int sc, String msg) throws IOException { + throw new UnsupportedOperationException(); + } + + @Override + public void sendError(int sc) throws IOException { + throw new UnsupportedOperationException(); + } + + @Override + public void sendRedirect(String location) throws IOException { + throw new UnsupportedOperationException(); + } + + @Override + public void addDateHeader(String name, long date) { + throw new UnsupportedOperationException(); + } + + @Override + public void setIntHeader(String name, int value) { + throw new UnsupportedOperationException(); + } + + @Override + public void addIntHeader(String name, int value) { + throw new UnsupportedOperationException(); + } + + + @Override + public String getCharacterEncoding() { + throw new UnsupportedOperationException(); + } + + @Override + public String getContentType() { + throw new UnsupportedOperationException(); + } + + @Override + public ServletOutputStream getOutputStream() throws IOException { + throw new UnsupportedOperationException(); + } + + @Override + public PrintWriter getWriter() throws IOException { + throw new UnsupportedOperationException(); + } + + @Override + public void setCharacterEncoding(String charset) { + throw new UnsupportedOperationException(); + } + + @Override + public void setContentLength(int len) { + throw new UnsupportedOperationException(); + } + + @Override + public void setContentLengthLong(long len) { + throw new UnsupportedOperationException(); + } + + @Override + public void setContentType(String type) { + throw new UnsupportedOperationException(); + } + + @Override + public void setBufferSize(int size) { + throw new UnsupportedOperationException(); + } + + @Override + public int getBufferSize() { + throw new UnsupportedOperationException(); + } + + @Override + public void flushBuffer() throws IOException { + throw new UnsupportedOperationException(); + } + + @Override + public void resetBuffer() { + throw new UnsupportedOperationException(); + } + + @Override + public boolean isCommitted() { + throw new UnsupportedOperationException(); + } + + @Override + public void reset() { + throw new UnsupportedOperationException(); + } + + @Override + public void setLocale(Locale loc) { + throw new UnsupportedOperationException(); + } + + @Override + public Locale getLocale() { + throw new UnsupportedOperationException(); + } + } + } diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/function/RequestPredicates.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/function/RequestPredicates.java index 1fe1e7f31e..d45abc693a 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/function/RequestPredicates.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/function/RequestPredicates.java @@ -20,6 +20,7 @@ import java.io.IOException; import java.net.InetSocketAddress; import java.net.URI; import java.security.Principal; +import java.time.Instant; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; @@ -1017,6 +1018,21 @@ public abstract class RequestPredicates { return this.request.servletRequest(); } + @Override + public Optional checkNotModified(Instant lastModified) { + return this.request.checkNotModified(lastModified); + } + + @Override + public Optional checkNotModified(String etag) { + return this.request.checkNotModified(etag); + } + + @Override + public Optional checkNotModified(Instant lastModified, String etag) { + return this.request.checkNotModified(lastModified, etag); + } + @Override public String toString() { return method() + " " + path(); diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/function/ServerRequest.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/function/ServerRequest.java index b292c6c272..1fee8d25cb 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/function/ServerRequest.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/function/ServerRequest.java @@ -21,6 +21,7 @@ import java.net.InetSocketAddress; import java.net.URI; import java.nio.charset.Charset; import java.security.Principal; +import java.time.Instant; import java.util.List; import java.util.Locale; import java.util.Map; @@ -42,6 +43,7 @@ import org.springframework.http.MediaType; import org.springframework.http.converter.HttpMessageConverter; import org.springframework.http.server.PathContainer; import org.springframework.lang.Nullable; +import org.springframework.util.Assert; import org.springframework.util.CollectionUtils; import org.springframework.util.MultiValueMap; import org.springframework.web.util.UriBuilder; @@ -224,6 +226,109 @@ public interface ServerRequest { */ HttpServletRequest servletRequest(); + /** + * Check whether the requested resource has been modified given the + * supplied last-modified timestamp (as determined by the application). + * If not modified, this method returns a response with corresponding + * status code and headers, otherwise an empty result. + *

Typical usage: + *

+	 * public ServerResponse myHandleMethod(ServerRequest request) {
+	 *   Instant lastModified = // application-specific calculation
+	 *	 return request.checkNotModified(lastModified)
+	 *	   .orElseGet(() -> {
+	 *	     // further request processing, actually building content
+	 *		 return ServerResponse.ok().body(...);
+	 *	   });
+	 * }
+ *

This method works with conditional GET/HEAD requests, but + * also with conditional POST/PUT/DELETE requests. + *

Note: you can use either + * this {@code #checkNotModified(Instant)} method; or + * {@link #checkNotModified(String)}. If you want enforce both + * a strong entity tag and a Last-Modified value, + * as recommended by the HTTP specification, + * then you should use {@link #checkNotModified(Instant, String)}. + * @param lastModified the last-modified timestamp that the + * application determined for the underlying resource + * @return a corresponding response if the request qualifies as not + * modified, or an empty result otherwise. + * @since 5.2.5 + */ + default Optional checkNotModified(Instant lastModified) { + Assert.notNull(lastModified, "LastModified must not be null"); + return DefaultServerRequest.checkNotModified(servletRequest(), lastModified, null); + } + + /** + * Check whether the requested resource has been modified given the + * supplied {@code ETag} (entity tag), as determined by the application. + * If not modified, this method returns a response with corresponding + * status code and headers, otherwise an empty result. + *

Typical usage: + *

+	 * public ServerResponse myHandleMethod(ServerRequest request) {
+	 *   String eTag = // application-specific calculation
+	 *	 return request.checkNotModified(eTag)
+	 *	   .orElseGet(() -> {
+	 *	     // further request processing, actually building content
+	 *		 return ServerResponse.ok().body(...);
+	 *	   });
+	 * }
+ *

This method works with conditional GET/HEAD requests, but + * also with conditional POST/PUT/DELETE requests. + *

Note: you can use either + * this {@link #checkNotModified(Instant)} method; or + * {@code #checkNotModified(String)}. If you want enforce both + * a strong entity tag and a Last-Modified value, + * as recommended by the HTTP specification, + * then you should use {@link #checkNotModified(Instant, String)}. + * @param etag the entity tag that the application determined + * for the underlying resource. This parameter will be padded + * with quotes (") if necessary. + * @return a corresponding response if the request qualifies as not + * modified, or an empty result otherwise. + * @since 5.2.5 + */ + default Optional checkNotModified(String etag) { + Assert.notNull(etag, "Etag must not be null"); + return DefaultServerRequest.checkNotModified(servletRequest(), null, 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. + * If not modified, this method returns a response with corresponding + * status code and headers, otherwise an empty result. + *

Typical usage: + *

+	 * public ServerResponse myHandleMethod(ServerRequest request) {
+	 *   Instant lastModified = // application-specific calculation
+	 *   String eTag = // application-specific calculation
+	 *	 return request.checkNotModified(lastModified, eTag)
+	 *	   .orElseGet(() -> {
+	 *	     // further request processing, actually building content
+	 *		 return ServerResponse.ok().body(...);
+	 *	   });
+	 * }
+ *

This method works with conditional GET/HEAD requests, but + * also with conditional POST/PUT/DELETE requests. + * @param lastModified the last-modified timestamp that the + * application determined for the underlying resource + * @param etag the entity tag that the application determined + * for the underlying resource. This parameter will be padded + * with quotes (") if necessary. + * @return a corresponding response if the request qualifies as not + * modified, or an empty result otherwise. + * @since 5.2.5 + */ + default Optional checkNotModified(Instant lastModified, String etag) { + Assert.notNull(lastModified, "LastModified must not be null"); + Assert.notNull(etag, "Etag must not be null"); + return DefaultServerRequest.checkNotModified(servletRequest(), lastModified, etag); + } + // Static methods @@ -248,6 +353,7 @@ public interface ServerRequest { } + /** * Represents the headers of the HTTP request. * @see ServerRequest#headers() diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/function/DefaultServerRequestTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/function/DefaultServerRequestTests.java index 7ba69dd0a8..2741c5a2e3 100644 --- a/spring-webmvc/src/test/java/org/springframework/web/servlet/function/DefaultServerRequestTests.java +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/function/DefaultServerRequestTests.java @@ -16,10 +16,16 @@ package org.springframework.web.servlet.function; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; import java.net.InetSocketAddress; import java.net.URI; import java.nio.charset.Charset; import java.security.Principal; +import java.time.Instant; +import java.time.temporal.ChronoUnit; import java.util.Collections; import java.util.List; import java.util.Map; @@ -29,11 +35,14 @@ import java.util.OptionalLong; import javax.servlet.http.Cookie; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import org.springframework.core.ParameterizedTypeReference; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; import org.springframework.http.HttpRange; +import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.converter.HttpMessageConverter; import org.springframework.http.converter.StringHttpMessageConverter; @@ -41,6 +50,7 @@ import org.springframework.http.converter.json.MappingJackson2HttpMessageConvert import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; import org.springframework.web.HttpMediaTypeNotSupportedException; +import org.springframework.web.testfixture.server.MockServerWebExchange; import org.springframework.web.testfixture.servlet.MockHttpServletRequest; import org.springframework.web.testfixture.servlet.MockHttpSession; @@ -48,6 +58,7 @@ import static java.nio.charset.StandardCharsets.UTF_8; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatExceptionOfType; import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.springframework.web.testfixture.http.server.reactive.MockServerHttpRequest.get; /** * @author Arjen Poutsma @@ -300,7 +311,193 @@ public class DefaultServerRequestTests { this.messageConverters); assertThat(request.principal().get()).isEqualTo(principal); + } + + @ParameterizedHttpMethodTest + void checkNotModifiedTimestamp(String method) throws Exception { + MockHttpServletRequest servletRequest = new MockHttpServletRequest(method, "/"); + Instant now = Instant.now().truncatedTo(ChronoUnit.SECONDS); + servletRequest.addHeader(HttpHeaders.IF_MODIFIED_SINCE, now.toEpochMilli()); + + DefaultServerRequest request = new DefaultServerRequest(servletRequest, this.messageConverters); + + Optional result = request.checkNotModified(now, ""); + + assertThat(result).hasValueSatisfying(serverResponse -> { + assertThat(serverResponse.statusCode()).isEqualTo(HttpStatus.NOT_MODIFIED); + assertThat(serverResponse.headers().getLastModified()).isEqualTo(now.toEpochMilli()); + }); + } + + @ParameterizedHttpMethodTest + void checkModifiedTimestamp(String method) { + MockHttpServletRequest servletRequest = new MockHttpServletRequest(method, "/"); + Instant now = Instant.now().truncatedTo(ChronoUnit.SECONDS); + Instant oneMinuteAgo = now.minus(1, ChronoUnit.MINUTES); + servletRequest.addHeader(HttpHeaders.IF_MODIFIED_SINCE, oneMinuteAgo.toEpochMilli()); + + DefaultServerRequest request = new DefaultServerRequest(servletRequest, this.messageConverters); + + Optional result = request.checkNotModified(now, ""); + + assertThat(result).isEmpty(); + } + + @ParameterizedHttpMethodTest + void checkNotModifiedETag(String method) { + MockHttpServletRequest servletRequest = new MockHttpServletRequest(method, "/"); + String eTag = "\"Foo\""; + servletRequest.addHeader(HttpHeaders.IF_NONE_MATCH, eTag); + + DefaultServerRequest request = new DefaultServerRequest(servletRequest, this.messageConverters); + + Optional result = request.checkNotModified(eTag); + + assertThat(result).hasValueSatisfying(serverResponse -> { + assertThat(serverResponse.statusCode()).isEqualTo(HttpStatus.NOT_MODIFIED); + assertThat(serverResponse.headers().getETag()).isEqualTo(eTag); + }); + } + + @ParameterizedHttpMethodTest + void checkNotModifiedETagWithSeparatorChars(String method) { + MockHttpServletRequest servletRequest = new MockHttpServletRequest(method, "/"); + String eTag = "\"Foo, Bar\""; + servletRequest.addHeader(HttpHeaders.IF_NONE_MATCH, eTag); + + DefaultServerRequest request = new DefaultServerRequest(servletRequest, this.messageConverters); + + Optional result = request.checkNotModified(eTag); + + assertThat(result).hasValueSatisfying(serverResponse -> { + assertThat(serverResponse.statusCode()).isEqualTo(HttpStatus.NOT_MODIFIED); + assertThat(serverResponse.headers().getETag()).isEqualTo(eTag); + }); + } + + @ParameterizedHttpMethodTest + void checkModifiedETag(String method) { + MockHttpServletRequest servletRequest = new MockHttpServletRequest(method, "/"); + String currentETag = "\"Foo\""; + String oldEtag = "Bar"; + servletRequest.addHeader(HttpHeaders.IF_NONE_MATCH, oldEtag); + + DefaultServerRequest request = new DefaultServerRequest(servletRequest, this.messageConverters); + + Optional result = request.checkNotModified(currentETag); + + assertThat(result).isEmpty(); + } + + @ParameterizedHttpMethodTest + void checkNotModifiedUnpaddedETag(String method) { + MockHttpServletRequest servletRequest = new MockHttpServletRequest(method, "/"); + String eTag = "Foo"; + String paddedEtag = String.format("\"%s\"", eTag); + servletRequest.addHeader(HttpHeaders.IF_NONE_MATCH, paddedEtag); + + DefaultServerRequest request = new DefaultServerRequest(servletRequest, this.messageConverters); + + Optional result = request.checkNotModified(eTag); + + assertThat(result).hasValueSatisfying(serverResponse -> { + assertThat(serverResponse.statusCode()).isEqualTo(HttpStatus.NOT_MODIFIED); + assertThat(serverResponse.headers().getETag()).isEqualTo(paddedEtag); + }); + } + + @ParameterizedHttpMethodTest + void checkModifiedUnpaddedETag(String method) { + MockHttpServletRequest servletRequest = new MockHttpServletRequest(method, "/"); + String currentETag = "Foo"; + String oldEtag = "Bar"; + servletRequest.addHeader(HttpHeaders.IF_NONE_MATCH, oldEtag); + + DefaultServerRequest request = new DefaultServerRequest(servletRequest, this.messageConverters); + + Optional result = request.checkNotModified(currentETag); + + assertThat(result).isEmpty(); + } + + @ParameterizedHttpMethodTest + void checkNotModifiedWildcardIsIgnored(String method) { + MockHttpServletRequest servletRequest = new MockHttpServletRequest(method, "/"); + String eTag = "\"Foo\""; + servletRequest.addHeader(HttpHeaders.IF_NONE_MATCH, "*"); + DefaultServerRequest request = new DefaultServerRequest(servletRequest, this.messageConverters); + + Optional result = request.checkNotModified(eTag); + + assertThat(result).isEmpty(); + } + + @ParameterizedHttpMethodTest + void checkNotModifiedETagAndTimestamp(String method) { + MockHttpServletRequest servletRequest = new MockHttpServletRequest(method, "/"); + String eTag = "\"Foo\""; + Instant now = Instant.now().truncatedTo(ChronoUnit.SECONDS); + servletRequest.addHeader(HttpHeaders.IF_NONE_MATCH, eTag); + servletRequest.addHeader(HttpHeaders.IF_MODIFIED_SINCE, now.toEpochMilli()); + + DefaultServerRequest request = new DefaultServerRequest(servletRequest, this.messageConverters); + + Optional result = request.checkNotModified(now, eTag); + + assertThat(result).hasValueSatisfying(serverResponse -> { + assertThat(serverResponse.statusCode()).isEqualTo(HttpStatus.NOT_MODIFIED); + assertThat(serverResponse.headers().getETag()).isEqualTo(eTag); + assertThat(serverResponse.headers().getLastModified()).isEqualTo(now.toEpochMilli()); + }); } + @ParameterizedHttpMethodTest + void checkNotModifiedETagAndModifiedTimestamp(String method) { + MockHttpServletRequest servletRequest = new MockHttpServletRequest(method, "/"); + String eTag = "\"Foo\""; + Instant now = Instant.now().truncatedTo(ChronoUnit.SECONDS); + Instant oneMinuteAgo = now.minus(1, ChronoUnit.MINUTES); + servletRequest.addHeader(HttpHeaders.IF_NONE_MATCH, eTag); + servletRequest.addHeader(HttpHeaders.IF_MODIFIED_SINCE, oneMinuteAgo.toEpochMilli()); + MockServerWebExchange exchange = MockServerWebExchange.from(get("/") + .ifNoneMatch(eTag) + .ifModifiedSince(oneMinuteAgo.toEpochMilli()) + ); + + DefaultServerRequest request = new DefaultServerRequest(servletRequest, this.messageConverters); + + Optional result = request.checkNotModified(now, eTag); + + assertThat(result).hasValueSatisfying(serverResponse -> { + assertThat(serverResponse.statusCode()).isEqualTo(HttpStatus.NOT_MODIFIED); + assertThat(serverResponse.headers().getETag()).isEqualTo(eTag); + assertThat(serverResponse.headers().getLastModified()).isEqualTo(now.toEpochMilli()); + }); + } + + @ParameterizedHttpMethodTest + void checkModifiedETagAndNotModifiedTimestamp(String method) throws Exception { + MockHttpServletRequest servletRequest = new MockHttpServletRequest(method, "/"); + String currentETag = "\"Foo\""; + String oldEtag = "\"Bar\""; + Instant now = Instant.now().truncatedTo(ChronoUnit.SECONDS); + servletRequest.addHeader(HttpHeaders.IF_NONE_MATCH, oldEtag); + servletRequest.addHeader(HttpHeaders.IF_MODIFIED_SINCE, now.toEpochMilli()); + + DefaultServerRequest request = new DefaultServerRequest(servletRequest, this.messageConverters); + + Optional result = request.checkNotModified(now, currentETag); + + assertThat(result).isEmpty(); + } + + + @Retention(RetentionPolicy.RUNTIME) + @Target(ElementType.METHOD) + @ParameterizedTest(name = "[{index}] {0}") + @ValueSource(strings = { "GET", "HEAD" }) + @interface ParameterizedHttpMethodTest { + } + }