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}