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.ArgumentMatchers.any; 028import static org.mockito.ArgumentMatchers.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.junit.MockitoJUnitRunner; 055import org.mockserver.client.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 try { 124 response.getBody().close(); 125 } catch (final 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 (final 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 // Removing MOVE and COPY operations as the mock server does not handle them 197 final int expectedCount = HttpMethods.values().length - 2; 198 final AtomicInteger actualCount = new AtomicInteger(0); 199 final MockHttpExpectations.Uris uri = uris.uri500; 200 201 Stream.of(HttpMethods.values()) 202 // MOVE and COPY do not appear to be supported in the mock server 203 .filter(method -> HttpMethods.MOVE != method && HttpMethods.COPY != 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.uri200RespBody; 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.uri200RespBody; 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.uri200RespBody; 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 * Demonstrates that are connections are released when the FcrepoClient receives an empty response body. 282 */ 283 @Test 284 public void connectionReleasedOnEmptyBody() { 285 final int expectedCount = (int) Stream.of(HttpMethods.values()).filter(m -> m.entity).count(); 286 final AtomicInteger actualCount = new AtomicInteger(0); 287 final MockHttpExpectations.Uris uri = uris.uri200; 288 289 Stream.of(HttpMethods.values()) 290 .filter(method -> method.entity) 291 .forEach(method -> { 292 connect(client, uri, method, null); 293 actualCount.getAndIncrement(); 294 }); 295 296 assertEquals("Expected to make " + expectedCount + " connections; made " + actualCount.get(), 297 expectedCount, actualCount.get()); 298 verifyConnectionRequestedAndClosed(actualCount.get(), connectionManager); 299 } 300 301 /** 302 * Uses the FcrepoClient to connect to supplied {@code uri} using the supplied {@code method}. 303 * This method invokes the supplied {@code responseHandler} on the {@code FcrepoResponse}. 304 * 305 * @param client the FcrepoClient used to invoke the request 306 * @param uri the request URI to connect to 307 * @param method the HTTP method corresponding to the FcrepoClient method invoked 308 * @param responseHandler invoked on the {@code FcrepoResponse}, may be {@code null} 309 */ 310 private void connect(final FcrepoClient client, final MockHttpExpectations.Uris uri, final HttpMethods method, 311 final Consumer<FcrepoResponse> responseHandler) { 312 313 final NullInputStream nullIn = new NullInputStream(1, true, false); 314 FcrepoResponse response = null; 315 316 try { 317 318 switch (method) { 319 320 case OPTIONS: 321 response = client.options(uri.asUri()).perform(); 322 break; 323 324 case DELETE: 325 response = client.delete(uri.asUri()).perform(); 326 break; 327 328 case GET: 329 response = client.get(uri.asUri()).accept(TEXT_TURTLE).perform(); 330 break; 331 332 case HEAD: 333 response = client.head(uri.asUri()).perform(); 334 break; 335 336 case PATCH: 337 response = client.patch(uri.asUri()).perform(); 338 break; 339 340 case POST: 341 response = client.post(uri.asUri()).body(nullIn, TEXT_TURTLE).perform(); 342 break; 343 344 case PUT: 345 response = client.put(uri.asUri()).body(nullIn, TEXT_TURTLE).perform(); 346 break; 347 default: 348 fail("Unknown HTTP method: " + method.name()); 349 } 350 351 if (uri.statusCode >= HttpStatus.SC_INTERNAL_SERVER_ERROR) { 352 fail("Expected a FcrepoOperationFailedException to be thrown for HTTP method " + method.name()); 353 } 354 } catch (final FcrepoOperationFailedException e) { 355 assertEquals( 356 "Expected request for " + uri.asUri() + " to return a " + uri.statusCode + ". " + 357 "Was: " + e.getStatusCode() + " Method:" + method, 358 uri.statusCode, e.getStatusCode()); 359 } finally { 360 if (responseHandler != null) { 361 responseHandler.accept(response); 362 } 363 } 364 } 365 366}