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}