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.slf4j.LoggerFactory.getLogger;
019
020import java.io.IOException;
021import java.io.InputStream;
022import java.net.URI;
023import java.util.ArrayList;
024import java.util.List;
025
026import org.apache.http.Header;
027import org.apache.http.HttpEntity;
028import org.apache.http.HttpResponse;
029import org.apache.http.HttpStatus;
030import org.apache.http.client.methods.CloseableHttpResponse;
031import org.apache.http.client.methods.HttpEntityEnclosingRequestBase;
032import org.apache.http.client.methods.HttpRequestBase;
033import org.apache.http.entity.InputStreamEntity;
034import org.apache.http.impl.client.CloseableHttpClient;
035import org.slf4j.Logger;
036
037/**
038 * Represents a client to interact with Fedora's HTTP API.
039 * <p>
040 * Users of the {@code FcrepoClient} are responsible for managing connection resources.  Specifically, the underlying
041 * HTTP connections of this client must be freed.  Suggested usage is to create the {@code FcrepoResponse} within
042 * a {@code try-with-resources} block, insuring that any resources held by the response are freed automatically.
043 * </p>
044 * <pre>
045 * FcrepoClient client = ...;
046 * try (FcrepoResponse res = client.get(...)) {
047 *     // do something with the response
048 * } catch (FcrepoOperationFailedException|IOException e) {
049 *     // handle any exceptions
050 * }
051 * </pre>
052 *
053 * @author Aaron Coburn
054 * @since October 20, 2014
055 */
056public class FcrepoClient {
057
058    private static final String DESCRIBED_BY = "describedby";
059
060    private static final String CONTENT_TYPE = "Content-Type";
061
062    private static final String LOCATION = "Location";
063
064    private CloseableHttpClient httpclient;
065
066    private Boolean throwExceptionOnFailure = true;
067
068    private static final Logger LOGGER = getLogger(FcrepoClient.class);
069
070    /**
071     * Create a FcrepoClient with a set of authentication values.
072     * @param username the username for the repository
073     * @param password the password for the repository
074     * @param host the authentication hostname (realm) for the repository
075     * @param throwExceptionOnFailure whether to throw an exception on any non-2xx or 3xx HTTP responses
076     */
077    public FcrepoClient(final String username, final String password, final String host,
078            final Boolean throwExceptionOnFailure) {
079
080        final FcrepoHttpClientBuilder client = new FcrepoHttpClientBuilder(username, password, host);
081
082        this.throwExceptionOnFailure = throwExceptionOnFailure;
083        this.httpclient = client.build();
084    }
085
086    /**
087     * Make a HEAD response
088     * @param url the URL of the resource to check
089     * @return the repository response
090     * @throws FcrepoOperationFailedException when the underlying HTTP request results in an error
091     */
092    public FcrepoResponse head(final URI url)
093            throws FcrepoOperationFailedException {
094
095        final HttpRequestBase request = HttpMethods.HEAD.createRequest(url);
096        final CloseableHttpResponse response = executeRequest(request);
097        final int status = response.getStatusLine().getStatusCode();
098        final String contentType = getContentTypeHeader(response);
099
100        LOGGER.debug("Fcrepo HEAD request returned status [{}]", status);
101
102        if ((status >= HttpStatus.SC_OK && status < HttpStatus.SC_BAD_REQUEST) || !this.throwExceptionOnFailure) {
103            URI describedBy = null;
104            final List<URI> links = getLinkHeaders(response, DESCRIBED_BY);
105            if (links.size() == 1) {
106                describedBy = links.get(0);
107            }
108            return new FcrepoResponse(url, status, contentType, describedBy, null);
109        } else {
110            free(response);
111            throw new FcrepoOperationFailedException(url, status,
112                    response.getStatusLine().getReasonPhrase());
113        }
114    }
115
116    /**
117     * Make a PUT request
118     * @param url the URL of the resource to PUT
119     * @param body the contents of the resource to send
120     * @param contentType the MIMEType of the resource
121     * @return the repository response
122     * @throws FcrepoOperationFailedException when the underlying HTTP request results in an error
123     */
124    public FcrepoResponse put(final URI url, final InputStream body, final String contentType)
125            throws FcrepoOperationFailedException {
126
127        final HttpMethods method = HttpMethods.PUT;
128        final HttpEntityEnclosingRequestBase request = (HttpEntityEnclosingRequestBase)method.createRequest(url);
129
130        if (contentType != null) {
131            request.addHeader(CONTENT_TYPE, contentType);
132        }
133        if (body != null) {
134            request.setEntity(new InputStreamEntity(body));
135        }
136
137        LOGGER.debug("Fcrepo PUT request headers: {}", request.getAllHeaders());
138
139        final CloseableHttpResponse response = executeRequest(request);
140
141        LOGGER.debug("Fcrepo PUT request returned status [{}]", response.getStatusLine().getStatusCode());
142
143        return fcrepoGenericResponse(url, response, throwExceptionOnFailure);
144    }
145
146    /**
147     * Make a PATCH request
148     * Please note: the body should have an application/sparql-update content-type
149     * @param url the URL of the resource to PATCH
150     * @param body the body to be sent to the repository
151     * @return the repository response
152     * @throws FcrepoOperationFailedException when the underlying HTTP request results in an error
153     */
154    public FcrepoResponse patch(final URI url, final InputStream body)
155            throws FcrepoOperationFailedException {
156
157        final HttpMethods method = HttpMethods.PATCH;
158        final HttpEntityEnclosingRequestBase request = (HttpEntityEnclosingRequestBase)method.createRequest(url);
159
160        request.addHeader(CONTENT_TYPE, "application/sparql-update");
161        request.setEntity(new InputStreamEntity(body));
162
163        LOGGER.debug("Fcrepo PATCH request headers: {}", request.getAllHeaders());
164
165        final CloseableHttpResponse response = executeRequest(request);
166
167        LOGGER.debug("Fcrepo PATCH request returned status [{}]", response.getStatusLine().getStatusCode());
168
169        return fcrepoGenericResponse(url, response, throwExceptionOnFailure);
170    }
171
172    /**
173     * Make a POST request
174     * @param url the URL of the resource to which to POST
175     * @param body the content to be sent to the server
176     * @param contentType the Content-Type of the body
177     * @return the repository response
178     * @throws FcrepoOperationFailedException when the underlying HTTP request results in an error
179     */
180    public FcrepoResponse post(final URI url, final InputStream body, final String contentType)
181            throws FcrepoOperationFailedException {
182
183        final HttpMethods method = HttpMethods.POST;
184        final HttpEntityEnclosingRequestBase request = (HttpEntityEnclosingRequestBase)method.createRequest(url);
185
186        if (contentType != null) {
187            request.addHeader(CONTENT_TYPE, contentType);
188        }
189        if (body != null) {
190            request.setEntity(new InputStreamEntity(body));
191        }
192
193        LOGGER.debug("Fcrepo POST request headers: {}", request.getAllHeaders());
194
195        final CloseableHttpResponse response = executeRequest(request);
196
197        LOGGER.debug("Fcrepo POST request returned status [{}]", response.getStatusLine().getStatusCode());
198
199        return fcrepoGenericResponse(url, response, throwExceptionOnFailure);
200    }
201
202    /**
203     * Make a DELETE request
204     * @param url the URL of the resource to delete
205     * @return the repository response
206     * @throws FcrepoOperationFailedException when the underlying HTTP request results in an error
207     */
208    public FcrepoResponse delete(final URI url)
209            throws FcrepoOperationFailedException {
210
211        final HttpRequestBase request = HttpMethods.DELETE.createRequest(url);
212        final CloseableHttpResponse response = executeRequest(request);
213
214        LOGGER.debug("Fcrepo DELETE request returned status [{}]", response.getStatusLine().getStatusCode());
215
216        return fcrepoGenericResponse(url, response, throwExceptionOnFailure);
217    }
218
219    /**
220     * Make a GET request
221     * @param url the URL of the resource to fetch
222     * @param accept the requested MIMEType of the resource to be retrieved
223     * @param prefer the value for a prefer header sent in the request
224     * @return the repository response
225     * @throws FcrepoOperationFailedException when the underlying HTTP request results in an error
226     */
227    public FcrepoResponse get(final URI url, final String accept, final String prefer)
228            throws FcrepoOperationFailedException {
229
230        final HttpRequestBase request = HttpMethods.GET.createRequest(url);
231
232        if (accept != null) {
233            request.setHeader("Accept", accept);
234        }
235
236        if (prefer != null) {
237            request.setHeader("Prefer", prefer);
238        }
239
240        LOGGER.debug("Fcrepo GET request headers: {}", request.getAllHeaders());
241
242        final CloseableHttpResponse response = executeRequest(request);
243        final int status = response.getStatusLine().getStatusCode();
244        final String contentType = getContentTypeHeader(response);
245
246        LOGGER.debug("Fcrepo GET request returned status [{}]", status);
247
248        if ((status >= HttpStatus.SC_OK && status < HttpStatus.SC_BAD_REQUEST) || !this.throwExceptionOnFailure) {
249            URI describedBy = null;
250            final List<URI> links = getLinkHeaders(response, DESCRIBED_BY);
251            if (links.size() == 1) {
252                describedBy = links.get(0);
253            }
254            return new FcrepoResponse(url, status, contentType, describedBy,
255                    getEntityContent(response));
256        } else {
257            free(response);
258            throw new FcrepoOperationFailedException(url, status,
259                    response.getStatusLine().getReasonPhrase());
260        }
261    }
262
263    /**
264     * Execute the HTTP request
265     */
266    private CloseableHttpResponse executeRequest(final HttpRequestBase request) throws FcrepoOperationFailedException {
267        try {
268            return httpclient.execute(request);
269        } catch (IOException ex) {
270            LOGGER.debug("HTTP Operation failed: ", ex);
271            throw new FcrepoOperationFailedException(request.getURI(), -1, ex.getMessage());
272        }
273    }
274
275    /**
276     * Handle the general case with responses.
277     */
278    private FcrepoResponse fcrepoGenericResponse(final URI url, final CloseableHttpResponse response,
279            final Boolean throwExceptionOnFailure) throws FcrepoOperationFailedException {
280        final int status = response.getStatusLine().getStatusCode();
281        final URI locationHeader = getLocationHeader(response);
282        final String contentTypeHeader = getContentTypeHeader(response);
283
284        if ((status >= HttpStatus.SC_OK && status < HttpStatus.SC_BAD_REQUEST) || !throwExceptionOnFailure) {
285            return new FcrepoResponse(url, status, contentTypeHeader, locationHeader, getEntityContent(response));
286        } else {
287            free(response);
288            throw new FcrepoOperationFailedException(url, status,
289                    response.getStatusLine().getReasonPhrase());
290        }
291    }
292
293    /**
294     * Frees resources associated with the HTTP response.  Specifically, closing the {@code response} frees the
295     * connection of the {@link org.apache.http.conn.HttpClientConnectionManager} underlying this {@link #httpclient}.
296     *
297     * @param response the response object to close
298     */
299    private void free(final CloseableHttpResponse response) {
300        // Free resources associated with the response.
301        try {
302            response.close();
303        } catch (IOException e) {
304            LOGGER.warn("Unable to close HTTP response.", e);
305        }
306    }
307
308    /**
309     * Extract the response body as an input stream
310     */
311    private static InputStream getEntityContent(final HttpResponse response) {
312        try {
313            final HttpEntity entity = response.getEntity();
314            if (entity == null) {
315                return null;
316            } else {
317                return entity.getContent();
318            }
319        } catch (IOException ex) {
320            LOGGER.debug("Unable to extract HttpEntity response into an InputStream: ", ex);
321            return null;
322        }
323    }
324
325    /**
326     * Extract the location header value
327     */
328    private static URI getLocationHeader(final HttpResponse response) {
329        final Header location = response.getFirstHeader(LOCATION);
330        if (location != null) {
331            return URI.create(location.getValue());
332        } else {
333            return null;
334        }
335    }
336
337    /**
338     * Extract the content-type header value
339     */
340    private static String getContentTypeHeader(final HttpResponse response) {
341        final Header contentType = response.getFirstHeader(CONTENT_TYPE);
342        if (contentType != null) {
343            return contentType.getValue();
344        } else {
345            return null;
346        }
347    }
348
349    /**
350     * Extract any Link headers
351     */
352    private static List<URI> getLinkHeaders(final HttpResponse response, final String relationship) {
353        final List<URI> uris = new ArrayList<URI>();
354        final Header[] links = response.getHeaders("Link");
355        for (Header header: links) {
356            final FcrepoLink link = new FcrepoLink(header.getValue());
357            if (link.getRel().equals(relationship)) {
358                uris.add(link.getUri());
359            }
360        }
361        return uris;
362    }
363 }