From 46b2e45fd572a14bd91e3cf3e5daee9781b4f508 Mon Sep 17 00:00:00 2001 From: Spencer Gibb Date: Mon, 27 Jun 2016 13:15:54 -0600 Subject: [PATCH] Adds a RibbonRequestCustomizer. This allows developers to customize the request builders for zuul requests to inject headers or modify the request in general. Each zuul request type (RestClient, Apache HttpClient and OkHttp) is supported. fixes gh-1141 --- .../apache/RibbonApacheHttpRequest.java | 4 + .../ribbon/okhttp/OkHttpRibbonRequest.java | 11 +- .../ribbon/support/ContextAwareRequest.java | 4 +- .../support/RibbonRequestCustomizer.java | 40 ++++++ .../netflix/zuul/ZuulProxyConfiguration.java | 11 +- .../route/RestClientRibbonCommand.java | 15 ++- .../filters/route/RibbonCommandContext.java | 22 ++++ .../filters/route/RibbonRoutingFilter.java | 11 +- .../apache/RibbonApacheHttpRequestTests.java | 23 +++- .../okhttp/OkHttpRibbonRequestTests.java | 19 ++- .../route/RestClientRibbonCommandTests.java | 118 ++++++++++++++++++ 11 files changed, 260 insertions(+), 18 deletions(-) create mode 100644 spring-cloud-netflix-core/src/main/java/org/springframework/cloud/netflix/ribbon/support/RibbonRequestCustomizer.java create mode 100644 spring-cloud-netflix-core/src/test/java/org/springframework/cloud/netflix/zuul/filters/route/RestClientRibbonCommandTests.java diff --git a/spring-cloud-netflix-core/src/main/java/org/springframework/cloud/netflix/ribbon/apache/RibbonApacheHttpRequest.java b/spring-cloud-netflix-core/src/main/java/org/springframework/cloud/netflix/ribbon/apache/RibbonApacheHttpRequest.java index f4157883..2c1cb96c 100644 --- a/spring-cloud-netflix-core/src/main/java/org/springframework/cloud/netflix/ribbon/apache/RibbonApacheHttpRequest.java +++ b/spring-cloud-netflix-core/src/main/java/org/springframework/cloud/netflix/ribbon/apache/RibbonApacheHttpRequest.java @@ -16,6 +16,8 @@ package org.springframework.cloud.netflix.ribbon.apache; +import static org.springframework.cloud.netflix.ribbon.support.RibbonRequestCustomizer.Runner.customize; + import java.net.URI; import java.util.List; @@ -69,6 +71,8 @@ public class RibbonApacheHttpRequest extends ContextAwareRequest implements Clon builder.setEntity(entity); } + customize(this.context.getRequestCustomizers(), builder); + builder.setConfig(requestConfig); return builder.build(); } diff --git a/spring-cloud-netflix-core/src/main/java/org/springframework/cloud/netflix/ribbon/okhttp/OkHttpRibbonRequest.java b/spring-cloud-netflix-core/src/main/java/org/springframework/cloud/netflix/ribbon/okhttp/OkHttpRibbonRequest.java index fb83140b..fa81212d 100644 --- a/spring-cloud-netflix-core/src/main/java/org/springframework/cloud/netflix/ribbon/okhttp/OkHttpRibbonRequest.java +++ b/spring-cloud-netflix-core/src/main/java/org/springframework/cloud/netflix/ribbon/okhttp/OkHttpRibbonRequest.java @@ -16,6 +16,8 @@ package org.springframework.cloud.netflix.ribbon.okhttp; +import static org.springframework.cloud.netflix.ribbon.support.RibbonRequestCustomizer.Runner.customize; + import java.io.IOException; import java.io.InputStream; import java.net.URI; @@ -72,11 +74,14 @@ public class OkHttpRibbonRequest extends ContextAwareRequest implements Cloneabl requestBody = new InputStreamRequestBody(this.context.getRequestEntity(), mediaType, this.context.getContentLength()); } - return new Request.Builder() + Request.Builder builder = new Request.Builder() .url(url.build()) .headers(headers.build()) - .method(this.context.getMethod(), requestBody) - .build(); + .method(this.context.getMethod(), requestBody); + + customize(this.context.getRequestCustomizers(), builder); + + return builder.build(); } public OkHttpRibbonRequest withNewUri(final URI uri) { diff --git a/spring-cloud-netflix-core/src/main/java/org/springframework/cloud/netflix/ribbon/support/ContextAwareRequest.java b/spring-cloud-netflix-core/src/main/java/org/springframework/cloud/netflix/ribbon/support/ContextAwareRequest.java index af62d9bf..093ed84e 100644 --- a/spring-cloud-netflix-core/src/main/java/org/springframework/cloud/netflix/ribbon/support/ContextAwareRequest.java +++ b/spring-cloud-netflix-core/src/main/java/org/springframework/cloud/netflix/ribbon/support/ContextAwareRequest.java @@ -41,8 +41,8 @@ public abstract class ContextAwareRequest extends ClientRequest { protected RibbonCommandContext newContext(URI uri) { RibbonCommandContext commandContext = new RibbonCommandContext(this.context.getServiceId(), this.context.getMethod(), uri.toString(), this.context.getRetryable(), - this.context.getHeaders(), this.context.getParams(), this.context.getRequestEntity()); - commandContext.setContentLength(this.context.getContentLength()); + this.context.getHeaders(), this.context.getParams(), this.context.getRequestEntity(), + this.context.getRequestCustomizers(), this.context.getContentLength()); return commandContext; } } diff --git a/spring-cloud-netflix-core/src/main/java/org/springframework/cloud/netflix/ribbon/support/RibbonRequestCustomizer.java b/spring-cloud-netflix-core/src/main/java/org/springframework/cloud/netflix/ribbon/support/RibbonRequestCustomizer.java new file mode 100644 index 00000000..4e416a1b --- /dev/null +++ b/spring-cloud-netflix-core/src/main/java/org/springframework/cloud/netflix/ribbon/support/RibbonRequestCustomizer.java @@ -0,0 +1,40 @@ +/* + * Copyright 2013-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.cloud.netflix.ribbon.support; + +import java.util.List; + +/** + * @author Spencer Gibb + */ +public interface RibbonRequestCustomizer { + boolean accepts(Class builderClass); + void customize(B builder); + + class Runner { + + @SuppressWarnings("unchecked") + public static void customize(List customizers, Object builder) { + for (RibbonRequestCustomizer customizer : customizers) { + if (customizer.accepts(builder.getClass())) { + customizer.customize(builder); + } + } + } + } +} diff --git a/spring-cloud-netflix-core/src/main/java/org/springframework/cloud/netflix/zuul/ZuulProxyConfiguration.java b/spring-cloud-netflix-core/src/main/java/org/springframework/cloud/netflix/zuul/ZuulProxyConfiguration.java index a21d30e0..4ac23b6d 100644 --- a/spring-cloud-netflix-core/src/main/java/org/springframework/cloud/netflix/zuul/ZuulProxyConfiguration.java +++ b/spring-cloud-netflix-core/src/main/java/org/springframework/cloud/netflix/zuul/ZuulProxyConfiguration.java @@ -16,6 +16,9 @@ package org.springframework.cloud.netflix.zuul; +import java.util.Collections; +import java.util.List; + import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.actuate.endpoint.Endpoint; import org.springframework.boot.actuate.trace.TraceRepository; @@ -30,6 +33,7 @@ import org.springframework.cloud.client.discovery.event.HeartbeatMonitor; import org.springframework.cloud.client.discovery.event.InstanceRegisteredEvent; import org.springframework.cloud.client.discovery.event.ParentHeartbeatEvent; import org.springframework.cloud.netflix.ribbon.SpringClientFactory; +import org.springframework.cloud.netflix.ribbon.support.RibbonRequestCustomizer; import org.springframework.cloud.netflix.zuul.filters.ProxyRequestHelper; import org.springframework.cloud.netflix.zuul.filters.RouteLocator; import org.springframework.cloud.netflix.zuul.filters.TraceProxyRequestHelper; @@ -38,12 +42,12 @@ import org.springframework.cloud.netflix.zuul.filters.discovery.DiscoveryClientR import org.springframework.cloud.netflix.zuul.filters.discovery.ServiceRouteMapper; import org.springframework.cloud.netflix.zuul.filters.discovery.SimpleServiceRouteMapper; import org.springframework.cloud.netflix.zuul.filters.pre.PreDecorationFilter; -import org.springframework.cloud.netflix.zuul.filters.route.okhttp.OkHttpRibbonCommandFactory; import org.springframework.cloud.netflix.zuul.filters.route.RestClientRibbonCommandFactory; import org.springframework.cloud.netflix.zuul.filters.route.RibbonCommandFactory; import org.springframework.cloud.netflix.zuul.filters.route.RibbonRoutingFilter; import org.springframework.cloud.netflix.zuul.filters.route.SimpleHostRoutingFilter; import org.springframework.cloud.netflix.zuul.filters.route.apache.HttpClientRibbonCommandFactory; +import org.springframework.cloud.netflix.zuul.filters.route.okhttp.OkHttpRibbonCommandFactory; import org.springframework.cloud.netflix.zuul.web.ZuulHandlerMapping; import org.springframework.context.ApplicationEvent; import org.springframework.context.ApplicationListener; @@ -57,6 +61,9 @@ import org.springframework.context.annotation.Configuration; @Configuration public class ZuulProxyConfiguration extends ZuulConfiguration { + @Autowired(required = false) + private List requestCustomizers = Collections.emptyList(); + @Autowired private DiscoveryClient discovery; @@ -122,7 +129,7 @@ public class ZuulProxyConfiguration extends ZuulConfiguration { public RibbonRoutingFilter ribbonRoutingFilter(ProxyRequestHelper helper, RibbonCommandFactory ribbonCommandFactory) { RibbonRoutingFilter filter = new RibbonRoutingFilter(helper, - ribbonCommandFactory); + ribbonCommandFactory, this.requestCustomizers); return filter; } diff --git a/spring-cloud-netflix-core/src/main/java/org/springframework/cloud/netflix/zuul/filters/route/RestClientRibbonCommand.java b/spring-cloud-netflix-core/src/main/java/org/springframework/cloud/netflix/zuul/filters/route/RestClientRibbonCommand.java index 3acb2dd3..2631a3b2 100644 --- a/spring-cloud-netflix-core/src/main/java/org/springframework/cloud/netflix/zuul/filters/route/RestClientRibbonCommand.java +++ b/spring-cloud-netflix-core/src/main/java/org/springframework/cloud/netflix/zuul/filters/route/RestClientRibbonCommand.java @@ -17,16 +17,19 @@ package org.springframework.cloud.netflix.zuul.filters.route; -import com.netflix.client.http.HttpRequest; -import com.netflix.client.http.HttpResponse; -import com.netflix.niws.client.http.RestClient; -import org.springframework.cloud.netflix.zuul.filters.route.support.AbstractRibbonCommand; -import org.springframework.util.MultiValueMap; +import static org.springframework.cloud.netflix.ribbon.support.RibbonRequestCustomizer.Runner.customize; import java.io.InputStream; import java.net.URI; import java.util.List; +import org.springframework.cloud.netflix.zuul.filters.route.support.AbstractRibbonCommand; +import org.springframework.util.MultiValueMap; + +import com.netflix.client.http.HttpRequest; +import com.netflix.client.http.HttpResponse; +import com.netflix.niws.client.http.RestClient; + /** * Hystrix wrapper around Eureka Ribbon command * @@ -79,7 +82,7 @@ public class RestClientRibbonCommand extends AbstractRibbonCommand headers; + @NonNull private final MultiValueMap params; private final InputStream requestEntity; + @NonNull + private final List requestCustomizers; private Long contentLength; + public RibbonCommandContext(String serviceId, String method, String uri, Boolean retryable, + MultiValueMap headers, MultiValueMap params, + InputStream requestEntity) { + this(serviceId, method, uri, retryable, headers, params, requestEntity, + new ArrayList(), null); + } + public URI uri() { try { return new URI(this.uri); diff --git a/spring-cloud-netflix-core/src/main/java/org/springframework/cloud/netflix/zuul/filters/route/RibbonRoutingFilter.java b/spring-cloud-netflix-core/src/main/java/org/springframework/cloud/netflix/zuul/filters/route/RibbonRoutingFilter.java index 95f7ec5c..8e355424 100644 --- a/spring-cloud-netflix-core/src/main/java/org/springframework/cloud/netflix/zuul/filters/route/RibbonRoutingFilter.java +++ b/spring-cloud-netflix-core/src/main/java/org/springframework/cloud/netflix/zuul/filters/route/RibbonRoutingFilter.java @@ -18,11 +18,13 @@ package org.springframework.cloud.netflix.zuul.filters.route; import java.io.IOException; import java.io.InputStream; +import java.util.List; import java.util.Map; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; +import org.springframework.cloud.netflix.ribbon.support.RibbonRequestCustomizer; import org.springframework.cloud.netflix.zuul.filters.ProxyRequestHelper; import org.springframework.http.HttpStatus; import org.springframework.http.client.ClientHttpResponse; @@ -42,15 +44,18 @@ public class RibbonRoutingFilter extends ZuulFilter { private static final String ERROR_STATUS_CODE = "error.status_code"; protected ProxyRequestHelper helper; protected RibbonCommandFactory ribbonCommandFactory; + protected List requestCustomizers; public RibbonRoutingFilter(ProxyRequestHelper helper, - RibbonCommandFactory ribbonCommandFactory) { + RibbonCommandFactory ribbonCommandFactory, + List requestCustomizers) { this.helper = helper; this.ribbonCommandFactory = ribbonCommandFactory; + this.requestCustomizers = requestCustomizers; } public RibbonRoutingFilter(RibbonCommandFactory ribbonCommandFactory) { - this(new ProxyRequestHelper(), ribbonCommandFactory); + this(new ProxyRequestHelper(), ribbonCommandFactory, null); } @Override @@ -115,7 +120,7 @@ public class RibbonRoutingFilter extends ZuulFilter { uri = uri.replace("//", "/"); return new RibbonCommandContext(serviceId, verb, uri, retryable, headers, params, - requestEntity); + requestEntity, this.requestCustomizers); } protected ClientHttpResponse forward(RibbonCommandContext context) throws Exception { diff --git a/spring-cloud-netflix-core/src/test/java/org/springframework/cloud/netflix/ribbon/apache/RibbonApacheHttpRequestTests.java b/spring-cloud-netflix-core/src/test/java/org/springframework/cloud/netflix/ribbon/apache/RibbonApacheHttpRequestTests.java index 4b1e74d7..11c9a150 100644 --- a/spring-cloud-netflix-core/src/test/java/org/springframework/cloud/netflix/ribbon/apache/RibbonApacheHttpRequestTests.java +++ b/spring-cloud-netflix-core/src/test/java/org/springframework/cloud/netflix/ribbon/apache/RibbonApacheHttpRequestTests.java @@ -29,12 +29,15 @@ import java.io.ByteArrayInputStream; import java.io.IOException; import java.net.URI; import java.nio.charset.Charset; +import java.util.Collections; import org.apache.http.HttpEntity; import org.apache.http.HttpEntityEnclosingRequest; import org.apache.http.client.config.RequestConfig; import org.apache.http.client.methods.HttpUriRequest; +import org.apache.http.client.methods.RequestBuilder; import org.junit.Test; +import org.springframework.cloud.netflix.ribbon.support.RibbonRequestCustomizer; import org.springframework.cloud.netflix.zuul.filters.route.RibbonCommandContext; import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.StreamUtils; @@ -85,7 +88,21 @@ public class RibbonApacheHttpRequestTests { headers.add("Content-Length", lengthString); length = (long) entityValue.length(); } - RibbonCommandContext context = new RibbonCommandContext("example", method, uri.toString(), false, headers, new LinkedMultiValueMap(), requestEntity); + + RibbonRequestCustomizer requestCustomizer = new RibbonRequestCustomizer() { + @Override + public boolean accepts(Class builderClass) { + return builderClass == RequestBuilder.class; + } + + @Override + public void customize(RequestBuilder builder) { + builder.addHeader("from-customizer", "foo"); + } + }; + RibbonCommandContext context = new RibbonCommandContext("example", method, + uri.toString(), false, headers, new LinkedMultiValueMap(), + requestEntity, Collections.singletonList(requestCustomizer)); context.setContentLength(length); RibbonApacheHttpRequest httpRequest = new RibbonApacheHttpRequest(context); @@ -98,6 +115,9 @@ public class RibbonApacheHttpRequestTests { assertThat("Content-Length is wrong", request.getFirstHeader("Content-Length").getValue(), is(equalTo(lengthString))); } + assertThat("from-customizer is missing", request.getFirstHeader("from-customizer"), is(notNullValue())); + assertThat("from-customizer is wrong", request.getFirstHeader("from-customizer").getValue(), + is(equalTo("foo"))); HttpEntityEnclosingRequest entityRequest = (HttpEntityEnclosingRequest) request; assertThat("entity is missing", entityRequest.getEntity(), is(notNullValue())); @@ -108,3 +128,4 @@ public class RibbonApacheHttpRequestTests { assertThat("content is wrong", string, is(equalTo(entityValue))); } } + diff --git a/spring-cloud-netflix-core/src/test/java/org/springframework/cloud/netflix/ribbon/okhttp/OkHttpRibbonRequestTests.java b/spring-cloud-netflix-core/src/test/java/org/springframework/cloud/netflix/ribbon/okhttp/OkHttpRibbonRequestTests.java index 94144d08..4c6c84fd 100644 --- a/spring-cloud-netflix-core/src/test/java/org/springframework/cloud/netflix/ribbon/okhttp/OkHttpRibbonRequestTests.java +++ b/spring-cloud-netflix-core/src/test/java/org/springframework/cloud/netflix/ribbon/okhttp/OkHttpRibbonRequestTests.java @@ -26,8 +26,10 @@ import static org.junit.Assert.assertThat; import java.io.ByteArrayInputStream; import java.io.IOException; +import java.util.Collections; import org.junit.Test; +import org.springframework.cloud.netflix.ribbon.support.RibbonRequestCustomizer; import org.springframework.cloud.netflix.zuul.filters.route.RibbonCommandContext; import org.springframework.util.LinkedMultiValueMap; @@ -80,8 +82,20 @@ public class OkHttpRibbonRequestTests { headers.add("Content-Length", lengthString); length = (long) entityValue.length(); } + + RibbonRequestCustomizer requestCustomizer = new RibbonRequestCustomizer() { + @Override + public boolean accepts(Class builderClass) { + return builderClass == Request.Builder.class; + } + + @Override + public void customize(Request.Builder builder) { + builder.addHeader("from-customizer", "foo"); + } + }; RibbonCommandContext context = new RibbonCommandContext("example", method, uri, false, - headers, new LinkedMultiValueMap(), requestEntity); + headers, new LinkedMultiValueMap(), requestEntity, Collections.singletonList(requestCustomizer)); context.setContentLength(length); OkHttpRibbonRequest httpRequest = new OkHttpRibbonRequest(context); @@ -92,6 +106,8 @@ public class OkHttpRibbonRequestTests { assertThat("Content-Length is wrong", request.header("Content-Length"), is(equalTo(lengthString))); } + assertThat("from-customizer is wrong", request.header("from-customizer"), + is(equalTo("foo"))); if (!method.equalsIgnoreCase("get")) { assertThat("body is null", request.body(), is(notNullValue())); @@ -104,3 +120,4 @@ public class OkHttpRibbonRequestTests { } } } + diff --git a/spring-cloud-netflix-core/src/test/java/org/springframework/cloud/netflix/zuul/filters/route/RestClientRibbonCommandTests.java b/spring-cloud-netflix-core/src/test/java/org/springframework/cloud/netflix/zuul/filters/route/RestClientRibbonCommandTests.java new file mode 100644 index 00000000..f8d43345 --- /dev/null +++ b/spring-cloud-netflix-core/src/test/java/org/springframework/cloud/netflix/zuul/filters/route/RestClientRibbonCommandTests.java @@ -0,0 +1,118 @@ +/* + * Copyright 2013-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.cloud.netflix.zuul.filters.route; + +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.notNullValue; +import static org.hamcrest.Matchers.startsWith; +import static org.junit.Assert.assertThat; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.net.URI; +import java.nio.charset.Charset; +import java.util.Collections; + +import org.junit.Test; +import org.springframework.cloud.netflix.ribbon.support.RibbonRequestCustomizer; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.StreamUtils; + +import com.netflix.client.http.HttpRequest; + +/** + * @author Spencer Gibb + */ +public class RestClientRibbonCommandTests { + + @Test + public void testNullEntity() throws Exception { + String uri = "http://example.com"; + LinkedMultiValueMap headers = new LinkedMultiValueMap<>(); + headers.add("my-header", "my-value"); + LinkedMultiValueMap params = new LinkedMultiValueMap<>(); + params.add("myparam", "myparamval"); + RestClientRibbonCommand command = new RestClientRibbonCommand("cmd", null, new RibbonCommandContext("example", "GET", uri, false, + headers, params, null)); + + HttpRequest request = command.createRequest(); + + assertThat("uri is wrong", request.getUri().toString(), startsWith(uri)); + assertThat("my-header is wrong", request.getHttpHeaders().getFirstValue("my-header"), is(equalTo("my-value"))); + assertThat("myparam is missing", request.getQueryParams().get("myparam").iterator().next(), is(equalTo("myparamval"))); + } + + @Test + // this situation happens, see https://github.com/spring-cloud/spring-cloud-netflix/issues/1042#issuecomment-227723877 + public void testEmptyEntityGet() throws Exception { + String entityValue = ""; + testEntity(entityValue, new ByteArrayInputStream(entityValue.getBytes()), false, "GET"); + } + + @Test + public void testNonEmptyEntityPost() throws Exception { + String entityValue = "abcd"; + testEntity(entityValue, new ByteArrayInputStream(entityValue.getBytes()), true, "POST"); + } + + void testEntity(String entityValue, ByteArrayInputStream requestEntity, boolean addContentLengthHeader, String method) throws Exception { + String lengthString = String.valueOf(entityValue.length()); + Long length = null; + URI uri = URI.create("http://example.com"); + LinkedMultiValueMap headers = new LinkedMultiValueMap<>(); + if (addContentLengthHeader) { + headers.add("Content-Length", lengthString); + length = (long) entityValue.length(); + } + + RibbonRequestCustomizer requestCustomizer = new RibbonRequestCustomizer() { + @Override + public boolean accepts(Class builderClass) { + return builderClass == HttpRequest.Builder.class; + } + + @Override + public void customize(HttpRequest.Builder builder) { + builder.header("from-customizer", "foo"); + } + }; + RibbonCommandContext context = new RibbonCommandContext("example", method, + uri.toString(), false, headers, new LinkedMultiValueMap(), + requestEntity, Collections.singletonList(requestCustomizer)); + context.setContentLength(length); + RestClientRibbonCommand command = new RestClientRibbonCommand("cmd", null, context); + + HttpRequest request = command.createRequest(); + + assertThat("uri is wrong", request.getUri().toString(), startsWith(uri.toString())); + if (addContentLengthHeader) { + assertThat("Content-Length is wrong", request.getHttpHeaders().getFirstValue("Content-Length"), + is(equalTo(lengthString))); + } + assertThat("from-customizer is wrong", request.getHttpHeaders().getFirstValue("from-customizer"), + is(equalTo("foo"))); + + + assertThat("entity is missing", request.getEntity(), is(notNullValue())); + assertThat("entity is wrong type", InputStream.class.isAssignableFrom(request.getEntity().getClass()), is(true)); + InputStream entity = (InputStream) request.getEntity(); + String string = StreamUtils.copyToString(entity, Charset.forName("UTF-8")); + assertThat("content is wrong", string, is(equalTo(entityValue))); + } +}