001/** 002 * Copyright 2014 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 */ 224 @PUT 225 @Consumes 226 @Timed 227 public Response createOrReplaceObjectRdf( 228 @HeaderParam("Content-Type") final MediaType requestContentType, 229 @ContentLocation final InputStream requestBodyStream, 230 @QueryParam("checksum") final String checksum, 231 @HeaderParam("Content-Disposition") final ContentDisposition contentDisposition, 232 @HeaderParam("If-Match") final String ifMatch) 233 throws InvalidChecksumException, MalformedRdfException { 234 235 final FedoraResource resource; 236 final Response.ResponseBuilder response; 237 238 final String path = toPath(translator(), externalPath); 239 240 final MediaType contentType = getSimpleContentType(requestContentType); 241 242 if (nodeService.exists(session, path)) { 243 resource = resource(); 244 response = noContent(); 245 } else { 246 final MediaType effectiveContentType 247 = requestBodyStream == null || requestContentType == null ? null : contentType; 248 resource = createFedoraResource(path, effectiveContentType, contentDisposition); 249 250 final URI location = getUri(resource); 251 252 response = created(location).entity(location.toString()); 253 } 254 255 if (httpConfiguration.putRequiresIfMatch() && StringUtils.isBlank(ifMatch) && !resource.isNew()) { 256 throw new ClientErrorException("An If-Match header is required", 428); 257 } 258 259 evaluateRequestPreconditions(request, servletResponse, resource, session); 260 261 final RdfStream resourceTriples; 262 263 if (resource.isNew()) { 264 resourceTriples = new RdfStream(); 265 } else { 266 resourceTriples = getResourceTriples(); 267 } 268 269 LOGGER.info("PUT resource '{}'", externalPath); 270 if (resource instanceof FedoraBinary) { 271 replaceResourceBinaryWithStream((FedoraBinary) resource, 272 requestBodyStream, contentDisposition, requestContentType, checksum); 273 } else if (isRdfContentType(contentType.toString())) { 274 try { 275 replaceResourceWithStream(resource, requestBodyStream, contentType, resourceTriples); 276 } catch (final RiotException e) { 277 throw new BadRequestException("RDF was not parsable", e); 278 } 279 } else if (!resource.isNew()) { 280 boolean emptyRequest = true; 281 try { 282 emptyRequest = requestBodyStream.read() == -1; 283 } catch (IOException ex) { 284 LOGGER.debug("Error checking for request body content", ex); 285 } 286 287 if (requestContentType == null && emptyRequest) { 288 throw new ClientErrorException("Resource Already Exists", CONFLICT); 289 } else { 290 throw new ClientErrorException("Invalid Content Type " + requestContentType, UNSUPPORTED_MEDIA_TYPE); 291 } 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 */ 376 @POST 377 @Consumes({MediaType.APPLICATION_OCTET_STREAM + ";qs=1001", MediaType.WILDCARD}) 378 @Timed 379 public Response createObject(@QueryParam("checksum") final String checksum, 380 @HeaderParam("Content-Disposition") final ContentDisposition contentDisposition, 381 @HeaderParam("Content-Type") final MediaType requestContentType, 382 @HeaderParam("Slug") final String slug, 383 @ContentLocation final InputStream requestBodyStream) 384 throws InvalidChecksumException, IOException, MalformedRdfException { 385 386 if (!(resource() instanceof Container)) { 387 throw new ClientErrorException("Object cannot have child nodes", CONFLICT); 388 } 389 390 final MediaType contentType = getSimpleContentType(requestContentType); 391 392 final String contentTypeString = contentType.toString(); 393 394 final String newObjectPath = mintNewPid(slug); 395 396 LOGGER.info("Ingest with path: {}", newObjectPath); 397 398 final MediaType effectiveContentType 399 = requestBodyStream == null || requestContentType == null ? null : contentType; 400 final FedoraResource result = createFedoraResource( 401 newObjectPath, 402 effectiveContentType, 403 contentDisposition); 404 405 final RdfStream resourceTriples; 406 407 if (result.isNew()) { 408 resourceTriples = new RdfStream(); 409 } else { 410 resourceTriples = getResourceTriples(); 411 } 412 413 if (requestBodyStream == null) { 414 LOGGER.trace("No request body detected"); 415 } else { 416 LOGGER.trace("Received createObject with a request body and content type \"{}\"", contentTypeString); 417 418 if ((result instanceof Container) 419 && isRdfContentType(contentTypeString)) { 420 replaceResourceWithStream(result, requestBodyStream, contentType, resourceTriples); 421 } else if (result instanceof FedoraBinary) { 422 LOGGER.trace("Created a datastream and have a binary payload."); 423 424 replaceResourceBinaryWithStream((FedoraBinary) result, 425 requestBodyStream, contentDisposition, requestContentType, checksum); 426 427 } else if (contentTypeString.equals(contentTypeSPARQLUpdate)) { 428 LOGGER.trace("Found SPARQL-Update content, applying.."); 429 patchResourcewithSparql(result, IOUtils.toString(requestBodyStream), resourceTriples); 430 } else { 431 if (requestBodyStream.read() != -1) { 432 throw new ClientErrorException("Invalid Content Type " + contentTypeString, UNSUPPORTED_MEDIA_TYPE); 433 } 434 } 435 } 436 437 try { 438 session.save(); 439 } catch (final RepositoryException e) { 440 throw new RepositoryRuntimeException(e); 441 } 442 443 LOGGER.debug("Finished creating resource with path: {}", newObjectPath); 444 445 addCacheControlHeaders(servletResponse, result, session); 446 447 final URI location = getUri(result); 448 449 addResourceLinkHeaders(result, true); 450 451 return created(location).entity(location.toString()).build(); 452 453 } 454 455 @Override 456 protected void addResourceHttpHeaders(final FedoraResource resource) { 457 super.addResourceHttpHeaders(resource); 458 459 if (getCurrentTransactionId(session) != null) { 460 final String canonical = translator().reverse() 461 .convert(resource) 462 .toString() 463 .replaceFirst("/tx:[^/]+", ""); 464 465 466 servletResponse.addHeader("Link", "<" + canonical + ">;rel=\"canonical\""); 467 468 } 469 470 addOptionsHttpHeaders(); 471 } 472 473 @Override 474 protected String externalPath() { 475 return externalPath; 476 } 477 478 private void addOptionsHttpHeaders() { 479 final String options; 480 481 if (resource() instanceof FedoraBinary) { 482 options = "DELETE,HEAD,GET,PUT,OPTIONS"; 483 484 } else if (resource() instanceof NonRdfSourceDescription) { 485 options = "MOVE,COPY,DELETE,POST,HEAD,GET,PUT,PATCH,OPTIONS"; 486 servletResponse.addHeader("Accept-Patch", contentTypeSPARQLUpdate); 487 488 } else if (resource() instanceof Container) { 489 options = "MOVE,COPY,DELETE,POST,HEAD,GET,PUT,PATCH,OPTIONS"; 490 servletResponse.addHeader("Accept-Patch", contentTypeSPARQLUpdate); 491 492 final String rdfTypes = TURTLE + "," + N3 + "," 493 + N3_ALT2 + "," + RDF_XML + "," + NTRIPLES; 494 servletResponse.addHeader("Accept-Post", rdfTypes + "," + MediaType.MULTIPART_FORM_DATA 495 + "," + contentTypeSPARQLUpdate); 496 } else { 497 options = ""; 498 } 499 500 addResourceLinkHeaders(resource()); 501 502 servletResponse.addHeader("Allow", options); 503 } 504 505 private void addResourceLinkHeaders(final FedoraResource resource) { 506 addResourceLinkHeaders(resource, false); 507 } 508 509 private void addResourceLinkHeaders(final FedoraResource resource, final boolean includeAnchor) { 510 if (resource instanceof NonRdfSourceDescription) { 511 final URI uri = getUri(((NonRdfSourceDescription) resource).getDescribedResource()); 512 final Link link = Link.fromUri(uri).rel("describes").build(); 513 servletResponse.addHeader("Link", link.toString()); 514 } else if (resource instanceof FedoraBinary) { 515 final URI uri = getUri(((FedoraBinary) resource).getDescription()); 516 final Link.Builder builder = Link.fromUri(uri).rel("describedby"); 517 518 if (includeAnchor) { 519 builder.param("anchor", getUri(resource).toString()); 520 } 521 servletResponse.addHeader("Link", builder.build().toString()); 522 } 523 524 525 } 526 527 private String getRequestedObjectType(final MediaType requestContentType, 528 final ContentDisposition contentDisposition) { 529 530 if (requestContentType != null) { 531 final String s = requestContentType.toString(); 532 if (!s.equals(contentTypeSPARQLUpdate) && !isRdfContentType(s) || s.equals(TEXT_PLAIN)) { 533 return FEDORA_BINARY; 534 } 535 } 536 537 if (contentDisposition != null && contentDisposition.getType().equals("attachment")) { 538 return FEDORA_BINARY; 539 } 540 541 return FEDORA_CONTAINER; 542 } 543 544 private FedoraResource createFedoraResource(final String path, 545 final MediaType requestContentType, 546 final ContentDisposition contentDisposition) { 547 final String objectType = getRequestedObjectType(requestContentType, contentDisposition); 548 549 final FedoraResource result; 550 551 if (objectType.equals(FEDORA_BINARY)) { 552 result = binaryService.findOrCreate(session, path); 553 } else { 554 result = containerService.findOrCreate(session, path); 555 } 556 557 return result; 558 } 559 560 @Override 561 protected Session session() { 562 return session; 563 } 564 565 private String mintNewPid(final String slug) { 566 String pid; 567 568 if (slug != null && !slug.isEmpty()) { 569 pid = slug; 570 } else { 571 pid = pidMinter.mintPid(); 572 } 573 // reverse translate the proffered or created identifier 574 LOGGER.trace("Using external identifier {} to create new resource.", pid); 575 LOGGER.trace("Using prefixed external identifier {} to create new resource.", uriInfo.getBaseUri() + "/" 576 + pid); 577 578 final URI newResourceUri = uriInfo.getAbsolutePathBuilder().clone().path(FedoraLdp.class) 579 .resolveTemplate("path", pid, false).build(); 580 581 pid = translator().asString(createResource(newResourceUri.toString())); 582 try { 583 pid = URLDecoder.decode(pid, "UTF-8"); 584 } catch (UnsupportedEncodingException e) { 585 // noop 586 } 587 // remove leading slash left over from translation 588 LOGGER.trace("Using internal identifier {} to create new resource.", pid); 589 590 if (nodeService.exists(session, pid)) { 591 LOGGER.trace("Resource with path {} already exists; minting new path instead", pid); 592 return mintNewPid(null); 593 } 594 595 return pid; 596 } 597 598}