Non-GPL implementation of JerseyLoggingFilter 81/42081/3
authorRob Daugherty <rd472p@att.com>
Tue, 10 Apr 2018 21:54:48 +0000 (17:54 -0400)
committerRob Daugherty <rd472p@att.com>
Tue, 10 Apr 2018 22:07:15 +0000 (18:07 -0400)
Does the same thing, and can carry the Apache 2.0 license.

97.3% unit test coverage.

Change-Id: I5ebe78616af2c0e4402deb30a165b3c62ed2efd8
Issue-ID: SO-398
Signed-off-by: Rob Daugherty <rd472p@att.com>
openstack-client-connectors/jersey-connector/pom.xml
openstack-client-connectors/jersey-connector/src/main/java/com/woorea/openstack/connector/JerseyLoggingFilter.java
openstack-client-connectors/jersey-connector/src/test/java/com/woorea/openstack/connector/JerseyLoggingFilterTest.java [new file with mode: 0644]

index 1915b5f..2c995cd 100644 (file)
        <artifactId>jackson-jaxrs</artifactId>
        <version>1.9.12</version>
   </dependency>
+  <dependency>
+    <groupId>junit</groupId>
+    <artifactId>junit</artifactId>
+    <scope>test</scope>
+  </dependency>
+  <dependency>
+    <groupId>org.mockito</groupId>
+    <artifactId>mockito-all</artifactId>
+    <scope>test</scope>
+  </dependency>
   </dependencies>
-
 </project>
index 43323f8..6d98005 100644 (file)
@@ -3,13 +3,12 @@
  * ONAP - SO
  * ================================================================================
  * Copyright (C) 2017 AT&T Intellectual Property. All rights reserved.
- * Copyright (C) 2017 Huawei Intellectual Property. All rights reserved.
  * ================================================================================
  * 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
+ *       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,
  * limitations under the License.
  * ============LICENSE_END=========================================================
  */
-
-/**
- * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS HEADER.
- *
- * Copyright (c) 2010-2011 Oracle and/or its affiliates. All rights reserved.
- *
- * The contents of this file are subject to the terms of either the GNU
- * General Public License Version 2 only ("GPL") or the Common Development
- * and Distribution License("CDDL") (collectively, the "License").  You
- * may not use this file except in compliance with the License.  You can
- * obtain a copy of the License at
- * http://glassfish.java.net/public/CDDL+GPL_1_1.html
- * or packager/legal/LICENSE.txt.  See the License for the specific
- * language governing permissions and limitations under the License.
- *
- * When distributing the software, include this License Header Notice in each
- * file and include the License file at packager/legal/LICENSE.txt.
- *
- * GPL Classpath Exception:
- * Oracle designates this particular file as subject to the "Classpath"
- * exception as provided by Oracle in the GPL Version 2 section of the License
- * file that accompanied this code.
- *
- * Modifications:
- * If applicable, add the following below the License Header, with the fields
- * enclosed by brackets [] replaced by your own identifying information:
- * "Portions Copyright [year] [name of copyright owner]"
- *
- * Contributor(s):
- * If you wish your version of this file to be governed by only the CDDL or
- * only the GPL Version 2, indicate your decision by adding "[Contributor]
- * elects to include this software in this distribution under the [CDDL or GPL
- * Version 2] license."  If you don't indicate a single choice of license, a
- * recipient has the option to distribute your version of this file under
- * either the CDDL, the GPL Version 2 or to extend the choice of license to
- * its licensees as provided above.  However, if you add GPL Version 2 code
- * and therefore, elected the GPL Version 2 license, then the option applies
- * only if the new code is made subject to such option by the copyright
- * holder.
- */
 package com.woorea.openstack.connector;
 
 import java.io.ByteArrayInputStream;
@@ -65,237 +24,202 @@ import java.io.ByteArrayOutputStream;
 import java.io.IOException;
 import java.io.InputStream;
 import java.io.OutputStream;
-import java.io.PrintStream;
 import java.util.List;
 import java.util.Map;
+import java.util.concurrent.atomic.AtomicLong;
 import java.util.logging.Logger;
 
-import javax.ws.rs.core.MultivaluedMap;
-
 import com.sun.jersey.api.client.AbstractClientRequestAdapter;
 import com.sun.jersey.api.client.ClientHandlerException;
 import com.sun.jersey.api.client.ClientRequest;
 import com.sun.jersey.api.client.ClientRequestAdapter;
 import com.sun.jersey.api.client.ClientResponse;
 import com.sun.jersey.api.client.filter.ClientFilter;
-import com.sun.jersey.api.client.filter.LoggingFilter;
 import com.sun.jersey.core.util.ReaderWriter;
 
 /**
- * A logging filter.
- * 
+ * A Jersey client filter that writes the request and response to a specified logger.
  */
 public class JerseyLoggingFilter extends ClientFilter {
 
-    private static final Logger LOGGER = Logger.getLogger(LoggingFilter.class.getName());
-
-    private static final String NOTIFICATION_PREFIX = "* ";
-    
-    private static final String REQUEST_PREFIX = "> ";
-    
-    private static final String RESPONSE_PREFIX = "< ";
-
-    private static final String PASSWORD_PATTERN = "\"password\".*:.*\"(.*)\"";
-    
-    private final class Adapter extends AbstractClientRequestAdapter {
-        private final StringBuilder b;
-
-        Adapter(ClientRequestAdapter cra, StringBuilder b) {
-            super(cra);
-            this.b = b;
-        }
-
-        @Override
-        public OutputStream adapt(ClientRequest request, OutputStream out) throws IOException {
-            return new LoggingOutputStream(getAdapter().adapt(request, out), b);
-        }
-        
-    }
-
-    private final class LoggingOutputStream extends OutputStream {
-        private final OutputStream out;
-        
-        private final ByteArrayOutputStream baos = new ByteArrayOutputStream();
-        
-        private final StringBuilder b;
-
-        LoggingOutputStream(OutputStream out, StringBuilder b) {
-            this.out = out;
-            this.b = b;
-        }
-        
-        @Override
-        public void write(byte[] b)  throws IOException {
-            baos.write(b);
-            out.write(b);
-        }
-    
-        @Override
-        public void write(byte[] b, int off, int len)  throws IOException {
-            baos.write(b, off, len);
-            out.write(b, off, len);
-        }
-
-        @Override
-        public void write(int b) throws IOException {
-            baos.write(b);
-            out.write(b);
-        }
-
-        @Override
-        public void close() throws IOException {
-            printEntity(b, baos.toByteArray());
-            log(b);
-            out.close();
-        }
-    }
-
-    private final PrintStream loggingStream;
-
-    private final Logger logger;
-
-    private long _id = 0;
-
-    /**
-     * Create a logging filter logging the request and response to
-     * a default JDK logger, named as the fully qualified class name of this
-     * class.
-     */
-    public JerseyLoggingFilter() {
-        this(LOGGER);
-    }
-
-    /**
-     * Create a logging filter logging the request and response to
-     * a JDK logger.
-     * 
-     * @param logger the logger to log requests and responses.
-     */
-    public JerseyLoggingFilter(Logger logger) {
-        this.loggingStream = null;
-        this.logger = logger;
-    }
-
-    /**
-     * Create a logging filter logging the request and response to
-     * print stream.
-     *
-     * @param loggingStream the print stream to log requests and responses.
-     */
-    public JerseyLoggingFilter(PrintStream loggingStream) {
-        this.loggingStream = loggingStream;
-        this.logger = null;
-    }
-
-    private void log(StringBuilder b) {
-        if (logger != null) {
-            logger.info(b.toString());
-        } else {
-            loggingStream.print(b);
-        }
-    }
-
-    private StringBuilder prefixId(StringBuilder b, long id) {
-        b.append(Long.toString(id)).append(" ");
-        return b;
-    }
-
-    @Override
-    public ClientResponse handle(ClientRequest request) throws ClientHandlerException {
-        long id = ++this._id;
-
-        logRequest(id, request);
-
-        ClientResponse response = getNext().handle(request);
-
-        logResponse(id, response);
-
-        return response;
-    }
-
-    private void logRequest(long id, ClientRequest request) {
-        StringBuilder b = new StringBuilder();
-        
-        printRequestLine(b, id, request);
-        printRequestHeaders(b, id, request.getHeaders());
-
-        if (request.getEntity() != null) {
-            request.setAdapter(new Adapter(request.getAdapter(), b));
-        } else {
-            log(b);
-        }
-    }
-
-    private void printRequestLine(StringBuilder b, long id, ClientRequest request) {
-        prefixId(b, id).append(NOTIFICATION_PREFIX).append("Client out-bound request").append("\n");
-        prefixId(b, id).append(REQUEST_PREFIX).append(request.getMethod()).append(" ").
-                append(request.getURI().toASCIIString()).append("\n");
-    }
-
-    private void printRequestHeaders(StringBuilder b, long id, MultivaluedMap<String, Object> headers) {
-        for (Map.Entry<String, List<Object>> e : headers.entrySet()) {
-            List<Object> val = e.getValue();
-            String header = e.getKey();
-
-            if(val.size() == 1) {
-                prefixId(b, id).append(REQUEST_PREFIX).append(header).append(": ").append(ClientRequest.getHeaderValue(val.get(0))).append("\n");
-            } else {
-                StringBuilder sb = new StringBuilder();
-                boolean add = false;
-                for(Object o : val) {
-                    if(add) sb.append(',');
-                    add = true;
-                    sb.append(ClientRequest.getHeaderValue(o));
-                }
-                prefixId(b, id).append(REQUEST_PREFIX).append(header).append(": ").append(sb.toString()).append("\n");
-            }
-        }
-    }
-
-    private void logResponse(long id, ClientResponse response) {
-        StringBuilder b = new StringBuilder();
-
-        printResponseLine(b, id, response);
-        printResponseHeaders(b, id, response.getHeaders());
-
-        ByteArrayOutputStream out = new ByteArrayOutputStream();
-        InputStream in = response.getEntityInputStream();
-        try {
-            ReaderWriter.writeTo(in, out);
-
-            byte[] requestEntity = out.toByteArray();
-            printEntity(b, requestEntity);
-            response.setEntityInputStream(new ByteArrayInputStream(requestEntity));
-        } catch (IOException ex) {
-            throw new ClientHandlerException(ex);
-        }
-        log(b);
-    }
-
-    private void printResponseLine(StringBuilder b, long id, ClientResponse response) {
-        prefixId(b, id).append(NOTIFICATION_PREFIX).
-                append("Client in-bound response").append("\n");
-        prefixId(b, id).append(RESPONSE_PREFIX).
-                append(Integer.toString(response.getStatus())).
-                append("\n");
-    }
-    
-    private void printResponseHeaders(StringBuilder b, long id, MultivaluedMap<String, String> headers) {
-        for (Map.Entry<String, List<String>> e : headers.entrySet()) {
-            String header = e.getKey();
-            for (String value : e.getValue()) {
-                prefixId(b, id).append(RESPONSE_PREFIX).append(header).append(": ").
-                        append(value).append("\n");
-            }
-        }
-        prefixId(b, id).append(RESPONSE_PREFIX).append("\n");
-    }
-
-    private void printEntity(StringBuilder b, byte[] entity) throws IOException {
-        if (entity.length == 0)
-            return;
-        String entityString = new String(entity);
-        entityString = entityString.replaceAll(PASSWORD_PATTERN, "\"password\" : \"******\"");
-        b.append(entityString).append("\n");
-    }
-}
+       private final AtomicLong counter = new AtomicLong(0);
+       private final Logger logger;
+
+       /**
+        * Constructor
+        * @param logger the logger to which the request and response are written.
+        */
+       public JerseyLoggingFilter(Logger logger) {
+               this.logger = logger;
+       }
+
+       @Override
+       public ClientResponse handle(ClientRequest request) throws ClientHandlerException {
+               long id = counter.incrementAndGet();
+               logRequest(id, request);
+               ClientResponse response = getNext().handle(request);
+               logResponse(id, response);
+               return response;
+       }
+
+       /**
+        * Logs a request.
+        * @param id the request id (counter)
+        * @param request the request
+        */
+       private void logRequest(long id, ClientRequest request) {
+               StringBuilder builder = new StringBuilder();
+
+               builder.append(String.valueOf(id));
+               builder.append(" * Client out-bound request\n");
+
+               builder.append(String.valueOf(id));
+               builder.append(" > ");
+               builder.append(request.getMethod());
+               builder.append(" ");
+               builder.append(request.getURI().toASCIIString());
+               builder.append("\n");
+
+               // Request headers
+
+               for (Map.Entry<String, List<Object>> entry : request.getHeaders().entrySet()) {
+                       String header = entry.getKey();
+                       List<Object> values = entry.getValue();
+
+                       if (values.size() == 1) {
+                               builder.append(String.valueOf(id));
+                               builder.append(" > ");
+                               builder.append(header);
+                               builder.append(": ");
+                               builder.append(ClientRequest.getHeaderValue(values.get(0)));
+                               builder.append("\n");
+                       } else {
+                               StringBuilder buf = new StringBuilder();
+                               boolean first = true;
+
+                               for(Object value : values) {
+                                       if (first) {
+                                               first = false;
+                                       } else {
+                                               buf.append(",");
+                                       }
+
+                                       buf.append(ClientRequest.getHeaderValue(value));
+                               }
+
+                               builder.append(String.valueOf(id));
+                               builder.append(" > ");
+                               builder.append(header);
+                               builder.append(": ");
+                               builder.append(buf.toString());
+                               builder.append("\n");
+                       }
+               }
+
+               // Request body
+
+               if (request.getEntity() != null) {
+                       request.setAdapter(new JerseyLoggingAdapter(request.getAdapter(), builder));
+               } else {
+                       logger.info(builder.toString());
+               }
+       }
+
+       /**
+        * Logs a response.
+        * @param id the request id (counter)
+        * @param response the response
+        */
+       private void logResponse(long id, ClientResponse response) {
+               StringBuilder builder = new StringBuilder();
+
+               builder.append(String.valueOf(id));
+               builder.append(" * Client in-bound response\n");
+
+               builder.append(String.valueOf(id));
+               builder.append(" < ");
+               builder.append(String.valueOf(response.getStatus()));
+               builder.append("\n");
+
+               // Response headers
+
+               for (Map.Entry<String, List<String>> entry : response.getHeaders().entrySet()) {
+                       String header = entry.getKey();
+                       for (String value : entry.getValue()) {
+                               builder.append(String.valueOf(id));
+                               builder.append(" < ");
+                               builder.append(header);
+                               builder.append(": ");
+                               builder.append(value).append("\n");
+                       }
+               }
+
+               // Response body
+
+               ByteArrayOutputStream out = new ByteArrayOutputStream();
+               InputStream in = response.getEntityInputStream();
+               try {
+                       ReaderWriter.writeTo(in, out);
+
+                       byte[] requestEntity = out.toByteArray();
+                       appendToBuffer(builder, requestEntity);
+                       response.setEntityInputStream(new ByteArrayInputStream(requestEntity));
+               } catch (IOException ex) {
+                       throw new ClientHandlerException(ex);
+               }
+
+               logger.info(builder.toString());
+       }
+
+       /**
+        * Appends bytes to the builder. If the bytes contain the password pattern,
+        * the password is obliterated.
+        * @param builder the builder
+        * @param bytes the bytes to append
+        */
+       private void appendToBuffer(StringBuilder builder, byte[] bytes) {
+               if (bytes.length != 0) {
+                       String s = new String(bytes);
+                       builder.append(s.replaceAll("\"password\".*:.*\"(.*)\"", "\"password\" : \"******\""));
+                       builder.append("\n");
+               }
+       }
+
+       private class JerseyLoggingAdapter extends AbstractClientRequestAdapter {
+               private final StringBuilder builder;
+
+               JerseyLoggingAdapter(ClientRequestAdapter adapter, StringBuilder builder) {
+                       super(adapter);
+                       this.builder = builder;
+               }
+
+               @Override
+               public OutputStream adapt(ClientRequest request, OutputStream out) throws IOException {
+                       return new JerseyLoggingOutputStream(getAdapter().adapt(request, out), builder);
+               }
+       }
+
+       private class JerseyLoggingOutputStream extends OutputStream {
+               private final OutputStream stream;
+               private final StringBuilder builder;
+               private final ByteArrayOutputStream logStream = new ByteArrayOutputStream();
+
+               JerseyLoggingOutputStream(OutputStream stream, StringBuilder builder) {
+                       this.stream = stream;
+                       this.builder = builder;
+               }
+
+               @Override
+               public void write(int value) throws IOException {
+                       logStream.write(value);
+                       stream.write(value);
+               }
+
+               @Override
+               public void close() throws IOException {
+                       appendToBuffer(builder, logStream.toByteArray());
+                       logger.info(builder.toString());
+                       stream.close();
+               }
+       }
+}
\ No newline at end of file
diff --git a/openstack-client-connectors/jersey-connector/src/test/java/com/woorea/openstack/connector/JerseyLoggingFilterTest.java b/openstack-client-connectors/jersey-connector/src/test/java/com/woorea/openstack/connector/JerseyLoggingFilterTest.java
new file mode 100644 (file)
index 0000000..3bd0717
--- /dev/null
@@ -0,0 +1,267 @@
+/*-
+ * ============LICENSE_START=======================================================
+ * ONAP - SO
+ * ================================================================================
+ * Copyright (C) 2018 AT&T Intellectual Property. All rights reserved.
+ * ================================================================================ 
+ * 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.
+ * ============LICENSE_END=========================================================
+ */
+
+package com.woorea.openstack.connector;
+
+import static org.junit.Assert.assertTrue;
+import static org.mockito.Matchers.any;
+import static org.mockito.Mockito.doCallRealMethod;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.lang.reflect.Method;
+import java.net.URI;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.logging.ConsoleHandler;
+import java.util.logging.Level;
+import java.util.logging.LogRecord;
+import java.util.logging.Logger;
+import java.util.logging.SimpleFormatter;
+
+import javax.ws.rs.core.MultivaluedMap;
+
+import org.junit.Before;
+import org.junit.BeforeClass;
+import org.junit.Test;
+import org.mockito.invocation.InvocationOnMock;
+import org.mockito.stubbing.Answer;
+
+import com.sun.jersey.api.client.ClientHandler;
+import com.sun.jersey.api.client.ClientRequest;
+import com.sun.jersey.api.client.ClientRequestAdapter;
+import com.sun.jersey.api.client.ClientResponse;
+import com.sun.jersey.api.client.filter.ClientFilter;
+
+public class JerseyLoggingFilterTest {
+
+       private static Logger logger;
+       private static LogFormatter logFormatter;
+       
+       @BeforeClass
+       public static void setUpClass() throws Exception {
+               logger = Logger.getLogger(JerseyLoggingFilterTest.class.getSimpleName());
+               logger.setLevel(Level.ALL);
+               logger.setUseParentHandlers(false);
+
+               ConsoleHandler handler = new ConsoleHandler();
+               logFormatter = new LogFormatter();
+               handler.setFormatter(logFormatter);
+               handler.setLevel(Level.ALL);
+               logger.addHandler(handler);
+       }
+
+       @Before
+       public void setUpTest() {
+               logFormatter.clearLog();
+       }
+       
+       /**
+        * Tests a scenario with no request content (GET).
+        * @throws Exception for unexpected errors
+        */
+       @Test
+       public void testGET() throws Exception {
+               String responseContent = "<response>Hello, I am Eliza.</response>";
+               execute("GET", "http://www.onap.org/eliza", null, responseContent);
+       }
+       
+       /**
+        * Tests a scenario with request content (POST).
+        * @throws Exception for unexpected errors
+        */
+       @Test
+       public void testPOST() throws Exception {
+               String requestContent = "<request>I feel sad.</request>";
+               String responseContent = "<response>Do you often feel sad?</response>";
+               execute("POST", "http://www.onap.org/eliza", requestContent, responseContent);
+       }
+
+       /**
+        * Runs a single test.
+        * @param httpMethod any HTTP method (POST, GET, ...)
+        * @param url any URL
+        * @param requestContent mock request content, possibly null
+        * @param responseContent mock response content, never null
+        * @throws Exception for unexpected errors
+        */
+       private void execute(String httpMethod, String url, String requestContent, String responseContent)
+                       throws Exception {
+               JerseyLoggingFilter loggingFilter = new JerseyLoggingFilter(logger);
+
+               // Mock multi-valued and single valued request headers
+
+               HashMap<String, List<Object>> requestHeaderMap = new HashMap<>();
+               requestHeaderMap.put("Accept", Arrays.asList(new Object[]{"application/xml","application/json"}));
+
+               if (requestContent != null) {
+                       requestHeaderMap.put("Content-Type", Arrays.asList(new Object[]{"application/xml"}));
+                       requestHeaderMap.put("Content-Length", Arrays.asList(new Object[]{String.valueOf(requestContent.length())}));
+               }
+
+               @SuppressWarnings("unchecked")
+               MultivaluedMap<String, Object> requestHeaders = mock(MultivaluedMap.class);
+               when(requestHeaders.entrySet()).thenReturn(requestHeaderMap.entrySet());
+
+               // Mock the request object
+
+               ClientRequest request = mock(TestClientRequest.class);
+               when(request.getURI()).thenReturn(new URI(url));
+               when(request.getMethod()).thenReturn(httpMethod);
+               when(request.getHeaders()).thenReturn(requestHeaders);
+
+               if (requestContent != null) {
+                       when(request.getEntity()).thenReturn(requestContent.getBytes("UTF-8"));
+               }
+
+               doCallRealMethod().when(request).setAdapter(any(ClientRequestAdapter.class));
+               when(request.getAdapter()).thenCallRealMethod();
+               request.setAdapter(new DefaultClientRequestAdapter());
+
+               // Mock multi-valued and single valued response headers
+
+               HashMap<String, List<String>> responseHeaderMap = new HashMap<>();
+               responseHeaderMap.put("Cache-Control", Arrays.asList(new String[]{"no-cache","no-store"}));
+               responseHeaderMap.put("Content-Type", Arrays.asList(new String[]{"application/xml"}));
+               responseHeaderMap.put("Content-Length", Arrays.asList(new String[]{String.valueOf(responseContent.length())}));
+               @SuppressWarnings("unchecked")
+               MultivaluedMap<String, String> responseHeaders = mock(MultivaluedMap.class);
+               when(responseHeaders.entrySet()).thenReturn(responseHeaderMap.entrySet());
+
+               // Mock the response object
+
+               ClientResponse response = mock(ClientResponse.class);
+               when(response.getStatus()).thenReturn(200);
+               when(response.getHeaders()).thenReturn(responseHeaders);
+               when(response.getEntityInputStream()).thenReturn(
+                               new ByteArrayInputStream(responseContent.getBytes("UTF-8")));
+
+               // Mock a handler that returns the response object and set
+               // it to be the next filter after the logging filter.
+
+               ClientFilter handler = mock(ClientFilter.class);
+               when(handler.handle(request)).then(produceResponse(response));
+               Method setNext = ClientFilter.class.getDeclaredMethod("setNext", new Class<?>[]{ClientHandler.class});
+               setNext.setAccessible(true);
+               setNext.invoke(loggingFilter, new Object[]{handler});
+
+               // Run the request into the logging filter
+
+               loggingFilter.handle(request);
+
+               // Validate resulting the log content
+
+               String log = logFormatter.getLog();
+
+               assertContains(log, "* Client out-bound request");
+               assertContains(log, "> " + httpMethod + " " + url);
+
+               for (String header : requestHeaderMap.keySet()) {
+                       assertContains(log, "> " + header + ": ");
+               }
+
+               if (requestContent != null) {
+                       assertContains(log, requestContent);
+               }
+
+               assertContains(log, "* Client in-bound response");
+               assertContains(log, "< 200");
+
+               for (String header : responseHeaderMap.keySet()) {
+                       assertContains(log, "< " + header + ": ");
+               }
+
+               assertContains(log, responseContent);
+       }
+       
+       private void assertContains(String log, String expect) {
+               assertTrue("Log does not contain '" + expect + "'", log.contains(expect));
+       }
+
+       private class DefaultClientRequestAdapter implements ClientRequestAdapter {
+               @Override
+               public OutputStream adapt(ClientRequest request, OutputStream out) throws IOException {
+                       return out;
+               }
+       }
+
+       private abstract class TestClientRequest extends ClientRequest {
+               private ClientRequestAdapter adapter;
+
+               @Override
+               public ClientRequestAdapter getAdapter() {
+                       return adapter;
+               }
+
+               @Override
+               public void setAdapter(ClientRequestAdapter adapter) {
+                       this.adapter = adapter;
+               }
+       }
+       
+       private Answer<ClientResponse> produceResponse(final ClientResponse response) { 
+               return new Answer<ClientResponse>() {
+                       public ClientResponse answer(InvocationOnMock invocation) throws IOException {
+                               ClientRequest request = (ClientRequest) invocation.getArguments()[0];
+                               byte[] entity = (byte[]) request.getEntity();
+
+                               if (entity != null) {
+                                       ClientRequestAdapter adapter = request.getAdapter();
+       
+                                       OutputStream nullOutputStream = new OutputStream() {
+                                               @Override
+                                               public void write(int b) {
+                                                       // Discard
+                                               }
+                                       };
+
+                                       OutputStream outputStream = adapter.adapt(request, nullOutputStream);
+                                       outputStream.write(entity);
+                                       outputStream.close();
+                               }
+
+                               return response;
+                       }
+               };
+       }
+
+       private static class LogFormatter extends SimpleFormatter {
+               StringBuilder buffer = new StringBuilder();
+
+               public synchronized String getLog() {
+                       return buffer.toString();
+               }
+
+               public synchronized void clearLog() {
+                       buffer.setLength(0);
+               }
+
+               @Override
+               public synchronized String format(LogRecord record) {
+                       String logData = super.format(record);
+                       buffer.append(logData);
+                       return logData;
+               }
+       }
+}
\ No newline at end of file