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}