001/*
002 * The contents of this file are subject to the license and copyright
003 * detailed in the LICENSE and NOTICE files at the root of the source
004 * tree.
005 */
006package org.fcrepo.client;
007
008import static org.slf4j.LoggerFactory.getLogger;
009
010import java.io.Closeable;
011import java.io.IOException;
012import java.io.InputStream;
013import java.net.URI;
014import java.time.Instant;
015import java.util.ArrayList;
016import java.util.List;
017import java.util.Map;
018import java.util.TreeMap;
019import java.util.regex.Pattern;
020
021import org.apache.http.Header;
022import org.apache.http.HttpEntity;
023import org.apache.http.HttpResponse;
024import org.apache.http.HttpStatus;
025import org.apache.http.client.methods.CloseableHttpResponse;
026import org.apache.http.client.methods.HttpRequestBase;
027import org.apache.http.impl.client.CloseableHttpClient;
028import org.slf4j.Logger;
029
030/**
031 * Represents a client to interact with Fedora's HTTP API.
032 * <p>
033 * Users of the {@code FcrepoClient} are responsible for managing connection resources. Specifically, the underlying
034 * HTTP connections of this client must be freed. Suggested usage is to create the {@code FcrepoResponse} within a
035 * {@code try-with-resources} block, insuring that any resources held by the response are freed automatically.
036 * </p>
037 * <pre>
038 * FcrepoClient client = ...;
039 * try (FcrepoResponse res = client.get(...).perform()) {
040 *     // do something with the response
041 * } catch (FcrepoOperationFailedException|IOException e) {
042 *     // handle any exceptions
043 * }
044 * </pre>
045 *
046 * @author Aaron Coburn
047 * @since October 20, 2014
048 */
049public class FcrepoClient implements Closeable {
050
051    private CloseableHttpClient httpclient;
052    private FcrepoHttpClientBuilder httpClientBuilder;
053
054    private Boolean throwExceptionOnFailure = true;
055
056    public static final String TRANSACTION_ENDPOINT = "fcr:tx";
057
058    private static final Logger LOGGER = getLogger(FcrepoClient.class);
059
060    /**
061     * Build a FcrepoClient
062     *
063     * @return a client builder
064     */
065    public static FcrepoClientBuilder client() {
066        return new FcrepoClientBuilder();
067    }
068
069    /**
070     * Create a FcrepoClient with a set of authentication values.
071     *
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    protected FcrepoClient(final String username, final String password, final String host,
078            final Boolean throwExceptionOnFailure) {
079        this(new FcrepoHttpClientBuilder(username, password, host), throwExceptionOnFailure);
080    }
081
082    /**
083     * Create a FcrepoClient which uses the given {@link FcrepoHttpClientBuilder} to manage its http client.
084     * FcrepoClient will close the httpClient when {@link #close()} is called.
085     * @param httpClientBuilder http client builder to use to connect to the repository
086     * @param throwExceptionOnFailure whether to throw an exception on any non-2xx or 3xx HTTP responses
087     */
088    protected FcrepoClient(final FcrepoHttpClientBuilder httpClientBuilder, final Boolean throwExceptionOnFailure) {
089        this.throwExceptionOnFailure = throwExceptionOnFailure;
090        this.httpclient = httpClientBuilder.build();
091        this.httpClientBuilder = httpClientBuilder;
092    }
093
094    /**
095     * Create a FcrepoClient which uses the given {@link org.apache.http.impl.client.CloseableHttpClient}.
096     * FcrepoClient will close the httpClient when {@link #close()} is called.
097     *
098     * @param httpClient http client to use to connect to the repository
099     * @param throwExceptionOnFailure whether to throw an exception on any non-2xx or 3xx HTTP responses
100     */
101    protected FcrepoClient(final CloseableHttpClient httpClient, final Boolean throwExceptionOnFailure) {
102        this.throwExceptionOnFailure = throwExceptionOnFailure;
103        this.httpclient = httpClient;
104    }
105
106    /**
107     * Make a PUT request to create a resource with a specified path, or replace the triples associated with a
108     * resource with the triples provided in the request body.
109     *
110     * @param url the URL of the resource to which to PUT
111     * @return a put request builder object
112     */
113    public PutBuilder put(final URI url) {
114        return new PutBuilder(url, this);
115    }
116
117    /**
118     * Make a PATCH request to modify the triples associated with a resource with SPARQL-Update.
119     *
120     * @param url the URL of the resource to which to PATCH
121     * @return a patch request builder object
122     */
123    public PatchBuilder patch(final URI url) {
124        return new PatchBuilder(url, this);
125    }
126
127    /**
128     * Make a POST request to create a new resource within an LDP container.
129     *
130     * @param url the URL of the resource to which to POST
131     * @return a post request builder object
132     */
133    public PostBuilder post(final URI url) {
134        return new PostBuilder(url, this);
135    }
136
137    /**
138     * Make a POST request to create a new memento (LDPRm) within an LDPCv of the current version of a resource.
139     *
140     * @param url the URL of the LDPCv in which to create the LDPRm.
141     * @return a memento creation request builder object
142     */
143    public OriginalMementoBuilder createMemento(final URI url) {
144        return new OriginalMementoBuilder(url, this);
145    }
146
147    /**
148     * Make a POST request to create a new memento (LDPRm) within an LDPCv using the given memento-datetime and the
149     * request body.
150     *
151     * @param url the URL of the LDPCv in which to create the LDPRm.
152     * @param mementoInstant the memento datetime as an Instant.
153     * @return a memento creation request builder object
154     */
155    public HistoricMementoBuilder createMemento(final URI url, final Instant mementoInstant) {
156        return new HistoricMementoBuilder(url, this, mementoInstant);
157    }
158
159    /**
160     * Make a POST request to create a new memento (LDPRm) within an LDPCv using the given memento-datetime and the
161     * request body.
162     *
163     * @param url the URL of the LDPCv in which to create the LDPRm.
164     * @param mementoDatetime the RFC1123 formatted memento datetime.
165     * @return a memento creation request builder object
166     */
167    public HistoricMementoBuilder createMemento(final URI url, final String mementoDatetime) {
168        return new HistoricMementoBuilder(url, this, mementoDatetime);
169    }
170
171    /**
172     * Start a transaction and create a new {@link TransactionalFcrepoClient}
173     *
174     * @param uri the base rest endpoint or the transaction endpoint
175     * @return the TransactionalFcrepoClient
176     * @throws IOException if there's an error with the http request
177     * @throws IllegalArgumentException if the uri is not the Fedora transaction endpoint
178     * @throws FcrepoOperationFailedException if there's an error in the fcrepo operation
179     */
180    public TransactionalFcrepoClient startTransactionClient(final URI uri)
181        throws IOException, FcrepoOperationFailedException {
182        final var target = getTxEndpoint(uri);
183        try (final var response = post(target).perform()) {
184            return transactionalClient(response);
185        }
186    }
187
188    private URI getTxEndpoint(final URI uri) {
189        final var isRoot = Pattern.compile("rest/?$").asPredicate();
190        final var isTx = Pattern.compile("rest/" + TRANSACTION_ENDPOINT + "/?$").asPredicate();
191        final var base = uri.toString();
192        if (isRoot.test(base)) {
193            LOGGER.debug("Start transaction request matches root, appending {}", TRANSACTION_ENDPOINT);
194            // preface with ./ so fcr:tx isn't interpreted as a scheme
195            return uri.resolve("./" + TRANSACTION_ENDPOINT);
196        } else if (isTx.test(base)) {
197            return uri;
198        } else {
199            throw new IllegalArgumentException("Uri is not the base rest endpoint or the transaction endpoint");
200        }
201    }
202
203    /**
204     * Create a new {@link TransactionalFcrepoClient} which adds the transaction {@link URI} to each request
205     *
206     * @param response the FcrepoResponse with an Atomic-ID Header
207     * @return a TransactionFcrepoClient
208     * @throws IllegalArgumentException if the FcrepoResponse does not contain a transaction location
209     */
210    public TransactionalFcrepoClient transactionalClient(final FcrepoResponse response) {
211        return new TransactionalFcrepoClient(response.getTransactionUri(), httpClientBuilder, throwExceptionOnFailure);
212    }
213
214    /**
215     * Make a DELETE request to delete a resource
216     *
217     * @param url the URL of the resource to which to DELETE
218     * @return a delete request builder object
219     */
220    public DeleteBuilder delete(final URI url) {
221        return new DeleteBuilder(url, this);
222    }
223
224    /**
225     * Make a GET request to retrieve the content of a resource
226     *
227     * @param url the URL of the resource to which to GET
228     * @return a get request builder object
229     */
230    public GetBuilder get(final URI url) {
231        return new GetBuilder(url, this);
232    }
233
234    /**
235     * Make a HEAD request to retrieve resource headers.
236     *
237     * @param url the URL of the resource to make the HEAD request on.
238     * @return a HEAD request builder object
239     */
240    public HeadBuilder head(final URI url) {
241        return new HeadBuilder(url, this);
242    }
243
244    /**
245     * Make a OPTIONS request to output information about the supported HTTP methods, etc.
246     *
247     * @param url the URL of the resource to make the OPTIONS request on.
248     * @return a OPTIONS request builder object
249     */
250    public OptionsBuilder options(final URI url) {
251        return new OptionsBuilder(url, this);
252    }
253
254    @Override
255    public void close() throws IOException {
256        this.httpclient.close();
257    }
258
259    /**
260     * Execute a HTTP request
261     *
262     * @param url URI the request is made to
263     * @param request the request
264     * @return the repository response
265     * @throws FcrepoOperationFailedException when the underlying HTTP request results in an error
266     */
267    public FcrepoResponse executeRequest(final URI url, final HttpRequestBase request)
268            throws FcrepoOperationFailedException {
269        LOGGER.debug("Fcrepo {} request to resource {}", request.getMethod(), url);
270        final CloseableHttpResponse response = executeRequest(request);
271
272        return fcrepoGenericResponse(url, response, throwExceptionOnFailure);
273    }
274
275    /**
276     * Execute the HTTP request
277     */
278    private CloseableHttpResponse executeRequest(final HttpRequestBase request)
279            throws FcrepoOperationFailedException {
280        try {
281            return httpclient.execute(request);
282        } catch (final IOException ex) {
283            LOGGER.debug("HTTP Operation failed: ", ex);
284            throw new FcrepoOperationFailedException(request.getURI(), -1, ex.getMessage());
285        }
286    }
287
288    /**
289     * Handle the general case with responses.
290     */
291    private FcrepoResponse fcrepoGenericResponse(final URI url, final CloseableHttpResponse response,
292            final Boolean throwExceptionOnFailure) throws FcrepoOperationFailedException {
293        final int status = response.getStatusLine().getStatusCode();
294        final Map<String, List<String>> headers = getHeaders(response);
295
296        if ((status >= HttpStatus.SC_OK && status < HttpStatus.SC_BAD_REQUEST) || !throwExceptionOnFailure) {
297            return new FcrepoResponse(url, status, headers, getEntityContent(response));
298        } else {
299            free(response);
300            throw new FcrepoOperationFailedException(url, status,
301                    response.getStatusLine().getReasonPhrase());
302        }
303    }
304
305    /**
306     * Frees resources associated with the HTTP response. Specifically, closing the {@code response} frees the
307     * connection of the {@link org.apache.http.conn.HttpClientConnectionManager} underlying this {@link #httpclient}.
308     *
309     * @param response the response object to close
310     */
311    private void free(final CloseableHttpResponse response) {
312        // Free resources associated with the response.
313        try {
314            response.close();
315        } catch (final IOException e) {
316            LOGGER.warn("Unable to close HTTP response.", e);
317        }
318    }
319
320    /**
321     * Extract the response body as an input stream
322     */
323    private static InputStream getEntityContent(final HttpResponse response) {
324        try {
325            final HttpEntity entity = response.getEntity();
326            if (entity == null) {
327                return null;
328            } else {
329                return entity.getContent();
330            }
331        } catch (final IOException ex) {
332            LOGGER.debug("Unable to extract HttpEntity response into an InputStream: ", ex);
333            return null;
334        }
335    }
336
337    /**
338     * Retrieve all header values
339     *
340     * @param response response from request
341     * @return Map of all values for all response headers
342     */
343    private static Map<String, List<String>> getHeaders(final HttpResponse response) {
344        final Map<String, List<String>> headers = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
345
346        for (final Header header : response.getAllHeaders()) {
347            final List<String> values;
348            if (headers.containsKey(header.getName())) {
349                values = headers.get(header.getName());
350            } else {
351                values = new ArrayList<>();
352                headers.put(header.getName(), values);
353            }
354            values.add(header.getValue());
355        }
356        return headers;
357    }
358
359    /**
360     * Builds an FcrepoClient
361     *
362     * @author bbpennel
363     */
364    public static class FcrepoClientBuilder {
365
366        private String authUser;
367
368        private String authPassword;
369
370        private String authHost;
371
372        private boolean throwExceptionOnFailure;
373
374        /**
375         * Add basic authentication credentials to this client
376         *
377         * @param username username for authentication
378         * @param password password for authentication
379         * @return the client builder
380         */
381        public FcrepoClientBuilder credentials(final String username, final String password) {
382            this.authUser = username;
383            this.authPassword = password;
384            return this;
385        }
386
387        /**
388         * Add an authentication scope to this client
389         *
390         * @param authHost authentication scope value
391         * @return this builder
392         */
393        public FcrepoClientBuilder authScope(final String authHost) {
394            this.authHost = authHost;
395
396            return this;
397        }
398
399        /**
400         * Client should throw exceptions when failures occur
401         *
402         * @return this builder
403         */
404        public FcrepoClientBuilder throwExceptionOnFailure() {
405            this.throwExceptionOnFailure = true;
406            return this;
407        }
408
409        /**
410         * Get the client
411         *
412         * @return the client constructed by this builder
413         */
414        public FcrepoClient build() {
415            final FcrepoHttpClientBuilder httpClient = new FcrepoHttpClientBuilder(authUser, authPassword, authHost);
416            return new FcrepoClient(httpClient, throwExceptionOnFailure);
417        }
418    }
419}