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