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.http.api; 017 018 019import static com.hp.hpl.jena.rdf.model.ResourceFactory.createResource; 020import static javax.ws.rs.core.MediaType.APPLICATION_XHTML_XML; 021import static javax.ws.rs.core.MediaType.APPLICATION_XML; 022import static javax.ws.rs.core.MediaType.TEXT_HTML; 023import static javax.ws.rs.core.MediaType.TEXT_PLAIN; 024import static javax.ws.rs.core.Response.Status.BAD_REQUEST; 025import static javax.ws.rs.core.Response.Status.NOT_IMPLEMENTED; 026import static javax.ws.rs.core.Response.created; 027import static javax.ws.rs.core.Response.noContent; 028import static javax.ws.rs.core.Response.ok; 029import static javax.ws.rs.core.Response.Status.CONFLICT; 030import static javax.ws.rs.core.Response.Status.UNSUPPORTED_MEDIA_TYPE; 031import static javax.ws.rs.core.Response.Status.FORBIDDEN; 032import static org.apache.commons.lang3.StringUtils.isBlank; 033import static org.apache.jena.riot.WebContent.contentTypeSPARQLUpdate; 034import static org.fcrepo.http.commons.domain.RDFMediaType.JSON_LD; 035import static org.fcrepo.http.commons.domain.RDFMediaType.N3; 036import static org.fcrepo.http.commons.domain.RDFMediaType.N3_ALT2; 037import static org.fcrepo.http.commons.domain.RDFMediaType.NTRIPLES; 038import static org.fcrepo.http.commons.domain.RDFMediaType.RDF_XML; 039import static org.fcrepo.http.commons.domain.RDFMediaType.TURTLE; 040import static org.fcrepo.http.commons.domain.RDFMediaType.TURTLE_X; 041import static org.fcrepo.kernel.api.FedoraJcrTypes.FEDORA_BINARY; 042import static org.fcrepo.kernel.api.FedoraJcrTypes.FEDORA_CONTAINER; 043import static org.fcrepo.kernel.api.FedoraJcrTypes.FEDORA_PAIRTREE; 044import static org.fcrepo.kernel.api.RdfLexicon.LDP_NAMESPACE; 045import static org.fcrepo.kernel.modeshape.services.TransactionServiceImpl.getCurrentTransactionId; 046import static org.slf4j.LoggerFactory.getLogger; 047 048import java.io.IOException; 049import java.io.InputStream; 050import java.io.UnsupportedEncodingException; 051import java.net.URI; 052import java.net.URLDecoder; 053 054import javax.annotation.PostConstruct; 055import javax.inject.Inject; 056import javax.jcr.AccessDeniedException; 057import javax.jcr.PathNotFoundException; 058import javax.jcr.RepositoryException; 059import javax.jcr.Session; 060import javax.ws.rs.BadRequestException; 061import javax.ws.rs.ClientErrorException; 062import javax.ws.rs.Consumes; 063import javax.ws.rs.DELETE; 064import javax.ws.rs.GET; 065import javax.ws.rs.HEAD; 066import javax.ws.rs.HeaderParam; 067import javax.ws.rs.OPTIONS; 068import javax.ws.rs.POST; 069import javax.ws.rs.PUT; 070import javax.ws.rs.Path; 071import javax.ws.rs.PathParam; 072import javax.ws.rs.Produces; 073import javax.ws.rs.QueryParam; 074import javax.ws.rs.ServerErrorException; 075import javax.ws.rs.core.Link; 076import javax.ws.rs.core.MediaType; 077import javax.ws.rs.core.Response; 078import javax.ws.rs.core.UriBuilderException; 079 080import org.fcrepo.http.commons.domain.ContentLocation; 081import org.fcrepo.http.commons.domain.PATCH; 082import org.fcrepo.kernel.api.exception.InvalidChecksumException; 083import org.fcrepo.kernel.api.exception.MalformedRdfException; 084import org.fcrepo.kernel.api.exception.RepositoryRuntimeException; 085import org.fcrepo.kernel.api.models.Container; 086import org.fcrepo.kernel.api.models.FedoraBinary; 087import org.fcrepo.kernel.api.models.FedoraResource; 088import org.fcrepo.kernel.api.models.NonRdfSourceDescription; 089import org.fcrepo.kernel.api.utils.iterators.RdfStream; 090 091import org.apache.commons.io.IOUtils; 092import org.apache.commons.lang3.StringUtils; 093import org.glassfish.jersey.media.multipart.ContentDisposition; 094import org.slf4j.Logger; 095import org.springframework.context.annotation.Scope; 096 097import com.codahale.metrics.annotation.Timed; 098import com.google.common.annotations.VisibleForTesting; 099 100/** 101 * @author cabeer 102 * @author ajs6f 103 * @since 9/25/14 104 */ 105 106@Scope("request") 107@Path("/{path: .*}") 108public class FedoraLdp extends ContentExposingResource { 109 110 111 @Inject 112 protected Session session; 113 114 private static final Logger LOGGER = getLogger(FedoraLdp.class); 115 116 @PathParam("path") protected String externalPath; 117 118 @Inject private FedoraHttpConfiguration httpConfiguration; 119 120 /** 121 * Default JAX-RS entry point 122 */ 123 public FedoraLdp() { 124 super(); 125 } 126 127 /** 128 * Create a new FedoraNodes instance for a given path 129 * @param externalPath the external path 130 */ 131 @VisibleForTesting 132 public FedoraLdp(final String externalPath) { 133 this.externalPath = externalPath; 134 } 135 136 /** 137 * Run these actions after initializing this resource 138 */ 139 @PostConstruct 140 public void postConstruct() { 141 setUpJMSInfo(uriInfo, headers); 142 } 143 144 /** 145 * Retrieve the node headers 146 * @return response 147 */ 148 @HEAD 149 @Timed 150 public Response head() { 151 LOGGER.info("HEAD for: {}", externalPath); 152 153 checkCacheControlHeaders(request, servletResponse, resource(), session); 154 155 addResourceHttpHeaders(resource()); 156 157 final Response.ResponseBuilder builder = ok(); 158 159 if (resource() instanceof FedoraBinary) { 160 builder.type(((FedoraBinary) resource()).getMimeType()); 161 } 162 163 return builder.build(); 164 } 165 166 /** 167 * Outputs information about the supported HTTP methods, etc. 168 * @return the outputs information about the supported HTTP methods, etc. 169 */ 170 @OPTIONS 171 @Timed 172 public Response options() { 173 LOGGER.info("OPTIONS for '{}'", externalPath); 174 addOptionsHttpHeaders(); 175 return ok().build(); 176 } 177 178 179 /** 180 * Retrieve the node profile 181 * 182 * @param rangeValue the range value 183 * @return triples for the specified node 184 * @throws IOException if IO exception occurred 185 */ 186 @GET 187 @Produces({TURTLE + ";qs=10", JSON_LD + ";qs=8", 188 N3, N3_ALT2, RDF_XML, NTRIPLES, APPLICATION_XML, TEXT_PLAIN, TURTLE_X, 189 TEXT_HTML, APPLICATION_XHTML_XML, "*/*"}) 190 public Response describe(@HeaderParam("Range") final String rangeValue) throws IOException { 191 checkCacheControlHeaders(request, servletResponse, resource(), session); 192 193 LOGGER.info("GET resource '{}'", externalPath); 194 addResourceHttpHeaders(resource()); 195 196 final RdfStream rdfStream = new RdfStream().session(session) 197 .topic(translator().reverse().convert(resource()).asNode()); 198 199 return getContent(rangeValue, rdfStream); 200 201 } 202 203 /** 204 * Deletes an object. 205 * 206 * @return response 207 */ 208 @DELETE 209 @Timed 210 public Response deleteObject() { 211 evaluateRequestPreconditions(request, servletResponse, resource(), session); 212 213 LOGGER.info("Delete resource '{}'", externalPath); 214 resource().delete(); 215 216 try { 217 session.save(); 218 } catch (final RepositoryException e) { 219 throw new RepositoryRuntimeException(e); 220 } 221 222 return noContent().build(); 223 } 224 225 226 /** 227 * Create a resource at a specified path, or replace triples with provided RDF. 228 * @param requestContentType the request content type 229 * @param requestBodyStream the request body stream 230 * @param checksum the checksum value 231 * @param contentDisposition the content disposition value 232 * @param ifMatch the if-match value 233 * @param link the link value 234 * @return 204 235 * @throws InvalidChecksumException if invalid checksum exception occurred 236 * @throws MalformedRdfException if malformed rdf exception occurred 237 */ 238 @PUT 239 @Consumes 240 @Timed 241 public Response createOrReplaceObjectRdf( 242 @HeaderParam("Content-Type") final MediaType requestContentType, 243 @ContentLocation final InputStream requestBodyStream, 244 @QueryParam("checksum") final String checksum, 245 @HeaderParam("Content-Disposition") final ContentDisposition contentDisposition, 246 @HeaderParam("If-Match") final String ifMatch, 247 @HeaderParam("Link") final String link) 248 throws InvalidChecksumException, MalformedRdfException { 249 250 checkLinkForLdpResourceCreation(link); 251 252 final FedoraResource resource; 253 final Response.ResponseBuilder response; 254 255 final String path = toPath(translator(), externalPath); 256 257 final MediaType contentType = getSimpleContentType(requestContentType); 258 259 if (nodeService.exists(session, path)) { 260 resource = resource(); 261 response = noContent(); 262 } else { 263 final MediaType effectiveContentType 264 = requestBodyStream == null || requestContentType == null ? null : contentType; 265 resource = createFedoraResource(path, effectiveContentType, contentDisposition); 266 267 final URI location = getUri(resource); 268 269 response = created(location).entity(location.toString()); 270 } 271 272 if (httpConfiguration.putRequiresIfMatch() && StringUtils.isBlank(ifMatch) && !resource.isNew()) { 273 throw new ClientErrorException("An If-Match header is required", 428); 274 } 275 276 evaluateRequestPreconditions(request, servletResponse, resource, session); 277 278 final RdfStream resourceTriples; 279 280 if (resource.isNew()) { 281 resourceTriples = new RdfStream(); 282 } else { 283 resourceTriples = getResourceTriples(); 284 } 285 286 LOGGER.info("PUT resource '{}'", externalPath); 287 if (resource instanceof FedoraBinary) { 288 replaceResourceBinaryWithStream((FedoraBinary) resource, 289 requestBodyStream, contentDisposition, requestContentType, checksum); 290 } else if (isRdfContentType(contentType.toString())) { 291 replaceResourceWithStream(resource, requestBodyStream, contentType, resourceTriples); 292 } else if (!resource.isNew()) { 293 boolean emptyRequest = true; 294 try { 295 emptyRequest = requestBodyStream.read() == -1; 296 } catch (final IOException ex) { 297 LOGGER.debug("Error checking for request body content", ex); 298 } 299 300 if (requestContentType == null && emptyRequest) { 301 throw new ClientErrorException("Resource Already Exists", CONFLICT); 302 } 303 throw new ClientErrorException("Invalid Content Type " + requestContentType, UNSUPPORTED_MEDIA_TYPE); 304 } 305 306 try { 307 session.save(); 308 } catch (final RepositoryException e) { 309 throw new RepositoryRuntimeException(e); 310 } 311 312 addCacheControlHeaders(servletResponse, resource, session); 313 314 addResourceLinkHeaders(resource); 315 316 return response.build(); 317 318 } 319 320 /** 321 * Update an object using SPARQL-UPDATE 322 * 323 * @param requestBodyStream the request body stream 324 * @return 201 325 * @throws MalformedRdfException if malformed rdf exception occurred 326 * @throws AccessDeniedException if exception updating property occurred 327 * @throws IOException if IO exception occurred 328 */ 329 @PATCH 330 @Consumes({contentTypeSPARQLUpdate}) 331 @Timed 332 public Response updateSparql(@ContentLocation final InputStream requestBodyStream) 333 throws IOException, MalformedRdfException, AccessDeniedException { 334 335 if (null == requestBodyStream) { 336 throw new BadRequestException("SPARQL-UPDATE requests must have content!"); 337 } 338 339 if (resource() instanceof FedoraBinary) { 340 throw new BadRequestException(resource() + " is not a valid object to receive a PATCH"); 341 } 342 343 try { 344 final String requestBody = IOUtils.toString(requestBodyStream); 345 if (isBlank(requestBody)) { 346 throw new BadRequestException("SPARQL-UPDATE requests must have content!"); 347 } 348 349 evaluateRequestPreconditions(request, servletResponse, resource(), session); 350 351 final RdfStream resourceTriples; 352 353 if (resource().isNew()) { 354 resourceTriples = new RdfStream(); 355 } else { 356 resourceTriples = getResourceTriples(); 357 } 358 359 LOGGER.info("PATCH for '{}'", externalPath); 360 patchResourcewithSparql(resource(), requestBody, resourceTriples); 361 362 session.save(); 363 364 addCacheControlHeaders(servletResponse, resource(), session); 365 366 return noContent().build(); 367 } catch (final IllegalArgumentException iae) { 368 throw new BadRequestException(iae.getMessage()); 369 } catch ( final RuntimeException ex ) { 370 final Throwable cause = ex.getCause(); 371 if (cause instanceof PathNotFoundException) { 372 // the sparql update referred to a repository resource that doesn't exist 373 throw new BadRequestException(cause.getMessage()); 374 } 375 throw ex; 376 } catch (final RepositoryException e) { 377 if (e instanceof AccessDeniedException) { 378 throw new AccessDeniedException(e.getMessage()); 379 } 380 throw new RepositoryRuntimeException(e); 381 } 382 } 383 384 /** 385 * Creates a new object. 386 * 387 * application/octet-stream;qs=1001 is a workaround for JERSEY-2636, to ensure 388 * requests without a Content-Type get routed here. 389 * 390 * @param checksum the checksum value 391 * @param contentDisposition the content Disposition value 392 * @param requestContentType the request content type 393 * @param slug the slug value 394 * @param requestBodyStream the request body stream 395 * @param link the link value 396 * @return 201 397 * @throws InvalidChecksumException if invalid checksum exception occurred 398 * @throws IOException if IO exception occurred 399 * @throws MalformedRdfException if malformed rdf exception occurred 400 * @throws AccessDeniedException if access denied in creating resource 401 */ 402 @POST 403 @Consumes({MediaType.APPLICATION_OCTET_STREAM + ";qs=1001", MediaType.WILDCARD}) 404 @Timed 405 public Response createObject(@QueryParam("checksum") final String checksum, 406 @HeaderParam("Content-Disposition") final ContentDisposition contentDisposition, 407 @HeaderParam("Content-Type") final MediaType requestContentType, 408 @HeaderParam("Slug") final String slug, 409 @ContentLocation final InputStream requestBodyStream, 410 @HeaderParam("Link") final String link) 411 throws InvalidChecksumException, IOException, MalformedRdfException, AccessDeniedException { 412 413 checkLinkForLdpResourceCreation(link); 414 415 if (!(resource() instanceof Container)) { 416 throw new ClientErrorException("Object cannot have child nodes", CONFLICT); 417 } else if (resource().hasType(FEDORA_PAIRTREE)) { 418 throw new ClientErrorException("Objects cannot be created under pairtree nodes", FORBIDDEN); 419 } 420 421 final MediaType contentType = getSimpleContentType(requestContentType); 422 423 final String contentTypeString = contentType.toString(); 424 425 final String newObjectPath = mintNewPid(slug); 426 427 LOGGER.info("Ingest with path: {}", newObjectPath); 428 429 final MediaType effectiveContentType 430 = requestBodyStream == null || requestContentType == null ? null : contentType; 431 final FedoraResource result = createFedoraResource( 432 newObjectPath, 433 effectiveContentType, 434 contentDisposition); 435 436 final RdfStream resourceTriples; 437 438 if (result.isNew()) { 439 resourceTriples = new RdfStream(); 440 } else { 441 resourceTriples = getResourceTriples(); 442 } 443 444 if (requestBodyStream == null) { 445 LOGGER.trace("No request body detected"); 446 } else { 447 LOGGER.trace("Received createObject with a request body and content type \"{}\"", contentTypeString); 448 449 if ((result instanceof Container) 450 && isRdfContentType(contentTypeString)) { 451 replaceResourceWithStream(result, requestBodyStream, contentType, resourceTriples); 452 } else if (result instanceof FedoraBinary) { 453 LOGGER.trace("Created a datastream and have a binary payload."); 454 455 replaceResourceBinaryWithStream((FedoraBinary) result, 456 requestBodyStream, contentDisposition, requestContentType, checksum); 457 458 } else if (contentTypeString.equals(contentTypeSPARQLUpdate)) { 459 LOGGER.trace("Found SPARQL-Update content, applying.."); 460 patchResourcewithSparql(result, IOUtils.toString(requestBodyStream), resourceTriples); 461 } else { 462 if (requestBodyStream.read() != -1) { 463 throw new ClientErrorException("Invalid Content Type " + contentTypeString, UNSUPPORTED_MEDIA_TYPE); 464 } 465 } 466 } 467 468 try { 469 session.save(); 470 } catch (final RepositoryException e) { 471 throw new RepositoryRuntimeException(e); 472 } 473 474 LOGGER.debug("Finished creating resource with path: {}", newObjectPath); 475 476 addCacheControlHeaders(servletResponse, result, session); 477 478 final URI location = getUri(result); 479 480 addResourceLinkHeaders(result, true); 481 482 return created(location).entity(location.toString()).build(); 483 484 } 485 486 @Override 487 protected void addResourceHttpHeaders(final FedoraResource resource) { 488 super.addResourceHttpHeaders(resource); 489 490 if (getCurrentTransactionId(session) != null) { 491 final String canonical = translator().reverse() 492 .convert(resource) 493 .toString() 494 .replaceFirst("/tx:[^/]+", ""); 495 496 497 servletResponse.addHeader("Link", "<" + canonical + ">;rel=\"canonical\""); 498 499 } 500 501 addOptionsHttpHeaders(); 502 } 503 504 @Override 505 protected String externalPath() { 506 return externalPath; 507 } 508 509 private void addOptionsHttpHeaders() { 510 final String options; 511 512 if (resource() instanceof FedoraBinary) { 513 options = "DELETE,HEAD,GET,PUT,OPTIONS"; 514 515 } else if (resource() instanceof NonRdfSourceDescription) { 516 options = "MOVE,COPY,DELETE,POST,HEAD,GET,PUT,PATCH,OPTIONS"; 517 servletResponse.addHeader("Accept-Patch", contentTypeSPARQLUpdate); 518 519 } else if (resource() instanceof Container) { 520 options = "MOVE,COPY,DELETE,POST,HEAD,GET,PUT,PATCH,OPTIONS"; 521 servletResponse.addHeader("Accept-Patch", contentTypeSPARQLUpdate); 522 523 final String rdfTypes = TURTLE + "," + N3 + "," 524 + N3_ALT2 + "," + RDF_XML + "," + NTRIPLES; 525 servletResponse.addHeader("Accept-Post", rdfTypes + "," + MediaType.MULTIPART_FORM_DATA 526 + "," + contentTypeSPARQLUpdate); 527 } else { 528 options = ""; 529 } 530 531 addResourceLinkHeaders(resource()); 532 533 servletResponse.addHeader("Allow", options); 534 } 535 536 private void addResourceLinkHeaders(final FedoraResource resource) { 537 addResourceLinkHeaders(resource, false); 538 } 539 540 private void addResourceLinkHeaders(final FedoraResource resource, final boolean includeAnchor) { 541 if (resource instanceof NonRdfSourceDescription) { 542 final URI uri = getUri(((NonRdfSourceDescription) resource).getDescribedResource()); 543 final Link link = Link.fromUri(uri).rel("describes").build(); 544 servletResponse.addHeader("Link", link.toString()); 545 } else if (resource instanceof FedoraBinary) { 546 final URI uri = getUri(((FedoraBinary) resource).getDescription()); 547 final Link.Builder builder = Link.fromUri(uri).rel("describedby"); 548 549 if (includeAnchor) { 550 builder.param("anchor", getUri(resource).toString()); 551 } 552 servletResponse.addHeader("Link", builder.build().toString()); 553 } 554 555 556 } 557 558 private static String getRequestedObjectType(final MediaType requestContentType, 559 final ContentDisposition contentDisposition) { 560 561 if (requestContentType != null) { 562 final String s = requestContentType.toString(); 563 if (!s.equals(contentTypeSPARQLUpdate) && !isRdfContentType(s) || s.equals(TEXT_PLAIN)) { 564 return FEDORA_BINARY; 565 } 566 } 567 568 if (contentDisposition != null && contentDisposition.getType().equals("attachment")) { 569 return FEDORA_BINARY; 570 } 571 572 return FEDORA_CONTAINER; 573 } 574 575 private FedoraResource createFedoraResource(final String path, 576 final MediaType requestContentType, 577 final ContentDisposition contentDisposition) { 578 final String objectType = getRequestedObjectType(requestContentType, contentDisposition); 579 580 final FedoraResource result; 581 582 if (objectType.equals(FEDORA_BINARY)) { 583 result = binaryService.findOrCreate(session, path); 584 } else { 585 result = containerService.findOrCreate(session, path); 586 } 587 588 return result; 589 } 590 591 @Override 592 protected Session session() { 593 return session; 594 } 595 596 private String mintNewPid(final String slug) { 597 String pid; 598 599 if (slug != null && !slug.isEmpty()) { 600 pid = slug; 601 } else { 602 pid = pidMinter.get(); 603 } 604 // reverse translate the proffered or created identifier 605 LOGGER.trace("Using external identifier {} to create new resource.", pid); 606 LOGGER.trace("Using prefixed external identifier {} to create new resource.", uriInfo.getBaseUri() + "/" 607 + pid); 608 609 final URI newResourceUri = uriInfo.getAbsolutePathBuilder().clone().path(FedoraLdp.class) 610 .resolveTemplate("path", pid, false).build(); 611 612 pid = translator().asString(createResource(newResourceUri.toString())); 613 try { 614 pid = URLDecoder.decode(pid, "UTF-8"); 615 } catch (final UnsupportedEncodingException e) { 616 // noop 617 } 618 // remove leading slash left over from translation 619 LOGGER.trace("Using internal identifier {} to create new resource.", pid); 620 621 if (nodeService.exists(session, pid)) { 622 LOGGER.trace("Resource with path {} already exists; minting new path instead", pid); 623 return mintNewPid(null); 624 } 625 626 return pid; 627 } 628 629 private void checkLinkForLdpResourceCreation(final String link) { 630 if (link != null) { 631 try { 632 final Link linq = Link.valueOf(link); 633 if ("type".equals(linq.getRel()) && (LDP_NAMESPACE + "Resource").equals(linq.getUri().toString())) { 634 LOGGER.info("Unimplemented LDPR creation requested with header link: {}", link); 635 throw new ServerErrorException("LDPR creation not implemented", NOT_IMPLEMENTED); 636 } 637 } catch (RuntimeException e) { 638 if (e instanceof IllegalArgumentException | e instanceof UriBuilderException) { 639 throw new ClientErrorException("Invalid link specified: " + link, BAD_REQUEST); 640 } 641 throw e; 642 } 643 } 644 } 645 646}