001/** 002 * Copyright 2015 DuraSpace, Inc. 003 * 004 * Licensed under the Apache License, Version 2.0 (the "License"); 005 * you may not use this file except in compliance with the License. 006 * You may obtain a copy of the License at 007 * 008 * http://www.apache.org/licenses/LICENSE-2.0 009 * 010 * Unless required by applicable law or agreed to in writing, software 011 * distributed under the License is distributed on an "AS IS" BASIS, 012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 013 * See the License for the specific language governing permissions and 014 * limitations under the License. 015 */ 016package org.fcrepo.client; 017 018import org.apache.commons.io.IOUtils; 019import org.apache.commons.io.input.NullInputStream; 020import org.apache.commons.io.output.NullOutputStream; 021import org.apache.http.HttpClientConnection; 022import org.apache.http.HttpStatus; 023import org.apache.http.conn.HttpClientConnectionManager; 024import org.apache.http.conn.routing.HttpRoute; 025import org.apache.http.impl.client.CloseableHttpClient; 026import org.apache.http.impl.client.HttpClientBuilder; 027import org.apache.http.impl.conn.PoolingHttpClientConnectionManager; 028import org.junit.After; 029import org.junit.Before; 030import org.junit.Rule; 031import org.junit.Test; 032import org.junit.runner.RunWith; 033import org.mockito.Spy; 034import org.mockito.runners.MockitoJUnitRunner; 035import org.mockserver.client.server.MockServerClient; 036import org.mockserver.junit.MockServerRule; 037 038import java.io.IOException; 039import java.lang.reflect.Method; 040import java.util.concurrent.TimeUnit; 041import java.util.concurrent.atomic.AtomicInteger; 042import java.util.function.Consumer; 043import java.util.stream.Stream; 044 045import static org.fcrepo.client.MockHttpExpectations.host; 046import static org.fcrepo.client.MockHttpExpectations.port; 047import static org.fcrepo.client.TestUtils.TEXT_TURTLE; 048import static org.fcrepo.client.TestUtils.setField; 049import static org.junit.Assert.assertEquals; 050import static org.junit.Assert.assertNotNull; 051import static org.junit.Assert.fail; 052import static org.mockito.Matchers.any; 053import static org.mockito.Matchers.anyLong; 054import static org.mockito.Mockito.times; 055import static org.mockito.Mockito.verify; 056 057/** 058 * Integration test used to demonstrate connection management issues with the FcrepoClient. 059 * 060 * @author esm 061 */ 062@RunWith(MockitoJUnitRunner.class) 063public class ConnectionManagementTest { 064 065 /** 066 * Starts a mock HTTP server on a free port 067 */ 068 @Rule 069 public MockServerRule mockServerRule = new MockServerRule(this); 070 071 // Set by the above @Rule, initialized on @Before via MockHttpExpectations 072 private MockServerClient mockServerClient; 073 074 /** 075 * URIs that our Mock HTTP server responds to. 076 */ 077 private MockHttpExpectations.SupportedUris uris; 078 079 /** 080 * Verifies that the expected number of connections have been requested and then closed. 081 * 082 * @param connectionCount the number of connections that have been requested and closed. 083 * @param connectionManager the HttpClientConnectionManager 084 * @return a Consumer that verifies the supplied HttpClientConnectionManager has opened and closed the expected 085 * number of connections. 086 */ 087 private static void verifyConnectionRequestedAndClosed(final int connectionCount, 088 final HttpClientConnectionManager connectionManager) { 089 // A new connection was requested by the Http client ... 090 verify(connectionManager, times(connectionCount)).requestConnection(any(HttpRoute.class), any()); 091 092 // Verify it was released. 093 verify(connectionManager, times(connectionCount)). 094 releaseConnection(any(HttpClientConnection.class), any(), anyLong(), any(TimeUnit.class)); 095 } 096 097 /** 098 * Verifies that the expected number of connections have been requested and <em>have not been</em> closed. 099 * 100 * @param connectionCount the number of connections that have been requested. 101 * @param connectionManager the HttpClientConnectionManager 102 */ 103 private static void verifyConnectionRequestedButNotClosed(final int connectionCount, 104 final HttpClientConnectionManager connectionManager) { 105 // A new connection was requested by the Http client ... 106 verify(connectionManager, times(connectionCount)).requestConnection(any(HttpRoute.class), any()); 107 108 // Verify it was NOT released. 109 verify(connectionManager, times(0)). 110 releaseConnection(any(HttpClientConnection.class), any(), anyLong(), any(TimeUnit.class)); 111 } 112 113 /** 114 * FcrepoResponse handlers. 115 */ 116 private static class FcrepoResponseHandler { 117 118 /** 119 * Closes the InputStream that constitutes the response body. 120 */ 121 private static Consumer<FcrepoResponse> closeEntityBody = response -> 122 { 123 try { 124 response.getBody().close(); 125 } catch (IOException e) { 126 // ignore 127 } 128 }; 129 130 /** 131 * Reads the InputStream that constitutes the response body. 132 */ 133 private static Consumer<FcrepoResponse> readEntityBody = response -> { 134 assertNotNull("Expected a non-null InputStream.", response.getBody()); 135 try { 136 IOUtils.copy(response.getBody(), NullOutputStream.NULL_OUTPUT_STREAM); 137 } catch (IOException e) { 138 // ignore 139 } 140 }; 141 142 } 143 144 /** 145 * The Fedora Repository client. 146 */ 147 private FcrepoClient client; 148 149 /** 150 * The Apache HttpClient under test. 151 */ 152 private CloseableHttpClient underTest; 153 154 /** 155 * The {@link org.apache.http.conn.HttpClientConnectionManager} implementation that the {@link #underTest 156 * HttpClient} is configured to used. 157 */ 158 @Spy 159 private PoolingHttpClientConnectionManager connectionManager = new PoolingHttpClientConnectionManager(); 160 161 @Before 162 public void setUp() { 163 164 // Required because we have a test that doesn't close connections, so we have to insure that the 165 // connection manager doesn't block during that test. 166 connectionManager.setDefaultMaxPerRoute(HttpMethods.values().length); 167 168 // Set up the expectations on the Mock http server 169 new MockHttpExpectations().initializeExpectations(this.mockServerClient, this.mockServerRule.getPort()); 170 171 // Uris that we connect to, and answered by the Mock http server 172 uris = new MockHttpExpectations.SupportedUris(); 173 174 // A FcrepoClient configured to throw exceptions when an error is encountered. 175 client = new FcrepoClient(null, null, host + ":" + port, true); 176 177 // We're testing the behavior of a default HttpClient with a pooling connection manager. 178 underTest = HttpClientBuilder.create().setConnectionManager(connectionManager).build(); 179 180 // Put our testable HttpClient instance on the FcrepoClient 181 setField(client, "httpclient", underTest); 182 183 } 184 185 @After 186 public void tearDown() throws IOException { 187 underTest.close(); 188 } 189 190 /** 191 * Demonstrates that HTTP connections are released when the FcrepoClient throws an exception. Each method of the 192 * FcrepoClient (get, put, post, etc.) is tested. 193 */ 194 @Test 195 public void connectionReleasedOnException() { 196 // Hard-coded 6 b/c HttpMethods lists Options, which isn't supported by the FcrepoClient. 197 final int expectedCount = 6; 198 final AtomicInteger actualCount = new AtomicInteger(0); 199 final MockHttpExpectations.Uris uri = uris.uri500; 200 201 Stream.of(HttpMethods.values()) 202 // OPTIONS not supported by FcrepoClient 203 .filter(method -> HttpMethods.OPTIONS != method) 204 .forEach(method -> { 205 connect(client, uri, method, null); 206 actualCount.getAndIncrement(); 207 }); 208 209 assertEquals("Expected to make " + expectedCount + " connections; made " + actualCount.get(), 210 expectedCount, actualCount.get()); 211 212 verifyConnectionRequestedAndClosed(actualCount.get(), connectionManager); 213 } 214 215 /** 216 * Demonstrates that HTTP connections are released when the user of the FcrepoClient closes the HTTP entity body. 217 * Each method of the FcrepoClient (get, put, post, etc.) is tested. 218 */ 219 @Test 220 public void connectionReleasedOnEntityBodyClose() { 221 final int expectedCount = (int) Stream.of(HttpMethods.values()).filter(m -> m.entity).count(); 222 final AtomicInteger actualCount = new AtomicInteger(0); 223 final MockHttpExpectations.Uris uri = uris.uri200; 224 225 Stream.of(HttpMethods.values()) 226 .filter(method -> method.entity) 227 .forEach(method -> { 228 connect(client, uri, method, FcrepoResponseHandler.closeEntityBody); 229 actualCount.getAndIncrement(); 230 }); 231 232 assertEquals("Expected to make " + expectedCount + " connections; made " + actualCount.get(), 233 expectedCount, actualCount.get()); 234 verifyConnectionRequestedAndClosed(actualCount.get(), connectionManager); 235 } 236 237 /** 238 * Demonstrates that are connections are released when the user of the FcrepoClient reads the HTTP entity body. 239 */ 240 @Test 241 public void connectionReleasedOnEntityBodyRead() { 242 final int expectedCount = (int) Stream.of(HttpMethods.values()).filter(m -> m.entity).count(); 243 final AtomicInteger actualCount = new AtomicInteger(0); 244 final MockHttpExpectations.Uris uri = uris.uri200; 245 246 Stream.of(HttpMethods.values()) 247 .filter(method -> method.entity) 248 .forEach(method -> { 249 connect(client, uri, method, FcrepoResponseHandler.readEntityBody); 250 actualCount.getAndIncrement(); 251 }); 252 253 assertEquals("Expected to make " + expectedCount + " connections; made " + actualCount.get(), 254 expectedCount, actualCount.get()); 255 verifyConnectionRequestedAndClosed(actualCount.get(), connectionManager); 256 } 257 258 /** 259 * Demonstrates that are connections are NOT released if the user of the FcrepoClient does not handle the response 260 * body at all. 261 */ 262 @Test 263 public void connectionNotReleasedWhenEntityBodyIgnored() { 264 final int expectedCount = (int) Stream.of(HttpMethods.values()).filter(m -> m.entity).count(); 265 final AtomicInteger actualCount = new AtomicInteger(0); 266 final MockHttpExpectations.Uris uri = uris.uri200; 267 268 Stream.of(HttpMethods.values()) 269 .filter(method -> method.entity) 270 .forEach(method -> { 271 connect(client, uri, method, null); 272 actualCount.getAndIncrement(); 273 }); 274 275 assertEquals("Expected to make " + expectedCount + " connections; made " + actualCount.get(), 276 expectedCount, actualCount.get()); 277 verifyConnectionRequestedButNotClosed(actualCount.get(), connectionManager); 278 } 279 280 /** 281 * Uses the FcrepoClient to connect to supplied {@code uri} using the supplied {@code method}. 282 * This method invokes the supplied {@code responseHandler} on the {@code FcrepoResponse}. 283 * 284 * @param client the FcrepoClient used to invoke the request 285 * @param uri the request URI to connect to 286 * @param method the HTTP method corresponding to the FcrepoClient method invoked 287 * @param responseHandler invoked on the {@code FcrepoResponse}, may be {@code null} 288 */ 289 private void connect(final FcrepoClient client, final MockHttpExpectations.Uris uri, final HttpMethods method, 290 final Consumer<FcrepoResponse> responseHandler) { 291 292 final NullInputStream nullIn = new NullInputStream(1, true, false); 293 FcrepoResponse response = null; 294 295 try { 296 297 switch (method) { 298 299 case OPTIONS: 300 // not currently supported by the FcrepoClient 301 // intentionally throws an exception if the FcrepoClient implements OPTIONS in the future, to 302 // insure that it gets test coverage. 303 for (Method m : client.getClass().getDeclaredMethods()) { 304 if (m.getName().contains(method.name().toLowerCase())) { 305 fail("Untested method " + FcrepoClient.class.getName() + "#" + m.getName()); 306 } 307 } 308 309 return; 310 311 case DELETE: 312 response = client.delete(uri.asUri()); 313 break; 314 315 case GET: 316 response = client.get(uri.asUri(), null, TEXT_TURTLE); 317 break; 318 319 case HEAD: 320 response = client.head(uri.asUri()); 321 break; 322 323 case PATCH: 324 response = client.patch(uri.asUri(), nullIn); 325 break; 326 327 case POST: 328 response = client.post(uri.asUri(), nullIn, TEXT_TURTLE); 329 break; 330 331 case PUT: 332 response = client.put(uri.asUri(), nullIn, TEXT_TURTLE); 333 break; 334 335 default: 336 fail("Unknown HTTP method: " + method.name()); 337 } 338 339 if (uri.statusCode >= HttpStatus.SC_INTERNAL_SERVER_ERROR) { 340 fail("Expected a FcrepoOperationFailedException to be thrown for HTTP method " + method.name()); 341 } 342 } catch (FcrepoOperationFailedException e) { 343 assertEquals( 344 "Expected request for " + uri.asUri() + " to return a " + uri.statusCode + ". " + 345 "Was: " + e.getStatusCode(), 346 uri.statusCode, e.getStatusCode()); 347 } finally { 348 if (responseHandler != null) { 349 responseHandler.accept(response); 350 } 351 } 352 } 353 354}