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