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