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}