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