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 org.apache.commons.io.IOUtils;
019import org.apache.commons.io.input.NullInputStream;
020import org.apache.commons.io.output.NullOutputStream;
021import org.apache.http.HttpClientConnection;
022import org.apache.http.HttpStatus;
023import org.apache.http.conn.HttpClientConnectionManager;
024import org.apache.http.conn.routing.HttpRoute;
025import org.apache.http.impl.client.CloseableHttpClient;
026import org.apache.http.impl.client.HttpClientBuilder;
027import org.apache.http.impl.conn.PoolingHttpClientConnectionManager;
028import org.junit.After;
029import org.junit.Before;
030import org.junit.Rule;
031import org.junit.Test;
032import org.junit.runner.RunWith;
033import org.mockito.Spy;
034import org.mockito.runners.MockitoJUnitRunner;
035import org.mockserver.client.server.MockServerClient;
036import org.mockserver.junit.MockServerRule;
037
038import java.io.IOException;
039import java.lang.reflect.Method;
040import java.util.concurrent.TimeUnit;
041import java.util.concurrent.atomic.AtomicInteger;
042import java.util.function.Consumer;
043import java.util.stream.Stream;
044
045import static org.fcrepo.client.MockHttpExpectations.host;
046import static org.fcrepo.client.MockHttpExpectations.port;
047import static org.fcrepo.client.TestUtils.TEXT_TURTLE;
048import static org.fcrepo.client.TestUtils.setField;
049import static org.junit.Assert.assertEquals;
050import static org.junit.Assert.assertNotNull;
051import static org.junit.Assert.fail;
052import static org.mockito.Matchers.any;
053import static org.mockito.Matchers.anyLong;
054import static org.mockito.Mockito.times;
055import static org.mockito.Mockito.verify;
056
057/**
058 * Integration test used to demonstrate connection management issues with the FcrepoClient.
059 *
060 * @author esm
061 */
062@RunWith(MockitoJUnitRunner.class)
063public class ConnectionManagementTest {
064
065    /**
066     * Starts a mock HTTP server on a free port
067     */
068    @Rule
069    public MockServerRule mockServerRule = new MockServerRule(this);
070
071    // Set by the above @Rule, initialized on @Before via MockHttpExpectations
072    private MockServerClient mockServerClient;
073
074    /**
075     * URIs that our Mock HTTP server responds to.
076     */
077    private MockHttpExpectations.SupportedUris uris;
078
079    /**
080     * Verifies that the expected number of connections have been requested and then closed.
081     *
082     * @param connectionCount   the number of connections that have been requested and closed.
083     * @param connectionManager the HttpClientConnectionManager
084     * @return a Consumer that verifies the supplied HttpClientConnectionManager has opened and closed the expected
085     * number of connections.
086     */
087    private static void verifyConnectionRequestedAndClosed(final int connectionCount,
088                                                     final HttpClientConnectionManager connectionManager) {
089        // A new connection was requested by the Http client ...
090        verify(connectionManager, times(connectionCount)).requestConnection(any(HttpRoute.class), any());
091
092        // Verify it was released.
093        verify(connectionManager, times(connectionCount)).
094                releaseConnection(any(HttpClientConnection.class), any(), anyLong(), any(TimeUnit.class));
095    }
096
097    /**
098     * Verifies that the expected number of connections have been requested and <em>have not been</em> closed.
099     *
100     * @param connectionCount   the number of connections that have been requested.
101     * @param connectionManager the HttpClientConnectionManager
102     */
103    private static void verifyConnectionRequestedButNotClosed(final int connectionCount,
104                                                    final HttpClientConnectionManager connectionManager) {
105        // A new connection was requested by the Http client ...
106        verify(connectionManager, times(connectionCount)).requestConnection(any(HttpRoute.class), any());
107
108        // Verify it was NOT released.
109        verify(connectionManager, times(0)).
110                releaseConnection(any(HttpClientConnection.class), any(), anyLong(), any(TimeUnit.class));
111    }
112
113    /**
114     * FcrepoResponse handlers.
115     */
116    private static class FcrepoResponseHandler {
117
118        /**
119         * Closes the InputStream that constitutes the response body.
120         */
121        private static Consumer<FcrepoResponse> closeEntityBody = response ->
122        {
123            try {
124                response.getBody().close();
125            } catch (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 (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        // Hard-coded 6 b/c HttpMethods lists Options, which isn't supported by the FcrepoClient.
197        final int expectedCount = 6;
198        final AtomicInteger actualCount = new AtomicInteger(0);
199        final MockHttpExpectations.Uris uri = uris.uri500;
200
201        Stream.of(HttpMethods.values())
202                // OPTIONS not supported by FcrepoClient
203                .filter(method -> HttpMethods.OPTIONS != 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.uri200;
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.uri200;
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.uri200;
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     * Uses the FcrepoClient to connect to supplied {@code uri} using the supplied {@code method}.
282     * This method invokes the supplied {@code responseHandler} on the {@code FcrepoResponse}.
283     *
284     * @param client the FcrepoClient used to invoke the request
285     * @param uri the request URI to connect to
286     * @param method the HTTP method corresponding to the FcrepoClient method invoked
287     * @param responseHandler invoked on the {@code FcrepoResponse}, may be {@code null}
288     */
289    private void connect(final FcrepoClient client, final MockHttpExpectations.Uris uri, final HttpMethods method,
290                         final Consumer<FcrepoResponse> responseHandler) {
291
292        final NullInputStream nullIn = new NullInputStream(1, true, false);
293        FcrepoResponse response = null;
294
295        try {
296
297            switch (method) {
298
299                case OPTIONS:
300                    // not currently supported by the FcrepoClient
301                    // intentionally throws an exception if the FcrepoClient implements OPTIONS in the future, to
302                    // insure that it gets test coverage.
303                    for (Method m : client.getClass().getDeclaredMethods()) {
304                        if (m.getName().contains(method.name().toLowerCase())) {
305                            fail("Untested method " + FcrepoClient.class.getName() + "#" + m.getName());
306                        }
307                    }
308
309                    return;
310
311                case DELETE:
312                    response = client.delete(uri.asUri());
313                    break;
314
315                case GET:
316                    response = client.get(uri.asUri(), null, TEXT_TURTLE);
317                    break;
318
319                case HEAD:
320                    response = client.head(uri.asUri());
321                    break;
322
323                case PATCH:
324                    response = client.patch(uri.asUri(), nullIn);
325                    break;
326
327                case POST:
328                    response = client.post(uri.asUri(), nullIn, TEXT_TURTLE);
329                    break;
330
331                case PUT:
332                    response = client.put(uri.asUri(), nullIn, TEXT_TURTLE);
333                    break;
334
335                default:
336                    fail("Unknown HTTP method: " + method.name());
337            }
338
339            if (uri.statusCode >= HttpStatus.SC_INTERNAL_SERVER_ERROR) {
340                fail("Expected a FcrepoOperationFailedException to be thrown for HTTP method " + method.name());
341            }
342        } catch (FcrepoOperationFailedException e) {
343            assertEquals(
344                    "Expected request for " + uri.asUri() + " to return a " + uri.statusCode + ".  " +
345                            "Was: " + e.getStatusCode(),
346                    uri.statusCode, e.getStatusCode());
347        } finally {
348            if (responseHandler != null) {
349                responseHandler.accept(response);
350            }
351        }
352    }
353
354}