Skip to content

ETag/If-None-Match logic in HttpEntityMethodProcessor should not affect methods other than HTTP GET [SPR-13496] #18074

Closed
@spring-projects-issues

Description

@spring-projects-issues

Francisco Lozano opened SPR-13496 and commented

A new etag-related logic has been introduced that breaks backward compatibility.

In HttpEntityMethodProcessor, the line:

|| clientETag.equals("*"))) {

maybe acceptable for GET, but definitely not for PUT/POST/PATCH verbs, because if I do:

PUT /some/resource
If-None-Match: *

the intention is to create the resource only if it doesn't exist previously. Forcing 304 not modified makes absolutely no sense here, because I want to return 201 created (which I set in my ResponseEntity<MyResponseObject>).

I haven't find any obvious way to disable this magic etag handling, that's why I'm marking this issue as blocker (feel free to downgrade if a workaround is available).

I think all this logic may make sense in some applications, but it can introduce heavy incompatibilities in existing REST APIs.

I wish I had found this when in RC - but I couldn't because of other issues of my components with 4.2 that only now I have been able to fix.

Full changes on this class:

git diff v4.1.7.RELEASE..v4.2.1.RELEASE -- spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/HttpEntityMethodProcessor.java
diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/HttpEntityMethodProcessor.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/HttpEntityMethodProcessor.java
index 26d06ef..22ebdbc 100644
--- a/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/HttpEntityMethodProcessor.java
+++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/HttpEntityMethodProcessor.java
@@ -19,18 +19,22 @@ package org.springframework.web.servlet.mvc.method.annotation;
 import java.io.IOException;
 import java.lang.reflect.ParameterizedType;
 import java.lang.reflect.Type;
+import java.net.URI;
 import java.util.List;

 import org.springframework.core.MethodParameter;
 import org.springframework.core.ResolvableType;
 import org.springframework.http.HttpEntity;
 import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpMethod;
+import org.springframework.http.HttpStatus;
 import org.springframework.http.RequestEntity;
 import org.springframework.http.ResponseEntity;
 import org.springframework.http.converter.HttpMessageConverter;
 import org.springframework.http.server.ServletServerHttpRequest;
 import org.springframework.http.server.ServletServerHttpResponse;
 import org.springframework.util.Assert;
+import org.springframework.util.StringUtils;
 import org.springframework.web.HttpMediaTypeNotSupportedException;
 import org.springframework.web.accept.ContentNegotiationManager;
 import org.springframework.web.bind.support.WebDataBinderFactory;
@@ -48,31 +52,59 @@ import org.springframework.web.method.support.ModelAndViewContainer;
  *
  * @author Arjen Poutsma
  * @author Rossen Stoyanchev
+ * @author Brian Clozel
  * @since 3.1
  */
 public class HttpEntityMethodProcessor extends AbstractMessageConverterMethodProcessor {

-       public HttpEntityMethodProcessor(List<HttpMessageConverter<?>> messageConverters) {
-               super(messageConverters);
+
+       /**
+        * Basic constructor with converters only. Suitable for resolving
+        * {@code HttpEntity}. For handling {@code ResponseEntity} consider also
+        * providing a {@code ContentNegotiationManager}.
+        */
+       public HttpEntityMethodProcessor(List<HttpMessageConverter<?>> converters) {
+               super(converters);
+       }
+
+       /**
+        * Basic constructor with converters and {@code ContentNegotiationManager}.
+        * Suitable for resolving {@code HttpEntity} and handling {@code ResponseEntity}
+        * without {@code Request~} or {@code ResponseBodyAdvice}.
+        */
+       public HttpEntityMethodProcessor(List<HttpMessageConverter<?>> converters,
+                       ContentNegotiationManager manager) {
+
+               super(converters, manager);
        }

-       public HttpEntityMethodProcessor(List<HttpMessageConverter<?>> messageConverters,
-                       ContentNegotiationManager contentNegotiationManager) {
+       /**
+        * Complete constructor for resolving {@code HttpEntity} method arguments.
+        * For handling {@code ResponseEntity} consider also providing a
+        * {@code ContentNegotiationManager}.
+        * @since 4.2
+        */
+       public HttpEntityMethodProcessor(List<HttpMessageConverter<?>> converters,
+                       List<Object> requestResponseBodyAdvice) {

-               super(messageConverters, contentNegotiationManager);
+               super(converters, null, requestResponseBodyAdvice);
        }

-       public HttpEntityMethodProcessor(List<HttpMessageConverter<?>> messageConverters,
-                       ContentNegotiationManager contentNegotiationManager, List<Object> responseBodyAdvice) {
+       /**
+        * Complete constructor for resolving {@code HttpEntity} and handling
+        * {@code ResponseEntity}.
+        */
+       public HttpEntityMethodProcessor(List<HttpMessageConverter<?>> converters,
+                       ContentNegotiationManager manager, List<Object> requestResponseBodyAdvice) {

-               super(messageConverters, contentNegotiationManager, responseBodyAdvice);
+               super(converters, manager, requestResponseBodyAdvice);
        }


        @Override
        public boolean supportsParameter(MethodParameter parameter) {
-               return (HttpEntity.class.equals(parameter.getParameterType()) ||
-                               RequestEntity.class.equals(parameter.getParameterType()));
+               return (HttpEntity.class == parameter.getParameterType() ||
+                               RequestEntity.class == parameter.getParameterType());
        }

        @Override
@@ -90,9 +122,10 @@ public class HttpEntityMethodProcessor extends AbstractMessageConverterMethodPro
                Type paramType = getHttpEntityType(parameter);

                Object body = readWithMessageConverters(webRequest, parameter, paramType);
-               if (RequestEntity.class.equals(parameter.getParameterType())) {
-                       return new RequestEntity<Object>(body, inputMessage.getHeaders(),
-                                       inputMessage.getMethod(), inputMessage.getURI());
+               if (RequestEntity.class == parameter.getParameterType()) {
+                       URI url = inputMessage.getURI();
+                       HttpMethod httpMethod = inputMessage.getMethod();
+                       return new RequestEntity<Object>(body, inputMessage.getHeaders(), httpMethod, url);
                }
                else {
                        return new HttpEntity<Object>(body, inputMessage.getHeaders());
@@ -131,22 +164,74 @@ public class HttpEntityMethodProcessor extends AbstractMessageConverterMethodPro

                Assert.isInstanceOf(HttpEntity.class, returnValue);
                HttpEntity<?> responseEntity = (HttpEntity<?>) returnValue;
-               if (responseEntity instanceof ResponseEntity) {
-                       outputMessage.setStatusCode(((ResponseEntity<?>) responseEntity).getStatusCode());
-               }

                HttpHeaders entityHeaders = responseEntity.getHeaders();
                if (!entityHeaders.isEmpty()) {
                        outputMessage.getHeaders().putAll(entityHeaders);
                }
-
                Object body = responseEntity.getBody();
+               if (responseEntity instanceof ResponseEntity) {
+                       outputMessage.setStatusCode(((ResponseEntity<?>) responseEntity).getStatusCode());
+                       if (isResourceNotModified(inputMessage, outputMessage)) {
+                               outputMessage.setStatusCode(HttpStatus.NOT_MODIFIED);
+                               // Ensure headers are flushed, no body should be written.
+                               outputMessage.flush();
+                               // Skip call to converters, as they may update the body.
+                               return;
+                       }
+               }

                // Try even with null body. ResponseBodyAdvice could get involved.
                writeWithMessageConverters(body, returnType, inputMessage, outputMessage);

                // Ensure headers are flushed even if no body was written.
-               outputMessage.getBody();
+               outputMessage.flush();
+       }
+
+       private boolean isResourceNotModified(ServletServerHttpRequest inputMessage, ServletServerHttpResponse outputMessage) {
+
+               List<String> ifNoneMatch = inputMessage.getHeaders().getIfNoneMatch();
+               long ifModifiedSince = inputMessage.getHeaders().getIfModifiedSince();
+               String eTag = addEtagPadding(outputMessage.getHeaders().getETag());
+               long lastModified = outputMessage.getHeaders().getLastModified();
+               boolean notModified = false;
+
+               if (lastModified != -1 && StringUtils.hasLength(eTag)) {
+                       notModified = isETagNotModified(ifNoneMatch, eTag) && isTimeStampNotModified(ifModifiedSince, lastModified);
+               }
+               else if (lastModified != -1) {
+                       notModified = isTimeStampNotModified(ifModifiedSince, lastModified);
+               }
+               else if (StringUtils.hasLength(eTag)) {
+                       notModified = isETagNotModified(ifNoneMatch, eTag);
+               }
+               return notModified;
+       }
+
+       private boolean isETagNotModified(List<String> ifNoneMatch, String etag) {
+               if (StringUtils.hasLength(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/", ""))
+                                                               || clientETag.equals("*"))) {
+                                       return true;
+                               }
+                       }
+               }
+               return false;
+       }
+
+       private boolean isTimeStampNotModified(long ifModifiedSince, long lastModifiedTimestamp) {
+               return (ifModifiedSince >= (lastModifiedTimestamp / 1000 * 1000));
+       }
+
+       private String addEtagPadding(String etag) {
+               if (StringUtils.hasLength(etag) &&
+                               (!(etag.startsWith("\"") || etag.startsWith("W/\"")) || !etag.endsWith("\"")) ) {
+                       etag = "\"" + etag + "\"";
+               }
+               return etag;
        }

        @Override

Affects: 4.2 GA, 4.2.1

Issue Links:

Referenced from: commits 583a48a

1 votes, 5 watchers

Metadata

Metadata

Assignees

Labels

in: webIssues in web modules (web, webmvc, webflux, websocket)type: bugA general bug

Type

No type

Projects

No projects

Milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions