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}