Improve DateHeaders in MockServletRequest/Response

Prior to this change, calling the `setDateHeader` method on a
Spring Test MockHttpServletResponse instance would just store the given
long value in a Map, not writing it as a formatted date String.
Also, calling `getDateHeader` on a MockHttpServletRequest would not
support date strings and could not parse those values.

This can be problematic when testing features related to date headers
such as "Expires", "If-Modified-Since", "Last-Modified", etc.

This commit adds formatting and parsing capabilities to Servlet Mocks
for date strings in HTTP headers.

When formatting dates to Strings, the date format used is the one
preferred by the HTTP RFC. When parsing date Strings, multiple date
formats are supported for better compatibility.

Issue: SPR-11912
This commit is contained in:
Brian Clozel 2015-07-22 13:39:19 +02:00
parent 3c799e6e05
commit 43e36e2dee
5 changed files with 198 additions and 42 deletions

View File

@ -24,6 +24,8 @@ import java.io.InputStreamReader;
import java.io.Reader;
import java.io.UnsupportedEncodingException;
import java.security.Principal;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Collection;
import java.util.Collections;
import java.util.Date;
@ -36,6 +38,8 @@ import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.TimeZone;
import javax.servlet.AsyncContext;
import javax.servlet.DispatcherType;
import javax.servlet.RequestDispatcher;
@ -120,6 +124,18 @@ public class MockHttpServletRequest implements HttpServletRequest {
private static final ServletInputStream EMPTY_SERVLET_INPUT_STREAM =
new DelegatingServletInputStream(new ByteArrayInputStream(new byte[0]));
/**
* Date formats as specified in the HTTP RFC
* @see <a href="https://tools.ietf.org/html/rfc7231#section-7.1.1.1">Section 7.1.1.1 of RFC 7231</a>
*/
private static final String[] DATE_FORMATS = new String[] {
"EEE, dd MMM yyyy HH:mm:ss zzz",
"EEE, dd-MMM-yy HH:mm:ss zzz",
"EEE MMM dd HH:mm:ss yyyy"
};
private static final TimeZone GMT = TimeZone.getTimeZone("GMT");
private boolean active = true;
@ -681,7 +697,7 @@ public class MockHttpServletRequest implements HttpServletRequest {
}
/**
* Returns the first preferred {@linkplain Locale locale} configured
* Return the first preferred {@linkplain Locale locale} configured
* in this mock request.
* <p>If no locales have been explicitly configured, the default,
* preferred {@link Locale} for the <em>server</em> mocked by this
@ -699,7 +715,7 @@ public class MockHttpServletRequest implements HttpServletRequest {
}
/**
* Returns an {@linkplain Enumeration enumeration} of the preferred
* Return an {@linkplain Enumeration enumeration} of the preferred
* {@linkplain Locale locales} configured in this mock request.
* <p>If no locales have been explicitly configured, the default,
* preferred {@link Locale} for the <em>server</em> mocked by this
@ -728,7 +744,7 @@ public class MockHttpServletRequest implements HttpServletRequest {
}
/**
* Returns {@code true} if the {@link #setSecure secure} flag has been set
* Return {@code true} if the {@link #setSecure secure} flag has been set
* to {@code true} or if the {@link #getScheme scheme} is {@code https}.
* @see javax.servlet.ServletRequest#isSecure()
*/
@ -860,20 +876,16 @@ public class MockHttpServletRequest implements HttpServletRequest {
/**
* Add a header entry for the given name.
* <p>If there was no entry for that header name before, the value will be used
* as-is. In case of an existing entry, a String array will be created,
* adding the given value (more specifically, its toString representation)
* as further element.
* <p>Multiple values can only be stored as list of Strings, following the
* Servlet spec (see {@code getHeaders} accessor). As alternative to
* repeated {@code addHeader} calls for individual elements, you can
* use a single call with an entire array or Collection of values as
* parameter.
* <p>While this method can take any {@code Object} as a parameter,
* it is recommended to use the following types:
* <ul>
* <li>String or any Object to be converted using {@code toString}, see {@link #getHeader} </li>
* <li>String, Number or Date for date headers, see {@link #getDateHeader}</li>
* <li>String or Number for integer headers, see {@link #getIntHeader}</li>
* <li>{@code String[]} and {@code Collection<String>} for multiple values, see {@link #getHeaders}</li>
* </ul>
* @see #getHeaderNames
* @see #getHeader
* @see #getHeaders
* @see #getDateHeader
* @see #getIntHeader
*/
public void addHeader(String name, Object value) {
if (CONTENT_TYPE_HEADER.equalsIgnoreCase(name)) {
@ -902,6 +914,18 @@ public class MockHttpServletRequest implements HttpServletRequest {
}
}
/**
* Return the long timestamp for the date header with the given {@code name}.
* <p>If the internal value representation is a String, this method will try
* to parse it as a date using the supported date formats:
* <ul>
* <li>"EEE, dd MMM yyyy HH:mm:ss zzz"</li>
* <li>"EEE, dd-MMM-yy HH:mm:ss zzz"</li>
* <li>"EEE MMM dd HH:mm:ss yyyy"</li>
* </ul>
* @param name the header name
* @see <a href="https://tools.ietf.org/html/rfc7231#section-7.1.1.1">Section 7.1.1.1 of RFC 7231</a>
*/
@Override
public long getDateHeader(String name) {
HeaderValueHolder header = HeaderValueHolder.getByName(this.headers, name);
@ -912,15 +936,32 @@ public class MockHttpServletRequest implements HttpServletRequest {
else if (value instanceof Number) {
return ((Number) value).longValue();
}
else if (value instanceof String) {
return parseDateHeader(name, (String) value);
}
else if (value != null) {
throw new IllegalArgumentException(
"Value for header '" + name + "' is neither a Date nor a Number: " + value);
"Value for header '" + name + "' is not a Date, Number, or String: " + value);
}
else {
return -1L;
}
}
private long parseDateHeader(String name, String value) {
for (String dateFormat : DATE_FORMATS) {
SimpleDateFormat simpleDateFormat = new SimpleDateFormat(dateFormat, Locale.US);
simpleDateFormat.setTimeZone(GMT);
try {
return simpleDateFormat.parse(value).getTime();
}
catch (ParseException ex) {
// ignore
}
}
throw new IllegalArgumentException("Cannot parse date value '" + value + "' for '" + name + "' header");
}
@Override
public String getHeader(String name) {
HeaderValueHolder header = HeaderValueHolder.getByName(this.headers, name);
@ -1171,7 +1212,7 @@ public class MockHttpServletRequest implements HttpServletRequest {
@Override
public Collection<Part> getParts() throws IOException, IllegalStateException, ServletException {
List<Part> result = new LinkedList<Part>();
for(List<Part> list : this.parts.values()) {
for (List<Part> list : this.parts.values()) {
result.addAll(list);
}
return result;

View File

@ -23,12 +23,16 @@ import java.io.OutputStreamWriter;
import java.io.PrintWriter;
import java.io.UnsupportedEncodingException;
import java.io.Writer;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.Date;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.TimeZone;
import javax.servlet.ServletOutputStream;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletResponse;
@ -47,6 +51,7 @@ import org.springframework.web.util.WebUtils;
*
* @author Juergen Hoeller
* @author Rod Johnson
* @author Brian Clozel
* @since 1.0.2
*/
public class MockHttpServletResponse implements HttpServletResponse {
@ -59,6 +64,10 @@ public class MockHttpServletResponse implements HttpServletResponse {
private static final String LOCATION_HEADER = "Location";
private static final String DATE_FORMAT = "EEE, dd MMM yyyy HH:mm:ss zzz";
private static final TimeZone GMT = TimeZone.getTimeZone("GMT");
//---------------------------------------------------------------------
// ServletResponse properties
@ -481,12 +490,18 @@ public class MockHttpServletResponse implements HttpServletResponse {
@Override
public void setDateHeader(String name, long value) {
setHeaderValue(name, value);
setHeaderValue(name, formatDate(value));
}
@Override
public void addDateHeader(String name, long value) {
addHeaderValue(name, value);
addHeaderValue(name, formatDate(value));
}
private String formatDate(long date) {
SimpleDateFormat dateFormat = new SimpleDateFormat(DATE_FORMAT, Locale.US);
dateFormat.setTimeZone(GMT);
return dateFormat.format(new Date(date));
}
@Override

View File

@ -1,5 +1,5 @@
/*
* Copyright 2002-2014 the original author or authors.
* Copyright 2002-2015 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.
@ -21,6 +21,7 @@ import java.nio.charset.Charset;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Date;
import java.util.Enumeration;
import java.util.HashMap;
import java.util.List;
@ -40,11 +41,17 @@ import static org.junit.Assert.*;
* @author Mark Fisher
* @author Rossen Stoyanchev
* @author Sam Brannen
* @author Brian Clozel
* @author Jakub Narloch
*/
public class MockHttpServletRequestTests {
private static final String HOST = "Host";
private static final String CONTENT_TYPE = "Content-Type";
private static final String IF_MODIFIED_SINCE = "If-Modified-Since";
private MockHttpServletRequest request = new MockHttpServletRequest();
@ -69,7 +76,7 @@ public class MockHttpServletRequestTests {
String contentType = "test/plain";
request.setContentType(contentType);
assertEquals(contentType, request.getContentType());
assertEquals(contentType, request.getHeader("Content-Type"));
assertEquals(contentType, request.getHeader(CONTENT_TYPE));
assertNull(request.getCharacterEncoding());
}
@ -78,7 +85,7 @@ public class MockHttpServletRequestTests {
String contentType = "test/plain;charset=UTF-8";
request.setContentType(contentType);
assertEquals(contentType, request.getContentType());
assertEquals(contentType, request.getHeader("Content-Type"));
assertEquals(contentType, request.getHeader(CONTENT_TYPE));
assertEquals("UTF-8", request.getCharacterEncoding());
}
@ -87,7 +94,7 @@ public class MockHttpServletRequestTests {
String contentType = "test/plain";
request.addHeader("Content-Type", contentType);
assertEquals(contentType, request.getContentType());
assertEquals(contentType, request.getHeader("Content-Type"));
assertEquals(contentType, request.getHeader(CONTENT_TYPE));
assertNull(request.getCharacterEncoding());
}
@ -96,7 +103,7 @@ public class MockHttpServletRequestTests {
String contentType = "test/plain;charset=UTF-8";
request.addHeader("Content-Type", contentType);
assertEquals(contentType, request.getContentType());
assertEquals(contentType, request.getHeader("Content-Type"));
assertEquals(contentType, request.getHeader(CONTENT_TYPE));
assertEquals("UTF-8", request.getCharacterEncoding());
}
@ -107,7 +114,7 @@ public class MockHttpServletRequestTests {
String contentType = "test/plain;charset=\"utf-8\";foo=\"charset=bar\";foocharset=bar;foo=bar";
request.addHeader("Content-Type", contentType);
assertEquals(contentType, request.getContentType());
assertEquals(contentType, request.getHeader("Content-Type"));
assertEquals(contentType, request.getHeader(CONTENT_TYPE));
assertEquals("UTF-8", request.getCharacterEncoding());
}
@ -116,7 +123,7 @@ public class MockHttpServletRequestTests {
request.setContentType("test/plain");
request.setCharacterEncoding("UTF-8");
assertEquals("test/plain", request.getContentType());
assertEquals("test/plain;charset=UTF-8", request.getHeader("Content-Type"));
assertEquals("test/plain;charset=UTF-8", request.getHeader(CONTENT_TYPE));
assertEquals("UTF-8", request.getCharacterEncoding());
}
@ -125,7 +132,7 @@ public class MockHttpServletRequestTests {
request.setCharacterEncoding("UTF-8");
request.setContentType("test/plain");
assertEquals("test/plain", request.getContentType());
assertEquals("test/plain;charset=UTF-8", request.getHeader("Content-Type"));
assertEquals("test/plain;charset=UTF-8", request.getHeader(CONTENT_TYPE));
assertEquals("UTF-8", request.getCharacterEncoding());
}
@ -378,6 +385,44 @@ public class MockHttpServletRequestTests {
assertTrue(request.isSecure());
}
@Test
public void httpHeaderDate() throws Exception {
Date date = new Date();
request.addHeader(IF_MODIFIED_SINCE, date);
assertEquals(date.getTime(), request.getDateHeader(IF_MODIFIED_SINCE));
}
@Test
public void httpHeaderTimestamp() throws Exception {
long timestamp = new Date().getTime();
request.addHeader(IF_MODIFIED_SINCE, timestamp);
assertEquals(timestamp, request.getDateHeader(IF_MODIFIED_SINCE));
}
@Test
public void httpHeaderRfcFormatedDate() throws Exception {
request.addHeader(IF_MODIFIED_SINCE, "Tue, 21 Jul 2015 10:00:00 GMT");
assertEquals(1437472800000L, request.getDateHeader(IF_MODIFIED_SINCE));
}
@Test
public void httpHeaderFirstVariantFormatedDate() throws Exception {
request.addHeader(IF_MODIFIED_SINCE, "Tue, 21-Jul-15 10:00:00 GMT");
assertEquals(1437472800000L, request.getDateHeader(IF_MODIFIED_SINCE));
}
@Test
public void httpHeaderSecondVariantFormatedDate() throws Exception {
request.addHeader(IF_MODIFIED_SINCE, "Tue Jul 21 10:00:00 2015");
assertEquals(1437472800000L, request.getDateHeader(IF_MODIFIED_SINCE));
}
@Test(expected = IllegalArgumentException.class)
public void httpHeaderFormatedDateError() throws Exception {
request.addHeader(IF_MODIFIED_SINCE, "This is not a date");
request.getDateHeader(IF_MODIFIED_SINCE);
}
private void assertEqualEnumerations(Enumeration<?> enum1, Enumeration<?> enum2) {
assertNotNull(enum1);
assertNotNull(enum2);

View File

@ -1,5 +1,5 @@
/*
* Copyright 2002-2014 the original author or authors.
* Copyright 2002-2015 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.
@ -19,6 +19,7 @@ package org.springframework.mock.web;
import java.io.IOException;
import java.util.Arrays;
import java.util.Collection;
import javax.servlet.http.HttpServletResponse;
import org.junit.Test;
@ -238,6 +239,20 @@ public class MockHttpServletResponseTests {
assertEquals(redirectUrl, response.getRedirectedUrl());
}
@Test
public void setDateHeader() {
response.setDateHeader("Last-Modified", 1437472800000L);
assertEquals("Tue, 21 Jul 2015 10:00:00 GMT", response.getHeader("Last-Modified"));
}
@Test
public void addDateHeader() {
response.addDateHeader("Last-Modified", 1437472800000L);
response.addDateHeader("Last-Modified", 1437472801000L);
assertEquals("Tue, 21 Jul 2015 10:00:00 GMT", response.getHeaders("Last-Modified").get(0));
assertEquals("Tue, 21 Jul 2015 10:00:01 GMT", response.getHeaders("Last-Modified").get(1));
}
/**
* SPR-10414
*/

View File

@ -24,6 +24,8 @@ import java.io.InputStreamReader;
import java.io.Reader;
import java.io.UnsupportedEncodingException;
import java.security.Principal;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Collection;
import java.util.Collections;
import java.util.Date;
@ -36,6 +38,8 @@ import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.TimeZone;
import javax.servlet.AsyncContext;
import javax.servlet.DispatcherType;
import javax.servlet.RequestDispatcher;
@ -120,6 +124,17 @@ public class MockHttpServletRequest implements HttpServletRequest {
private static final ServletInputStream EMPTY_SERVLET_INPUT_STREAM =
new DelegatingServletInputStream(new ByteArrayInputStream(new byte[0]));
/**
* Date formats as specified in the HTTP RFC
* @see <a href="https://tools.ietf.org/html/rfc7231#section-7.1.1.1">Section 7.1.1.1 of RFC 7231</a>
*/
private static final String[] DATE_FORMATS = new String[] {
"EEE, dd MMM yyyy HH:mm:ss zzz",
"EEE, dd-MMM-yy HH:mm:ss zzz",
"EEE MMM dd HH:mm:ss yyyy"
};
private static TimeZone GMT = TimeZone.getTimeZone("GMT");
private boolean active = true;
@ -860,20 +875,16 @@ public class MockHttpServletRequest implements HttpServletRequest {
/**
* Add a header entry for the given name.
* <p>If there was no entry for that header name before, the value will be used
* as-is. In case of an existing entry, a String array will be created,
* adding the given value (more specifically, its toString representation)
* as further element.
* <p>Multiple values can only be stored as list of Strings, following the
* Servlet spec (see {@code getHeaders} accessor). As alternative to
* repeated {@code addHeader} calls for individual elements, you can
* use a single call with an entire array or Collection of values as
* parameter.
* <p>While this method can take any {@code Object} as a parameter,
* it is recommended to use the following types:
* <ul>
* <li>String or any Object to be converted using {@code toString}, see {@link #getHeader}</li>
* <li>String, Number or Date for date headers, see {@link #getDateHeader}</li>
* <li>String or Number for integer headers, see {@link #getIntHeader}</li>
* <li>{@code String[]} and {@code Collection<String>} for multiple values, see {@link #getHeaders}</li>
* </ul>
* @see #getHeaderNames
* @see #getHeader
* @see #getHeaders
* @see #getDateHeader
* @see #getIntHeader
*/
public void addHeader(String name, Object value) {
if (CONTENT_TYPE_HEADER.equalsIgnoreCase(name)) {
@ -902,6 +913,18 @@ public class MockHttpServletRequest implements HttpServletRequest {
}
}
/**
* Return the long timestamp for the date header with the given {@code name}.
* <p>If the internal value representation is a String, this method will try
* to parse it as a date using the supported date formats:
* <ul>
* <li>"EEE, dd MMM yyyy HH:mm:ss zzz"</li>
* <li>"EEE, dd-MMM-yy HH:mm:ss zzz"</li>
* <li>"EEE MMM dd HH:mm:ss yyyy"</li>
* </ul>
* @param name the header name
* @see <a href="https://tools.ietf.org/html/rfc7231#section-7.1.1.1">Section 7.1.1.1 of RFC 7231</a>
*/
@Override
public long getDateHeader(String name) {
HeaderValueHolder header = HeaderValueHolder.getByName(this.headers, name);
@ -912,15 +935,32 @@ public class MockHttpServletRequest implements HttpServletRequest {
else if (value instanceof Number) {
return ((Number) value).longValue();
}
else if (value instanceof String) {
return parseDateHeader(name, (String) value);
}
else if (value != null) {
throw new IllegalArgumentException(
"Value for header '" + name + "' is neither a Date nor a Number: " + value);
"Value for header '" + name + "' is not a Date, Number, or String: " + value);
}
else {
return -1L;
}
}
private long parseDateHeader(String name, String value) {
for (String dateFormat : DATE_FORMATS) {
SimpleDateFormat simpleDateFormat = new SimpleDateFormat(dateFormat, Locale.US);
simpleDateFormat.setTimeZone(GMT);
try {
return simpleDateFormat.parse(value).getTime();
}
catch (ParseException ex) {
// ignore
}
}
throw new IllegalArgumentException("Cannot parse date value '" + value + "' for '" + name + "' header");
}
@Override
public String getHeader(String name) {
HeaderValueHolder header = HeaderValueHolder.getByName(this.headers, name);
@ -1171,7 +1211,7 @@ public class MockHttpServletRequest implements HttpServletRequest {
@Override
public Collection<Part> getParts() throws IOException, IllegalStateException, ServletException {
List<Part> result = new LinkedList<Part>();
for(List<Part> list : this.parts.values()) {
for (List<Part> list : this.parts.values()) {
result.addAll(list);
}
return result;