001/* 002 * Licensed to DuraSpace under one or more contributor license agreements. 003 * See the NOTICE file distributed with this work for additional information 004 * regarding copyright ownership. 005 * 006 * DuraSpace licenses this file to you under the Apache License, 007 * Version 2.0 (the "License"); you may not use this file except in 008 * compliance with the License. You may obtain a copy of the License at 009 * 010 * http://www.apache.org/licenses/LICENSE-2.0 011 * 012 * Unless required by applicable law or agreed to in writing, software 013 * distributed under the License is distributed on an "AS IS" BASIS, 014 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 015 * See the License for the specific language governing permissions and 016 * limitations under the License. 017 */ 018package org.fcrepo.http.api; 019 020 021import static com.google.common.base.Strings.nullToEmpty; 022import static java.nio.charset.StandardCharsets.UTF_8; 023import static javax.ws.rs.core.MediaType.TEXT_HTML; 024import static javax.ws.rs.core.MediaType.TEXT_PLAIN_TYPE; 025import static javax.ws.rs.core.MediaType.WILDCARD; 026import static javax.ws.rs.core.HttpHeaders.ACCEPT; 027import static javax.ws.rs.core.HttpHeaders.CONTENT_DISPOSITION; 028import static javax.ws.rs.core.HttpHeaders.CONTENT_TYPE; 029import static javax.ws.rs.core.HttpHeaders.LINK; 030import static javax.ws.rs.core.Response.created; 031import static javax.ws.rs.core.Response.noContent; 032import static javax.ws.rs.core.Response.notAcceptable; 033import static javax.ws.rs.core.Response.ok; 034import static javax.ws.rs.core.Response.temporaryRedirect; 035import static javax.ws.rs.core.Response.Status.BAD_REQUEST; 036import static javax.ws.rs.core.Response.Status.CONFLICT; 037import static javax.ws.rs.core.Response.Status.FORBIDDEN; 038import static javax.ws.rs.core.Response.Status.UNSUPPORTED_MEDIA_TYPE; 039import static javax.ws.rs.core.Variant.mediaTypes; 040import static org.apache.commons.lang3.StringUtils.isBlank; 041import static org.apache.http.HttpStatus.SC_BAD_REQUEST; 042import static org.apache.jena.rdf.model.ResourceFactory.createResource; 043import static org.apache.jena.riot.WebContent.contentTypeSPARQLUpdate; 044import static org.apache.jena.atlas.web.ContentType.create; 045import static org.apache.jena.riot.WebContent.ctTextPlain; 046import static org.apache.jena.riot.WebContent.ctTextCSV; 047import static org.apache.jena.riot.WebContent.ctSPARQLUpdate; 048import static org.apache.jena.riot.WebContent.matchContentType; 049import static org.fcrepo.http.commons.domain.RDFMediaType.JSON_LD; 050import static org.fcrepo.http.commons.domain.RDFMediaType.N3; 051import static org.fcrepo.http.commons.domain.RDFMediaType.N3_WITH_CHARSET; 052import static org.fcrepo.http.commons.domain.RDFMediaType.N3_ALT2; 053import static org.fcrepo.http.commons.domain.RDFMediaType.N3_ALT2_WITH_CHARSET; 054import static org.fcrepo.http.commons.domain.RDFMediaType.NTRIPLES; 055import static org.fcrepo.http.commons.domain.RDFMediaType.RDF_XML; 056import static org.fcrepo.http.commons.domain.RDFMediaType.TEXT_PLAIN_WITH_CHARSET; 057import static org.fcrepo.http.commons.domain.RDFMediaType.TEXT_HTML_WITH_CHARSET; 058import static org.fcrepo.http.commons.domain.RDFMediaType.TURTLE; 059import static org.fcrepo.http.commons.domain.RDFMediaType.TURTLE_WITH_CHARSET; 060import static org.fcrepo.http.commons.domain.RDFMediaType.TURTLE_X; 061import static org.fcrepo.kernel.api.FedoraTypes.FEDORA_BINARY; 062import static org.fcrepo.kernel.api.FedoraTypes.FEDORA_CONTAINER; 063import static org.fcrepo.kernel.api.FedoraTypes.FEDORA_PAIRTREE; 064import static org.fcrepo.kernel.api.RdfLexicon.LDP_NAMESPACE; 065import static org.slf4j.LoggerFactory.getLogger; 066 067import java.io.IOException; 068import java.io.InputStream; 069import java.io.UnsupportedEncodingException; 070import java.net.URI; 071import java.net.URLDecoder; 072import java.util.Arrays; 073import java.util.Collection; 074import java.util.List; 075import java.util.Map; 076import java.util.stream.Collectors; 077import javax.annotation.PostConstruct; 078import javax.inject.Inject; 079import javax.ws.rs.BadRequestException; 080import javax.ws.rs.ClientErrorException; 081import javax.ws.rs.Consumes; 082import javax.ws.rs.DELETE; 083import javax.ws.rs.GET; 084import javax.ws.rs.HEAD; 085import javax.ws.rs.HeaderParam; 086import javax.ws.rs.OPTIONS; 087import javax.ws.rs.POST; 088import javax.ws.rs.PUT; 089import javax.ws.rs.Path; 090import javax.ws.rs.PathParam; 091import javax.ws.rs.Produces; 092import javax.ws.rs.core.HttpHeaders; 093import javax.ws.rs.core.Link; 094import javax.ws.rs.core.MediaType; 095import javax.ws.rs.core.Response; 096import javax.ws.rs.core.UriBuilderException; 097import javax.ws.rs.core.Variant.VariantListBuilder; 098 099import org.apache.commons.io.IOUtils; 100import org.apache.commons.lang3.StringUtils; 101 102import org.fcrepo.http.api.PathLockManager.AcquiredLock; 103import org.fcrepo.http.commons.domain.ContentLocation; 104import org.fcrepo.http.commons.domain.PATCH; 105import org.fcrepo.http.commons.responses.RdfNamespacedStream; 106import org.apache.jena.atlas.web.ContentType; 107import org.fcrepo.kernel.api.RdfStream; 108import org.fcrepo.kernel.api.exception.AccessDeniedException; 109import org.fcrepo.kernel.api.exception.CannotCreateResourceException; 110import org.fcrepo.kernel.api.exception.InsufficientStorageException; 111import org.fcrepo.kernel.api.exception.InvalidChecksumException; 112import org.fcrepo.kernel.api.exception.MalformedRdfException; 113import org.fcrepo.kernel.api.exception.PathNotFoundRuntimeException; 114import org.fcrepo.kernel.api.exception.RepositoryRuntimeException; 115import org.fcrepo.kernel.api.models.Container; 116import org.fcrepo.kernel.api.models.FedoraBinary; 117import org.fcrepo.kernel.api.models.FedoraResource; 118import org.fcrepo.kernel.api.models.NonRdfSourceDescription; 119import org.fcrepo.kernel.api.rdf.DefaultRdfStream; 120import org.fcrepo.kernel.api.utils.ContentDigest; 121import org.glassfish.jersey.media.multipart.ContentDisposition; 122import org.slf4j.Logger; 123import org.springframework.context.annotation.Scope; 124 125import com.codahale.metrics.annotation.Timed; 126import com.google.common.annotations.VisibleForTesting; 127import com.google.common.base.Splitter; 128import com.google.common.collect.ImmutableList; 129 130/** 131 * @author cabeer 132 * @author ajs6f 133 * @since 9/25/14 134 */ 135 136@Scope("request") 137@Path("/{path: .*}") 138public class FedoraLdp extends ContentExposingResource { 139 140 private static final Logger LOGGER = getLogger(FedoraLdp.class); 141 142 private static final Splitter.MapSplitter RFC3230_SPLITTER = 143 Splitter.on(',').omitEmptyStrings().trimResults(). 144 withKeyValueSeparator(Splitter.on('=').limit(2)); 145 146 static final String INSUFFICIENT_SPACE_IDENTIFYING_MESSAGE = "No space left on device"; 147 148 static final String HTTP_HEADER_ACCEPT_PATCH = "Accept-Patch"; 149 150 @PathParam("path") protected String externalPath; 151 152 @Inject private FedoraHttpConfiguration httpConfiguration; 153 154 /** 155 * Default JAX-RS entry point 156 */ 157 public FedoraLdp() { 158 super(); 159 } 160 161 /** 162 * Create a new FedoraNodes instance for a given path 163 * @param externalPath the external path 164 */ 165 @VisibleForTesting 166 public FedoraLdp(final String externalPath) { 167 this.externalPath = externalPath; 168 } 169 170 /** 171 * Run these actions after initializing this resource 172 */ 173 @PostConstruct 174 public void postConstruct() { 175 setUpJMSInfo(uriInfo, headers); 176 } 177 178 /** 179 * Retrieve the node headers 180 * 181 * @return response 182 */ 183 @HEAD 184 @Timed 185 @Produces({ TURTLE_WITH_CHARSET + ";qs=1.0", JSON_LD + ";qs=0.8", 186 N3_WITH_CHARSET, N3_ALT2_WITH_CHARSET, RDF_XML, NTRIPLES, TEXT_PLAIN_WITH_CHARSET, 187 TURTLE_X, TEXT_HTML_WITH_CHARSET }) 188 public Response head() { 189 LOGGER.info("HEAD for: {}", externalPath); 190 191 checkCacheControlHeaders(request, servletResponse, resource(), session); 192 193 addResourceHttpHeaders(resource()); 194 195 Response.ResponseBuilder builder = ok(); 196 197 if (resource() instanceof FedoraBinary) { 198 final MediaType mediaType = MediaType.valueOf(((FedoraBinary) resource()).getMimeType()); 199 200 if (isExternalBody(mediaType)) { 201 builder = temporaryRedirect(URI.create(mediaType.getParameters().get("URL"))); 202 } 203 204 // we set the content-type explicitly to avoid content-negotiation from getting in the way 205 builder.type(mediaType.toString()); 206 } else { 207 final String accept = headers.getHeaderString(HttpHeaders.ACCEPT); 208 if (accept == null || "*/*".equals(accept)) { 209 builder.type(TURTLE_WITH_CHARSET); 210 } 211 } 212 213 return builder.build(); 214 } 215 216 /** 217 * Outputs information about the supported HTTP methods, etc. 218 * @return the outputs information about the supported HTTP methods, etc. 219 */ 220 @OPTIONS 221 @Timed 222 public Response options() { 223 LOGGER.info("OPTIONS for '{}'", externalPath); 224 addLinkAndOptionsHttpHeaders(); 225 return ok().build(); 226 } 227 228 229 /** 230 * Retrieve the node profile 231 * 232 * @param rangeValue the range value 233 * @return a binary or the triples for the specified node 234 * @throws IOException if IO exception occurred 235 */ 236 @GET 237 @Produces({TURTLE_WITH_CHARSET + ";qs=1.0", JSON_LD + ";qs=0.8", 238 N3_WITH_CHARSET, N3_ALT2_WITH_CHARSET, RDF_XML, NTRIPLES, TEXT_PLAIN_WITH_CHARSET, 239 TURTLE_X, TEXT_HTML_WITH_CHARSET}) 240 public Response getResource(@HeaderParam("Range") final String rangeValue) throws IOException { 241 checkCacheControlHeaders(request, servletResponse, resource(), session); 242 243 LOGGER.info("GET resource '{}'", externalPath); 244 final AcquiredLock readLock = lockManager.lockForRead(resource().getPath()); 245 try (final RdfStream rdfStream = new DefaultRdfStream(asNode(resource()))) { 246 247 // If requesting a binary, check the mime-type if "Accept:" header is present. 248 // (This needs to be done before setting up response headers, as getContent 249 // returns a response - so changing headers after that won't work so nicely.) 250 final ImmutableList<MediaType> acceptableMediaTypes = ImmutableList.copyOf(headers 251 .getAcceptableMediaTypes()); 252 253 if (resource() instanceof FedoraBinary && acceptableMediaTypes.size() > 0) { 254 final MediaType mediaType = MediaType.valueOf(((FedoraBinary) resource()).getMimeType()); 255 256 if (!acceptableMediaTypes.stream().anyMatch(t -> t.isCompatible(mediaType))) { 257 return notAcceptable(VariantListBuilder.newInstance().mediaTypes(mediaType).build()).build(); 258 } 259 } 260 261 addResourceHttpHeaders(resource()); 262 return getContent(rangeValue, getChildrenLimit(), rdfStream); 263 } finally { 264 readLock.release(); 265 } 266 } 267 268 private int getChildrenLimit() { 269 final List<String> acceptHeaders = headers.getRequestHeader(ACCEPT); 270 if (acceptHeaders != null && acceptHeaders.size() > 0) { 271 final List<String> accept = Arrays.asList(acceptHeaders.get(0).split(",")); 272 if (accept.contains(TEXT_HTML)) { 273 // Magic number '100' is tied to common-metadata.vsl display of ellipses 274 return 100; 275 } 276 } 277 278 final List<String> limits = headers.getRequestHeader("Limit"); 279 if (null != limits && limits.size() > 0) { 280 try { 281 return Integer.parseInt(limits.get(0)); 282 283 } catch (final NumberFormatException e) { 284 LOGGER.warn("Invalid 'Limit' header value: {}", limits.get(0)); 285 throw new ClientErrorException("Invalid 'Limit' header value: " + limits.get(0), SC_BAD_REQUEST, e); 286 } 287 } 288 return -1; 289 } 290 291 /** 292 * Deletes an object. 293 * 294 * @return response 295 */ 296 @DELETE 297 @Timed 298 public Response deleteObject() { 299 evaluateRequestPreconditions(request, servletResponse, resource(), session); 300 301 LOGGER.info("Delete resource '{}'", externalPath); 302 303 final AcquiredLock lock = lockManager.lockForDelete(resource().getPath()); 304 305 try { 306 resource().delete(); 307 session.commit(); 308 return noContent().build(); 309 } finally { 310 lock.release(); 311 } 312 } 313 314 /** 315 * Create a resource at a specified path, or replace triples with provided RDF. 316 * 317 * @param requestContentType the request content type 318 * @param requestBodyStream the request body stream 319 * @param contentDisposition the content disposition value 320 * @param ifMatch the if-match value 321 * @param link the link value 322 * @param digest the digest header 323 * @return 204 324 * @throws InvalidChecksumException if invalid checksum exception occurred 325 * @throws MalformedRdfException if malformed rdf exception occurred 326 */ 327 @PUT 328 @Consumes 329 @Timed 330 public Response createOrReplaceObjectRdf( 331 @HeaderParam(CONTENT_TYPE) final MediaType requestContentType, 332 @ContentLocation final InputStream requestBodyStream, 333 @HeaderParam(CONTENT_DISPOSITION) final ContentDisposition contentDisposition, 334 @HeaderParam("If-Match") final String ifMatch, 335 @HeaderParam(LINK) final String link, 336 @HeaderParam("Digest") final String digest) 337 throws InvalidChecksumException, MalformedRdfException { 338 339 checkLinkForLdpResourceCreation(link); 340 341 final FedoraResource resource; 342 343 final String path = toPath(translator(), externalPath); 344 345 final AcquiredLock lock = lockManager.lockForWrite(path, session.getFedoraSession(), nodeService); 346 347 try { 348 349 final Collection<String> checksums = parseDigestHeader(digest); 350 351 final MediaType contentType = getSimpleContentType(requestContentType); 352 353 354 if (nodeService.exists(session.getFedoraSession(), path)) { 355 resource = resource(); 356 } else { 357 final MediaType effectiveContentType 358 = requestBodyStream == null || requestContentType == null ? null : contentType; 359 resource = createFedoraResource(path, effectiveContentType, contentDisposition); 360 } 361 362 363 if (httpConfiguration.putRequiresIfMatch() && StringUtils.isBlank(ifMatch) && !resource.isNew()) { 364 throw new ClientErrorException("An If-Match header is required", 428); 365 } 366 367 evaluateRequestPreconditions(request, servletResponse, resource, session); 368 final boolean created = resource.isNew(); 369 370 try (final RdfStream resourceTriples = 371 created ? new DefaultRdfStream(asNode(resource())) : getResourceTriples()) { 372 373 LOGGER.info("PUT resource '{}'", externalPath); 374 if (resource instanceof FedoraBinary) { 375 replaceResourceBinaryWithStream((FedoraBinary) resource, 376 requestBodyStream, contentDisposition, requestContentType, checksums); 377 } else if (isRdfContentType(contentType.toString())) { 378 replaceResourceWithStream(resource, requestBodyStream, contentType, resourceTriples); 379 } else if (!created) { 380 boolean emptyRequest = true; 381 try { 382 emptyRequest = requestBodyStream.read() == -1; 383 } catch (final IOException ex) { 384 LOGGER.debug("Error checking for request body content", ex); 385 } 386 387 if (requestContentType == null && emptyRequest) { 388 throw new ClientErrorException("Resource Already Exists", CONFLICT); 389 } 390 throw new ClientErrorException("Invalid Content Type " + requestContentType, 391 UNSUPPORTED_MEDIA_TYPE); 392 } 393 } catch (final Exception e) { 394 checkForInsufficientStorageException(e, e); 395 } 396 397 session.commit(); 398 return createUpdateResponse(resource, created); 399 400 } finally { 401 lock.release(); 402 } 403 } 404 405 /** 406 * Update an object using SPARQL-UPDATE 407 * 408 * @param requestBodyStream the request body stream 409 * @return 201 410 * @throws IOException if IO exception occurred 411 */ 412 @PATCH 413 @Consumes({contentTypeSPARQLUpdate}) 414 @Timed 415 public Response updateSparql(@ContentLocation final InputStream requestBodyStream) 416 throws IOException { 417 418 if (null == requestBodyStream) { 419 throw new BadRequestException("SPARQL-UPDATE requests must have content!"); 420 } 421 422 if (resource() instanceof FedoraBinary) { 423 throw new BadRequestException(resource().getPath() + " is not a valid object to receive a PATCH"); 424 } 425 426 final AcquiredLock lock = lockManager.lockForWrite(resource().getPath(), session.getFedoraSession(), 427 nodeService); 428 429 try { 430 final String requestBody = IOUtils.toString(requestBodyStream, UTF_8); 431 if (isBlank(requestBody)) { 432 throw new BadRequestException("SPARQL-UPDATE requests must have content!"); 433 } 434 435 evaluateRequestPreconditions(request, servletResponse, resource(), session); 436 437 try (final RdfStream resourceTriples = 438 resource().isNew() ? new DefaultRdfStream(asNode(resource())) : getResourceTriples()) { 439 LOGGER.info("PATCH for '{}'", externalPath); 440 patchResourcewithSparql(resource(), requestBody, resourceTriples); 441 } 442 session.commit(); 443 444 addCacheControlHeaders(servletResponse, resource().getDescription(), session); 445 446 return noContent().build(); 447 } catch (final IllegalArgumentException iae) { 448 throw new BadRequestException(iae.getMessage()); 449 } catch (final AccessDeniedException e) { 450 throw e; 451 } catch ( final RuntimeException ex ) { 452 final Throwable cause = ex.getCause(); 453 if (cause instanceof PathNotFoundRuntimeException) { 454 // the sparql update referred to a repository resource that doesn't exist 455 throw new BadRequestException(cause.getMessage()); 456 } 457 throw ex; 458 } finally { 459 lock.release(); 460 } 461 } 462 463 /** 464 * Creates a new object. 465 * 466 * This originally used application/octet-stream;qs=1001 as a workaround 467 * for JERSEY-2636, to ensure requests without a Content-Type get routed here. 468 * This qs value does not parse with newer versions of Jersey, as qs values 469 * must be between 0 and 1. We use qs=1.000 to mark where this historical 470 * anomaly had been. 471 * 472 * @param contentDisposition the content Disposition value 473 * @param requestContentType the request content type 474 * @param slug the slug value 475 * @param requestBodyStream the request body stream 476 * @param link the link value 477 * @param digest the digest header 478 * @return 201 479 * @throws InvalidChecksumException if invalid checksum exception occurred 480 * @throws IOException if IO exception occurred 481 * @throws MalformedRdfException if malformed rdf exception occurred 482 */ 483 @POST 484 @Consumes({MediaType.APPLICATION_OCTET_STREAM + ";qs=1.000", WILDCARD}) 485 @Timed 486 @Produces({TURTLE_WITH_CHARSET + ";qs=1.0", JSON_LD + ";qs=0.8", 487 N3_WITH_CHARSET, N3_ALT2_WITH_CHARSET, RDF_XML, NTRIPLES, TEXT_PLAIN_WITH_CHARSET, 488 TURTLE_X, TEXT_HTML_WITH_CHARSET, "*/*"}) 489 public Response createObject(@HeaderParam(CONTENT_DISPOSITION) final ContentDisposition contentDisposition, 490 @HeaderParam(CONTENT_TYPE) final MediaType requestContentType, 491 @HeaderParam("Slug") final String slug, 492 @ContentLocation final InputStream requestBodyStream, 493 @HeaderParam(LINK) final String link, 494 @HeaderParam("Digest") final String digest) 495 throws InvalidChecksumException, IOException, MalformedRdfException { 496 497 checkLinkForLdpResourceCreation(link); 498 499 if (!(resource() instanceof Container)) { 500 throw new ClientErrorException("Object cannot have child nodes", CONFLICT); 501 } else if (resource().hasType(FEDORA_PAIRTREE)) { 502 throw new ClientErrorException("Objects cannot be created under pairtree nodes", FORBIDDEN); 503 } 504 505 final MediaType contentType = getSimpleContentType(requestContentType); 506 507 final String contentTypeString = contentType.toString(); 508 509 final String newObjectPath = mintNewPid(slug); 510 511 final AcquiredLock lock = lockManager.lockForWrite(newObjectPath, session.getFedoraSession(), nodeService); 512 513 try { 514 515 final Collection<String> checksum = parseDigestHeader(digest); 516 517 LOGGER.info("Ingest with path: {}", newObjectPath); 518 519 final MediaType effectiveContentType 520 = requestBodyStream == null || requestContentType == null ? null : contentType; 521 resource = createFedoraResource(newObjectPath, effectiveContentType, contentDisposition); 522 523 try (final RdfStream resourceTriples = 524 resource.isNew() ? new DefaultRdfStream(asNode(resource())) : getResourceTriples()) { 525 526 if (requestBodyStream == null) { 527 LOGGER.trace("No request body detected"); 528 } else { 529 LOGGER.trace("Received createObject with a request body and content type \"{}\"", 530 contentTypeString); 531 532 if ((resource instanceof Container) && isRdfContentType(contentTypeString)) { 533 replaceResourceWithStream(resource, requestBodyStream, contentType, resourceTriples); 534 } else if (resource instanceof FedoraBinary) { 535 LOGGER.trace("Created a datastream and have a binary payload."); 536 replaceResourceBinaryWithStream((FedoraBinary) resource, 537 requestBodyStream, contentDisposition, requestContentType, checksum); 538 539 } else if (contentTypeString.equals(contentTypeSPARQLUpdate)) { 540 LOGGER.trace("Found SPARQL-Update content, applying.."); 541 patchResourcewithSparql(resource, IOUtils.toString(requestBodyStream, UTF_8), resourceTriples); 542 } else { 543 if (requestBodyStream.read() != -1) { 544 throw new ClientErrorException("Invalid Content Type " + contentTypeString, 545 UNSUPPORTED_MEDIA_TYPE); 546 } 547 } 548 } 549 session.commit(); 550 } catch (final Exception e) { 551 checkForInsufficientStorageException(e, e); 552 } 553 554 LOGGER.debug("Finished creating resource with path: {}", newObjectPath); 555 return createUpdateResponse(resource, true); 556 } finally { 557 lock.release(); 558 } 559 } 560 561 /** 562 * @param rootThrowable The original throwable 563 * @param throwable The throwable under direct scrutiny. 564 */ 565 private void checkForInsufficientStorageException(final Throwable rootThrowable, final Throwable throwable) 566 throws InvalidChecksumException { 567 final String message = throwable.getMessage(); 568 if (throwable instanceof IOException && message != null && message.contains( 569 INSUFFICIENT_SPACE_IDENTIFYING_MESSAGE)) { 570 throw new InsufficientStorageException(throwable.getMessage(), rootThrowable); 571 } 572 573 if (throwable.getCause() != null) { 574 checkForInsufficientStorageException(rootThrowable, throwable.getCause()); 575 } 576 577 if (rootThrowable instanceof InvalidChecksumException) { 578 throw (InvalidChecksumException) rootThrowable; 579 } else if (rootThrowable instanceof RuntimeException) { 580 throw (RuntimeException) rootThrowable; 581 } else { 582 throw new RepositoryRuntimeException(rootThrowable); 583 } 584 } 585 586 /** 587 * Create the appropriate response after a create or update request is processed. When a resource is created, 588 * examine the Prefer and Accept headers to determine whether to include a representation. By default, the 589 * URI for the created resource is return as plain text. If a minimal response is requested, then no body is 590 * returned. If a non-minimal return is requested, return the RDF for the created resource in the appropriate 591 * RDF serialization. 592 * 593 * @param resource The created or updated Fedora resource. 594 * @param created True for a newly-created resource, false for an updated resource. 595 * @return 204 No Content (for updated resources), 201 Created (for created resources) including the resource 596 * URI or content depending on Prefer headers. 597 */ 598 @SuppressWarnings("resource") 599 private Response createUpdateResponse(final FedoraResource resource, final boolean created) { 600 addCacheControlHeaders(servletResponse, resource, session); 601 addResourceLinkHeaders(resource, created); 602 if (!created) { 603 return noContent().build(); 604 } 605 606 final URI location = getUri(resource); 607 final Response.ResponseBuilder builder = created(location); 608 609 if (prefer == null || !prefer.hasReturn()) { 610 final MediaType acceptablePlainText = acceptabePlainTextMediaType(); 611 if (acceptablePlainText != null) { 612 return builder.type(acceptablePlainText).entity(location.toString()).build(); 613 } 614 return notAcceptable(mediaTypes(TEXT_PLAIN_TYPE).build()).build(); 615 } else if (prefer.getReturn().getValue().equals("minimal")) { 616 return builder.build(); 617 } else { 618 servletResponse.addHeader("Vary", "Accept, Range, Accept-Encoding, Accept-Language"); 619 if (prefer != null) { 620 prefer.getReturn().addResponseHeaders(servletResponse); 621 } 622 final RdfNamespacedStream rdfStream = new RdfNamespacedStream( 623 new DefaultRdfStream(asNode(resource()), getResourceTriples()), 624 session().getFedoraSession().getNamespaces()); 625 return builder.entity(rdfStream).build(); 626 } 627 } 628 629 /** 630 * Returns an acceptable plain text media type if possible, or null if not. 631 */ 632 private MediaType acceptabePlainTextMediaType() { 633 final List<MediaType> acceptable = headers.getAcceptableMediaTypes(); 634 if (acceptable == null || acceptable.size() == 0) { 635 return TEXT_PLAIN_TYPE; 636 } 637 for (final MediaType type : acceptable ) { 638 if (type.isWildcardType() || (type.isCompatible(TEXT_PLAIN_TYPE) && type.isWildcardSubtype())) { 639 return TEXT_PLAIN_TYPE; 640 } else if (type.isCompatible(TEXT_PLAIN_TYPE)) { 641 return type; 642 } 643 } 644 return null; 645 } 646 647 @Override 648 protected void addResourceHttpHeaders(final FedoraResource resource) { 649 super.addResourceHttpHeaders(resource); 650 651 if (session.isBatchSession()) { 652 final String canonical = translator().reverse() 653 .convert(resource) 654 .toString() 655 .replaceFirst("/tx:[^/]+", ""); 656 657 658 servletResponse.addHeader(LINK, "<" + canonical + ">;rel=\"canonical\""); 659 660 } 661 662 addLinkAndOptionsHttpHeaders(); 663 } 664 665 @Override 666 protected String externalPath() { 667 return externalPath; 668 } 669 670 private void addLinkAndOptionsHttpHeaders() { 671 // Add Link headers 672 addResourceLinkHeaders(resource()); 673 674 // Add Options headers 675 final String options; 676 if (resource() instanceof FedoraBinary) { 677 options = "DELETE,HEAD,GET,PUT,OPTIONS"; 678 679 } else if (resource() instanceof NonRdfSourceDescription) { 680 options = "HEAD,GET,DELETE,PUT,PATCH,OPTIONS"; 681 servletResponse.addHeader(HTTP_HEADER_ACCEPT_PATCH, contentTypeSPARQLUpdate); 682 683 } else if (resource() instanceof Container) { 684 options = "MOVE,COPY,DELETE,POST,HEAD,GET,PUT,PATCH,OPTIONS"; 685 servletResponse.addHeader(HTTP_HEADER_ACCEPT_PATCH, contentTypeSPARQLUpdate); 686 687 final String rdfTypes = TURTLE + "," + N3 + "," + N3_ALT2 + "," 688 + RDF_XML + "," + NTRIPLES + "," + JSON_LD; 689 servletResponse.addHeader("Accept-Post", rdfTypes + "," + MediaType.MULTIPART_FORM_DATA 690 + "," + contentTypeSPARQLUpdate); 691 } else { 692 options = ""; 693 } 694 695 servletResponse.addHeader("Allow", options); 696 } 697 698 private static String getRequestedObjectType(final MediaType requestContentType, 699 final ContentDisposition contentDisposition) { 700 701 if (requestContentType != null) { 702 final ContentType ctRequest = create(requestContentType.toString()); 703 704 // Text files and CSV files are not considered RDF to Fedora, though CSV is a valid 705 // RDF type to Jena (although deprecated). SPARQL updates are done on containers. 706 if (matchContentType(ctRequest, ctTextPlain) || matchContentType(ctRequest, ctTextCSV) || 707 !isRdfContentType(requestContentType.toString()) && !matchContentType(ctRequest, ctSPARQLUpdate)) { 708 return FEDORA_BINARY; 709 } 710 } 711 712 if (contentDisposition != null && contentDisposition.getType().equals("attachment")) { 713 return FEDORA_BINARY; 714 } 715 716 return FEDORA_CONTAINER; 717 } 718 719 private FedoraResource createFedoraResource(final String path, 720 final MediaType requestContentType, 721 final ContentDisposition contentDisposition) { 722 final String objectType = getRequestedObjectType(requestContentType, contentDisposition); 723 724 final FedoraResource result; 725 726 if (objectType.equals(FEDORA_BINARY)) { 727 result = binaryService.findOrCreate(session.getFedoraSession(), path); 728 } else { 729 result = containerService.findOrCreate(session.getFedoraSession(), path); 730 } 731 732 return result; 733 } 734 735 private String mintNewPid(final String slug) { 736 String pid; 737 738 if (slug != null && !slug.isEmpty()) { 739 pid = slug; 740 } else if (pidMinter != null) { 741 pid = pidMinter.get(); 742 } else { 743 pid = defaultPidMinter.get(); 744 } 745 // reverse translate the proffered or created identifier 746 LOGGER.trace("Using external identifier {} to create new resource.", pid); 747 LOGGER.trace("Using prefixed external identifier {} to create new resource.", uriInfo.getBaseUri() + "/" 748 + pid); 749 750 final URI newResourceUri = uriInfo.getAbsolutePathBuilder().clone().path(FedoraLdp.class) 751 .resolveTemplate("path", pid, false).build(); 752 753 pid = translator().asString(createResource(newResourceUri.toString())); 754 try { 755 pid = URLDecoder.decode(pid, "UTF-8"); 756 } catch (final UnsupportedEncodingException e) { 757 // noop 758 } 759 // remove leading slash left over from translation 760 LOGGER.trace("Using internal identifier {} to create new resource.", pid); 761 762 if (nodeService.exists(session.getFedoraSession(), pid)) { 763 LOGGER.trace("Resource with path {} already exists; minting new path instead", pid); 764 return mintNewPid(null); 765 } 766 767 return pid; 768 } 769 770 private static void checkLinkForLdpResourceCreation(final String link) { 771 if (link != null) { 772 try { 773 final Link linq = Link.valueOf(link); 774 if ("type".equals(linq.getRel()) && (LDP_NAMESPACE + "Resource").equals(linq.getUri().toString())) { 775 LOGGER.info("Unimplemented LDPR creation requested with header link: {}", link); 776 throw new CannotCreateResourceException("LDPR creation not implemented"); 777 } 778 } catch (final RuntimeException e) { 779 if (e instanceof IllegalArgumentException | e instanceof UriBuilderException) { 780 throw new ClientErrorException("Invalid link specified: " + link, BAD_REQUEST); 781 } 782 throw e; 783 } 784 } 785 } 786 787 /** 788 * Parse the RFC-3230 Digest response header value. Look for a 789 * sha1 checksum and return it as a urn, if missing or malformed 790 * an empty string is returned. 791 * @param digest The Digest header value 792 * @return the sha1 checksum value 793 * @throws InvalidChecksumException if an unsupported digest is used 794 */ 795 private static Collection<String> parseDigestHeader(final String digest) throws InvalidChecksumException { 796 try { 797 final Map<String,String> digestPairs = RFC3230_SPLITTER.split(nullToEmpty(digest)); 798 final boolean allSupportedAlgorithms = digestPairs.keySet().stream().allMatch( 799 ContentDigest.DIGEST_ALGORITHM::isSupportedAlgorithm); 800 801 // If you have one or more digests that are all valid or no digests. 802 if (digestPairs.isEmpty() || allSupportedAlgorithms) { 803 return digestPairs.entrySet().stream() 804 .filter(entry -> ContentDigest.DIGEST_ALGORITHM.isSupportedAlgorithm(entry.getKey())) 805 .map(entry -> ContentDigest.asURI(entry.getKey(), entry.getValue()).toString()) 806 .collect(Collectors.toSet()); 807 } else { 808 throw new InvalidChecksumException(String.format("Unsupported Digest Algorithm: %1$s", digest)); 809 } 810 } catch (final RuntimeException e) { 811 if (e instanceof IllegalArgumentException) { 812 throw new ClientErrorException("Invalid Digest header: " + digest + "\n", BAD_REQUEST); 813 } 814 throw e; 815 } 816 } 817}