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