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.utils;
017
018import static java.lang.Integer.MAX_VALUE;
019
020import static org.apache.http.HttpStatus.SC_BAD_REQUEST;
021import static org.apache.http.HttpStatus.SC_FORBIDDEN;
022import static org.apache.http.HttpStatus.SC_NOT_FOUND;
023import static org.apache.http.HttpStatus.SC_OK;
024
025import static org.apache.jena.riot.WebContent.contentTypeSPARQLUpdate;
026
027import static org.apache.commons.lang3.StringUtils.isBlank;
028import static org.slf4j.LoggerFactory.getLogger;
029
030import java.io.IOException;
031import java.io.InputStream;
032import java.io.UnsupportedEncodingException;
033import java.net.URI;
034import java.net.URLEncoder;
035import java.util.Iterator;
036import java.util.List;
037import java.util.Map;
038
039import org.fcrepo.client.BadRequestException;
040import org.fcrepo.client.ForbiddenException;
041import org.fcrepo.client.NotFoundException;
042
043import com.hp.hpl.jena.graph.Node;
044
045import org.apache.http.Header;
046import org.apache.http.HttpEntity;
047import org.apache.http.HttpResponse;
048import org.apache.http.StatusLine;
049import org.apache.http.auth.AuthScope;
050import org.apache.http.auth.UsernamePasswordCredentials;
051import org.apache.http.client.CredentialsProvider;
052import org.apache.http.client.HttpClient;
053import org.apache.http.client.methods.HttpDelete;
054import org.apache.http.client.methods.HttpGet;
055import org.apache.http.client.methods.HttpHead;
056import org.apache.http.client.methods.HttpPatch;
057import org.apache.http.client.methods.HttpPost;
058import org.apache.http.client.methods.HttpPut;
059import org.apache.http.client.methods.HttpUriRequest;
060import org.apache.http.entity.ByteArrayEntity;
061import org.apache.http.entity.InputStreamEntity;
062import org.apache.http.impl.client.BasicCredentialsProvider;
063import org.apache.http.impl.client.DefaultHttpClient;
064import org.apache.http.impl.client.DefaultRedirectStrategy;
065import org.apache.http.impl.client.StandardHttpRequestRetryHandler;
066import org.apache.http.impl.conn.PoolingClientConnectionManager;
067
068import org.apache.jena.riot.Lang;
069import org.apache.jena.riot.RDFLanguages;
070import org.apache.jena.riot.RiotReader;
071import org.apache.jena.riot.lang.CollectorStreamTriples;
072
073import org.fcrepo.client.FedoraContent;
074import org.fcrepo.client.FedoraException;
075import org.fcrepo.client.FedoraObject;
076import org.fcrepo.client.ReadOnlyException;
077import org.fcrepo.client.impl.FedoraResourceImpl;
078
079import org.slf4j.Logger;
080
081
082/**
083 * HTTP utilities
084 * @author escowles
085 * @since 2014-08-26
086**/
087public class HttpHelper {
088    private static final Logger LOGGER = getLogger(HttpHelper.class);
089
090    private final String repositoryURL;
091    private final HttpClient httpClient;
092    private final boolean readOnly;
093
094    /**
095     * Create an HTTP helper with a pre-configured HttpClient instance.
096     * @param repositoryURL Fedora base URL.
097     * @param httpClient Pre-configured HttpClient instance.
098     * @param readOnly If true, throw an exception when an update is attempted.
099    **/
100    public HttpHelper(final String repositoryURL, final HttpClient httpClient, final boolean readOnly) {
101        this.repositoryURL = repositoryURL;
102        this.httpClient = httpClient;
103        this.readOnly = readOnly;
104    }
105
106    /**
107     * Create an HTTP helper for the specified repository.  If fedoraUsername and fedoraPassword are not null, then
108     * they will be used to connect to the repository.
109     * @param repositoryURL Fedora base URL.
110     * @param fedoraUsername Fedora username
111     * @param fedoraPassword Fedora password
112     * @param readOnly If true, throw an exception when an update is attempted.
113    **/
114    public HttpHelper(final String repositoryURL, final String fedoraUsername, final String fedoraPassword,
115                      final boolean readOnly) {
116        this.repositoryURL = repositoryURL;
117        this.readOnly = readOnly;
118
119        final PoolingClientConnectionManager connMann = new PoolingClientConnectionManager();
120        connMann.setMaxTotal(MAX_VALUE);
121        connMann.setDefaultMaxPerRoute(MAX_VALUE);
122
123        final DefaultHttpClient httpClient = new DefaultHttpClient(connMann);
124        httpClient.setRedirectStrategy(new DefaultRedirectStrategy());
125        httpClient.setHttpRequestRetryHandler(new StandardHttpRequestRetryHandler(0, false));
126
127        // If the Fedora instance requires authentication, set it up here
128        if (!isBlank(fedoraUsername) && !isBlank(fedoraPassword)) {
129            LOGGER.debug("Adding BASIC credentials to client for repo requests.");
130
131            final URI fedoraUri = URI.create(repositoryURL);
132            final CredentialsProvider credsProvider = new BasicCredentialsProvider();
133            credsProvider.setCredentials(new AuthScope(fedoraUri.getHost(), fedoraUri.getPort()),
134                                         new UsernamePasswordCredentials(fedoraUsername, fedoraPassword));
135
136            httpClient.setCredentialsProvider(credsProvider);
137        }
138
139        this.httpClient = httpClient;
140    }
141
142    /**
143     * Execute a request for a subclass.
144     *
145     * @param request request to be executed
146     * @return response containing response to request
147     * @throws IOException
148     * @throws ReadOnlyException
149    **/
150    public HttpResponse execute( final HttpUriRequest request ) throws IOException, ReadOnlyException {
151        if ( readOnly ) {
152            switch ( request.getMethod().toLowerCase() ) {
153                case "copy": case "delete": case "move": case "patch": case "post": case "put":
154                    throw new ReadOnlyException();
155                default:
156                    break;
157            }
158        }
159
160        return httpClient.execute(request);
161    }
162
163    /**
164     * Encode URL parameters as a query string.
165     * @param params Query parameters
166    **/
167    private static String queryString( final Map<String, List<String>> params ) {
168        final StringBuilder builder = new StringBuilder();
169        if (params != null && params.size() > 0) {
170            for (final Iterator<String> it = params.keySet().iterator(); it.hasNext(); ) {
171                final String key = it.next();
172                final List<String> values = params.get(key);
173                for (final String value : values) {
174                    try {
175                        builder.append(key + "=" + URLEncoder.encode(value, "UTF-8") + "&");
176                    } catch (final UnsupportedEncodingException e) {
177                        builder.append(key + "=" + value + "&");
178                    }
179                }
180            }
181            return builder.length() > 0 ? "?" + builder.substring(0, builder.length() - 1) : "";
182        }
183        return "";
184    }
185
186    /**
187     * Create HEAD method
188     * @param path Resource path, relative to repository baseURL
189     * @return HEAD method
190    **/
191    public HttpHead createHeadMethod(final String path) {
192        return new HttpHead(repositoryURL + path);
193    }
194
195    /**
196     * Create GET method with list of parameters
197     * @param path Resource path, relative to repository baseURL
198     * @param params Query parameters
199     * @return GET method
200    **/
201    public HttpGet createGetMethod(final String path, final Map<String, List<String>> params) {
202        return new HttpGet(repositoryURL + path + queryString(params));
203    }
204
205    /**
206     * Create DELETE method
207     * @param path Resource path, relative to repository baseURL
208     * @return DELETE method
209    **/
210    public HttpDelete createDeleteMethod(final String path) {
211        return new HttpDelete(repositoryURL + path);
212    }
213
214    /**
215     * Create a request to update triples with SPARQL Update.
216     * @param path The datastream path.
217     * @param sparqlUpdate SPARQL Update command.
218     * @return created patch based on parameters
219     * @throws FedoraException
220    **/
221    public HttpPatch createPatchMethod(final String path, final String sparqlUpdate) throws FedoraException {
222        if ( isBlank(sparqlUpdate) ) {
223            throw new FedoraException("SPARQL Update command must not be blank");
224        }
225
226        final HttpPatch patch = new HttpPatch(repositoryURL + path);
227        patch.setEntity( new ByteArrayEntity(sparqlUpdate.getBytes()) );
228        patch.setHeader("Content-Type", contentTypeSPARQLUpdate);
229        return patch;
230    }
231
232    /**
233     * Create POST method with list of parameters
234     * @param path Resource path, relative to repository baseURL
235     * @param params Query parameters
236     * @return PUT method
237    **/
238    public HttpPost createPostMethod(final String path, final Map<String, List<String>> params) {
239        return new HttpPost(repositoryURL + path + queryString(params));
240    }
241
242    /**
243     * Create PUT method with list of parameters
244     * @param path Resource path, relative to repository baseURL
245     * @param params Query parameters
246     * @return PUT method
247    **/
248    public HttpPut createPutMethod(final String path, final Map<String, List<String>> params) {
249        return new HttpPut(repositoryURL + path + queryString(params));
250    }
251
252    /**
253     * Create a request to create/update content.
254     * @param path The datastream path.
255     * @param params Mapping of parameters for the PUT request
256     * @param content Content parameters.
257     * @return PUT method
258    **/
259    public HttpPut createContentPutMethod(final String path, final Map<String, List<String>> params,
260                                          final FedoraContent content ) {
261        String contentPath = path;
262        if ( content != null && content.getChecksum() != null ) {
263            contentPath += "?checksum=" + content.getChecksum();
264        }
265
266        final HttpPut put = createPutMethod( contentPath, params );
267
268        // content stream
269        if ( content != null ) {
270            put.setEntity( new InputStreamEntity(content.getContent()) );
271        }
272
273        // filename
274        if ( content != null && content.getFilename() != null ) {
275            put.setHeader("Content-Disposition", "attachment; filename=\"" + content.getFilename() + "\"" );
276        }
277
278        // content type
279        if ( content != null && content.getContentType() != null ) {
280            put.setHeader("Content-Type", content.getContentType() );
281        }
282
283        return put;
284    }
285
286    /**
287     * Create a request to update triples.
288     * @param path The datastream path.
289     * @param updatedProperties InputStream containing RDF.
290     * @param contentType Content type of the RDF in updatedProperties (e.g., "text/rdf+n3" or
291     *        "application/rdf+xml").
292     * @return PUT method
293     * @throws FedoraException
294    **/
295    public HttpPut createTriplesPutMethod(final String path, final InputStream updatedProperties,
296                                          final String contentType) throws FedoraException {
297        if ( updatedProperties == null ) {
298            throw new FedoraException("updatedProperties must not be null");
299        } else if ( isBlank(contentType) ) {
300            throw new FedoraException("contentType must not be blank");
301        }
302
303        final HttpPut put = new HttpPut(repositoryURL + path);
304        put.setEntity( new InputStreamEntity(updatedProperties) );
305        put.setHeader("Content-Type", contentType);
306        return put;
307    }
308
309    /**
310     * Retrieve RDF from the repository and update the properties of a resource
311     * @param resource The resource to update
312     * @return the updated resource
313     * @throws FedoraException
314    **/
315    public FedoraResourceImpl loadProperties( final FedoraResourceImpl resource ) throws FedoraException {
316        final String path = resource.getPropertiesPath();
317        final HttpGet get = createGetMethod(path, null);
318        if (resource instanceof FedoraObject) {
319            get.addHeader("Prefer", "return=representation; "
320                + "include=\"http://fedora.info/definitions/v4/repository#EmbedResources\"");
321        }
322
323        try {
324            get.setHeader("accept", "application/rdf+xml");
325            final HttpResponse response = execute(get);
326
327            final String uri = get.getURI().toString();
328            final StatusLine status = response.getStatusLine();
329
330            if (status.getStatusCode() == SC_OK) {
331                LOGGER.debug("Updated properties for resource {}", uri);
332
333                // header processing
334                final Header[] etagHeader = response.getHeaders("ETag");
335                if (etagHeader != null && etagHeader.length > 0) {
336                    resource.setEtagValue( etagHeader[0].getValue() );
337                }
338
339                // StreamRdf
340                final HttpEntity entity = response.getEntity();
341                final Lang lang = RDFLanguages.contentTypeToLang(entity.getContentType().getValue().split(":")[0]);
342                final CollectorStreamTriples streamTriples = new CollectorStreamTriples();
343                RiotReader.parse(entity.getContent(), lang, uri, streamTriples);
344                resource.setGraph( RDFSinkFilter.filterTriples(streamTriples.getCollected().iterator(), Node.ANY) );
345                return resource;
346            } else if (status.getStatusCode() == SC_FORBIDDEN) {
347                LOGGER.info("request for resource {} is not authorized.", uri);
348                throw new ForbiddenException("request for resource " + uri + " is not authorized.");
349            } else if (status.getStatusCode() == SC_BAD_REQUEST) {
350                LOGGER.info("server does not support metadata type application/rdf+xml for resource {} " +
351                                     " cannot retrieve", uri);
352                throw new BadRequestException("server does not support the request metadata type for resource " + uri);
353            } else if (status.getStatusCode() == SC_NOT_FOUND) {
354                LOGGER.info("resource {} does not exist, cannot retrieve", uri);
355                throw new NotFoundException("resource " + uri + " does not exist, cannot retrieve");
356            } else {
357                LOGGER.info("unexpected status code ({}) when retrieving resource {}", status.getStatusCode(), uri);
358                throw new FedoraException("error retrieving resource " + uri + ": " + status.getStatusCode() + " "
359                                          + status.getReasonPhrase());
360            }
361        } catch (final FedoraException e) {
362            throw e;
363        } catch (final Exception e) {
364            e.printStackTrace();
365            LOGGER.info("could not encode URI parameter", e);
366            throw new FedoraException(e);
367        } finally {
368            get.releaseConnection();
369        }
370    }
371
372    /**
373     * Create COPY method
374     * @param sourcePath Source path, relative to repository baseURL
375     * @param destinationPath Destination path, relative to repository baseURL
376     * @return COPY method
377    **/
378    public HttpCopy createCopyMethod(final String sourcePath, final String destinationPath) {
379        return new HttpCopy(repositoryURL + sourcePath, repositoryURL + destinationPath);
380    }
381
382    /**
383     * Create MOVE method
384     * @param sourcePath Source path, relative to repository baseURL
385     * @param destinationPath Destination path, relative to repository baseURL
386     * @return MOVE method
387    **/
388    public HttpMove createMoveMethod(final String sourcePath, final String destinationPath) {
389        return new HttpMove(repositoryURL + sourcePath, repositoryURL + destinationPath);
390    }
391
392}