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