All Downloads are FREE. Search and download functionalities are using the official Maven repository.

org.fcrepo.http.api.FedoraLdp Maven / Gradle / Ivy

Go to download

The Fedora Commons repository HTTP API: Provides a RESTful HTTP API to interact with the Fedora Commons repository.

The newest version!
/*
 * The contents of this file are subject to the license and copyright
 * detailed in the LICENSE and NOTICE files at the root of the source
 * tree.
 */
package org.fcrepo.http.api;

import static com.google.common.base.Strings.isNullOrEmpty;
import static java.nio.charset.StandardCharsets.UTF_8;
import static javax.ws.rs.core.HttpHeaders.CONTENT_DISPOSITION;
import static javax.ws.rs.core.HttpHeaders.CONTENT_TYPE;
import static javax.ws.rs.core.HttpHeaders.LINK;
import static javax.ws.rs.core.HttpHeaders.LOCATION;
import static javax.ws.rs.core.MediaType.TEXT_HTML_TYPE;
import static javax.ws.rs.core.MediaType.WILDCARD;
import static javax.ws.rs.core.Response.noContent;
import static javax.ws.rs.core.Response.notAcceptable;
import static javax.ws.rs.core.Response.ok;
import static javax.ws.rs.core.Response.status;
import static javax.ws.rs.core.Response.temporaryRedirect;
import static javax.ws.rs.core.Response.Status.BAD_REQUEST;
import static javax.ws.rs.core.Response.Status.FOUND;
import static javax.ws.rs.core.Response.Status.METHOD_NOT_ALLOWED;
import static javax.ws.rs.core.Response.Status.NOT_ACCEPTABLE;
import static org.apache.commons.lang3.StringUtils.isBlank;
import static org.apache.http.HttpStatus.SC_BAD_REQUEST;
import static org.apache.jena.rdf.model.ResourceFactory.createResource;
import static org.apache.jena.riot.WebContent.contentTypeSPARQLUpdate;
import static org.fcrepo.http.commons.domain.RDFMediaType.JSON_LD;
import static org.fcrepo.http.commons.domain.RDFMediaType.N3_ALT2_WITH_CHARSET;
import static org.fcrepo.http.commons.domain.RDFMediaType.N3_WITH_CHARSET;
import static org.fcrepo.http.commons.domain.RDFMediaType.NTRIPLES;
import static org.fcrepo.http.commons.domain.RDFMediaType.RDF_XML;
import static org.fcrepo.http.commons.domain.RDFMediaType.TEXT_HTML_WITH_CHARSET;
import static org.fcrepo.http.commons.domain.RDFMediaType.TEXT_PLAIN_WITH_CHARSET;
import static org.fcrepo.http.commons.domain.RDFMediaType.TURTLE_WITH_CHARSET;
import static org.fcrepo.http.commons.domain.RDFMediaType.TURTLE_TYPE;
import static org.fcrepo.http.commons.domain.RDFMediaType.APPLICATION_OCTET_STREAM_TYPE;

import static org.fcrepo.kernel.api.FedoraTypes.FCR_METADATA;
import static org.fcrepo.kernel.api.RdfLexicon.ARCHIVAL_GROUP;
import static org.fcrepo.kernel.api.RdfLexicon.INTERACTION_MODEL_RESOURCES;
import static org.fcrepo.kernel.api.RdfLexicon.NON_RDF_SOURCE;
import static org.fcrepo.kernel.api.RdfLexicon.VERSIONED_RESOURCE;
import static org.fcrepo.kernel.api.services.VersionService.MEMENTO_RFC_1123_FORMATTER;
import static org.slf4j.LoggerFactory.getLogger;

import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.net.URLDecoder;
import java.time.Instant;
import java.time.format.DateTimeParseException;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Predicate;
import java.util.stream.Collectors;
import java.util.stream.Stream;

import javax.inject.Inject;
import javax.ws.rs.BadRequestException;
import javax.ws.rs.ClientErrorException;
import javax.ws.rs.Consumes;
import javax.ws.rs.DELETE;
import javax.ws.rs.DefaultValue;
import javax.ws.rs.GET;
import javax.ws.rs.HEAD;
import javax.ws.rs.HeaderParam;
import javax.ws.rs.OPTIONS;
import javax.ws.rs.POST;
import javax.ws.rs.PUT;
import javax.ws.rs.Path;
import javax.ws.rs.PathParam;
import javax.ws.rs.Produces;
import javax.ws.rs.QueryParam;
import javax.ws.rs.core.Link;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import javax.ws.rs.core.UriBuilderException;
import javax.ws.rs.core.Variant.VariantListBuilder;

import io.micrometer.core.annotation.Timed;
import org.apache.commons.io.IOUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.jena.rdf.model.Model;
import org.apache.jena.rdf.model.Resource;

import org.fcrepo.http.commons.domain.PATCH;
import org.fcrepo.kernel.api.FedoraTypes;
import org.fcrepo.kernel.api.exception.AccessDeniedException;
import org.fcrepo.kernel.api.exception.CannotCreateResourceException;
import org.fcrepo.kernel.api.exception.GhostNodeException;
import org.fcrepo.kernel.api.exception.InteractionModelViolationException;
import org.fcrepo.kernel.api.exception.InvalidChecksumException;
import org.fcrepo.kernel.api.exception.MalformedRdfException;
import org.fcrepo.kernel.api.exception.MementoDatetimeFormatException;
import org.fcrepo.kernel.api.exception.PathNotFoundException;
import org.fcrepo.kernel.api.exception.PathNotFoundRuntimeException;
import org.fcrepo.kernel.api.exception.UnsupportedAlgorithmException;
import org.fcrepo.kernel.api.identifiers.FedoraId;
import org.fcrepo.kernel.api.models.Binary;
import org.fcrepo.kernel.api.models.Container;
import org.fcrepo.kernel.api.models.ExternalContent;
import org.fcrepo.kernel.api.models.FedoraResource;
import org.fcrepo.kernel.api.models.NonRdfSourceDescription;
import org.fcrepo.kernel.api.models.Tombstone;
import org.fcrepo.kernel.api.services.FixityService;
import org.fcrepo.kernel.api.services.ReplaceBinariesService;
import org.fcrepo.config.DigestAlgorithm;

import org.slf4j.Logger;
import org.springframework.context.annotation.Scope;
import org.springframework.http.ContentDisposition;

import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Splitter;
import com.google.common.collect.ImmutableList;

/**
 * @author cabeer
 * @author ajs6f
 * @since 9/25/14
 */

@Timed
@Scope("request")
@Path("/{path: .*}")
public class FedoraLdp extends ContentExposingResource {

    private static final Logger LOGGER = getLogger(FedoraLdp.class);

    private static final String WANT_DIGEST = "Want-Digest";

    private static final String DIGEST = "Digest";

    private static final MediaType DEFAULT_RDF_CONTENT_TYPE = TURTLE_TYPE;
    private static final MediaType DEFAULT_NON_RDF_CONTENT_TYPE = APPLICATION_OCTET_STREAM_TYPE;

    /**
     * List of RDF_TYPES for comparison, text/plain isn't really an RDF type but it is still accepted.
     */
    private static final List RDF_TYPES = Stream.of(TURTLE_WITH_CHARSET, JSON_LD,
            N3_WITH_CHARSET, N3_ALT2_WITH_CHARSET, RDF_XML, NTRIPLES, TEXT_PLAIN_WITH_CHARSET
            ).map(MediaType::valueOf).collect(Collectors.toList());

    /**
     * This predicate allows comparing a list of accept headers to a list of RDF types.
     * It is needed to account for charset variations.
     */
    private static final Predicate> IS_RDF_TYPE = t -> {
        assert t != null;
        return t.stream()
                .anyMatch(c -> RDF_TYPES.stream().anyMatch(c::isCompatible));
    };

    /**
     * This predicate checks if the list does not have a mediatype that is wildcard
     */
    private static final Predicate> NOT_WILDCARD = t -> {
        assert t != null;
        return t.stream().noneMatch(MediaType::isWildcardType);
    };

    /**
     * This predicate checks if the list does not have a mediatype that is compatible with text html
     */
    private static final Predicate> NOT_HTML =
            t -> t.stream().noneMatch(TEXT_HTML_TYPE::isCompatible);

    private static final VariantListBuilder RDF_VARIANT_BUILDER = VariantListBuilder.newInstance();
    static {
        RDF_TYPES.forEach(t -> RDF_VARIANT_BUILDER.mediaTypes(t).add());
    }

    @PathParam("path") protected String externalPath;

    @Inject
    private FixityService fixityService;

    @Inject
    private FedoraHttpConfiguration httpConfiguration;

    @Inject
    protected ReplaceBinariesService replaceBinariesService;

    /**
     * Default JAX-RS entry point
     */
    public FedoraLdp() {
        super();
    }

    /**
     * Create a new FedoraNodes instance for a given path
     * @param externalPath the external path
     */
    @VisibleForTesting
    public FedoraLdp(final String externalPath) {
        this.externalPath = externalPath;
    }

    /**
     * Retrieve the node headers
     *
     * @param inlineDisposition whether to return a Content-Disposition inline header for a binary
     * @return response
     * @throws UnsupportedAlgorithmException if unsupported digest algorithm occurred
     */
    @HEAD
    @Produces({TURTLE_WITH_CHARSET + ";qs=1.0", JSON_LD + ";qs=0.8",
            N3_WITH_CHARSET, N3_ALT2_WITH_CHARSET, RDF_XML, NTRIPLES, TEXT_PLAIN_WITH_CHARSET,
            TEXT_HTML_WITH_CHARSET, "*/*"})
    public Response head(@DefaultValue("false") @QueryParam("inline") final boolean inlineDisposition)
            throws UnsupportedAlgorithmException {
        LOGGER.info("HEAD for: {}", externalPath);

        final String datetimeHeader = headers.getHeaderString(ACCEPT_DATETIME);
        if (!isBlank(datetimeHeader) && resource(true).isOriginalResource()) {
            return getMemento(datetimeHeader, resource(true), inlineDisposition);
        }

        final ImmutableList acceptableMediaTypes = ImmutableList.copyOf(headers
                .getAcceptableMediaTypes());

        checkCacheControlHeaders(request, servletResponse, resource(), transaction());

        addResourceHttpHeaders(resource(), inlineDisposition);

        Response.ResponseBuilder builder = ok();

        if (resource() instanceof Binary) {
            final Binary binary = (Binary) resource();
            final MediaType mediaType = getBinaryResourceMediaType(binary);

            if (!acceptableMediaTypes.isEmpty()) {
                if (acceptableMediaTypes.stream().noneMatch(t -> t.isCompatible(mediaType))) {
                    return notAcceptable(VariantListBuilder.newInstance().mediaTypes(mediaType).build()).build();
                }
            }

            if (binary.isRedirect()) {
                builder = temporaryRedirect(binary.getExternalURI());
            }

            // we set the content-type explicitly to avoid content-negotiation from getting in the way
            builder.type(mediaType.toString());

            // Respect the Want-Digest header with fixity check
            final String wantDigest = headers.getHeaderString(WANT_DIGEST);
            if (!isNullOrEmpty(wantDigest)) {
                builder.header(DIGEST, handleWantDigestHeader(binary, wantDigest));
            }
        } else {
            if (!acceptableMediaTypes.isEmpty() && NOT_WILDCARD.test(acceptableMediaTypes)) {
                // Accept header is not empty and is not */*
                if (!IS_RDF_TYPE.test(acceptableMediaTypes)) {
                    return notAcceptable(VariantListBuilder.newInstance().mediaTypes().build()).build();
                }
            } else if (acceptableMediaTypes.isEmpty() || !NOT_WILDCARD.test(acceptableMediaTypes)) {
                // If there is no Accept header or it is */*, so default to text/turtle
                builder.type(TURTLE_WITH_CHARSET);
            }
            setVaryAndPreferenceAppliedHeaders(servletResponse, prefer, resource());
        }


        return builder.build();
    }

    /**
     * Outputs information about the supported HTTP methods, etc.
     * @return the outputs information about the supported HTTP methods, etc.
     */
    @OPTIONS
    public Response options() {
        LOGGER.info("OPTIONS for '{}'", externalPath);

        addLinkAndOptionsHttpHeaders(resource());
        return ok().build();
    }


    /**
     * Retrieve the node profile
     *
     * @param rangeValue the range value
     * @param inlineDisposition whether to return a Content-Disposition inline header for a binary
     * @return a binary or the triples for the specified node
     * @throws IOException if IO exception occurred
     * @throws UnsupportedAlgorithmException if unsupported digest algorithm occurred
     */
    @GET
    @Produces({TURTLE_WITH_CHARSET + ";qs=1.0", JSON_LD + ";qs=0.8",
            N3_WITH_CHARSET, N3_ALT2_WITH_CHARSET, RDF_XML, NTRIPLES, TEXT_PLAIN_WITH_CHARSET,
            TEXT_HTML_WITH_CHARSET, "*/*"})
    public Response getResource(
            @HeaderParam("Range") final String rangeValue,
            @DefaultValue("false") @QueryParam("inline") final boolean inlineDisposition)
            throws IOException, UnsupportedAlgorithmException {

        final String datetimeHeader = headers.getHeaderString(ACCEPT_DATETIME);
        if (!isBlank(datetimeHeader) && resource(true).isOriginalResource()) {
            return getMemento(datetimeHeader, resource(true), inlineDisposition);
        }

        checkCacheControlHeaders(request, servletResponse, resource(), transaction());

        final ImmutableList acceptableMediaTypes = ImmutableList.copyOf(headers
                .getAcceptableMediaTypes());

        LOGGER.info("GET resource '{}'", externalPath);
        addResourceHttpHeaders(resource(), inlineDisposition);

        if (resource() instanceof Binary) {
            final Binary binary = (Binary) resource();
            if (!acceptableMediaTypes.isEmpty()) {
                final MediaType mediaType = getBinaryResourceMediaType(resource());

                if (acceptableMediaTypes.stream().noneMatch(t -> t.isCompatible(mediaType))) {
                    return notAcceptable(VariantListBuilder.newInstance().mediaTypes(mediaType).build()).build();
                }
            }

            // Respect the Want-Digest header for fixity check
            final String wantDigest = headers.getHeaderString(WANT_DIGEST);
            if (!isNullOrEmpty(wantDigest)) {
                servletResponse.addHeader(DIGEST, handleWantDigestHeader(binary, wantDigest));
            }

            if (binary.isRedirect()) {
                return temporaryRedirect(binary.getExternalURI()).build();
            } else {
                return getBinaryContent(rangeValue, binary);
            }
        } else {
            if (!acceptableMediaTypes.isEmpty() && NOT_WILDCARD.test(acceptableMediaTypes) &&
                    NOT_HTML.test(acceptableMediaTypes) &&
                    !IS_RDF_TYPE.test(acceptableMediaTypes)) {
                // Accept header is not empty and is not */* and is not text/html and is not a valid RDF type.
                return notAcceptable(RDF_VARIANT_BUILDER.build()).build();
            }
            return getContent(getChildrenLimit(), resource());
        }
    }

    /**
     * Return the location of a requested Memento.
     *
     * @param datetimeHeader The RFC datetime for the Memento.
     * @param resource The fedora resource
     * @param inlineDisposition whether to return binary as Content-Disposition inline
     * @return A 302 Found response or 406 if no mementos.
     */
    private Response getMemento(final String datetimeHeader, final FedoraResource resource,
                                final boolean inlineDisposition) {
        try {
            final Instant mementoDatetime = Instant.from(MEMENTO_RFC_1123_FORMATTER.parse(datetimeHeader));
            final FedoraResource memento = resource.findMementoByDatetime(mementoDatetime);
            final Response builder;
            boolean isRedirect = false;
            if (memento != null) {
                isRedirect = true;
                builder = status(FOUND).header(LOCATION, getUri(memento)).build();
            } else {
                builder = status(NOT_ACCEPTABLE).build();
            }
            addResourceHttpHeaders(resource, inlineDisposition, isRedirect);
            setVaryAndPreferenceAppliedHeaders(servletResponse, prefer, resource);
            return builder;
        } catch (final DateTimeParseException e) {
            throw new MementoDatetimeFormatException("Invalid Accept-Datetime value: " + e.getMessage()
                + ". Please use RFC-1123 date-time format, such as 'Tue, 3 Jun 2008 11:05:30 GMT'", e);
        }
    }

    /**
     * Deletes an object.
     *
     * @return response
     */
    @DELETE
    public Response deleteObject() {
        LOGGER.info("Delete resource '{}'", externalPath);
        if (externalPath.contains("/" + FedoraTypes.FCR_VERSIONS)) {
            handleRequestDisallowedOnMemento();

            return status(METHOD_NOT_ALLOWED).build();
        }

        hasRestrictedPath(externalPath);
        if (resource() instanceof Container) {
            final String depth = headers.getHeaderString("Depth");
            LOGGER.debug("Depth header value is: {}", depth);
            if (depth != null && !depth.equalsIgnoreCase("infinity")) {
                throw new ClientErrorException("Depth header, if present, must be set to 'infinity' for containers",
                        SC_BAD_REQUEST);
            }
        }
        if (resource() instanceof NonRdfSourceDescription && resource().isOriginalResource()) {
            LOGGER.debug("Trying to delete binary description directly.");
            throw new ClientErrorException(
                "NonRDFSource descriptions are removed when their associated NonRDFSource object is removed.",
                METHOD_NOT_ALLOWED);
        }

        try {
            evaluateRequestPreconditions(request, servletResponse, resource(), transaction());

            doInDbTxWithRetry(() -> {
                deleteResourceService.perform(transaction(), resource(), getUserPrincipal());
                transaction().commitIfShortLived();
            });
            return noContent().build();
        } finally {
            transaction().releaseResourceLocksIfShortLived();
        }
    }

    /**
     * Create a resource at a specified path, or replace triples with provided RDF.
     *
     * @param requestContentType the request content type
     * @param requestBodyStream the request body stream
     * @param contentDispositionRaw the content disposition value
     * @param ifMatch the if-match value
     * @param rawLinks the raw link values
     * @param digest the digest header
     * @param overwriteTombstoneRaw the Overwrite-Tombstone header
     * @return 204
     * @throws InvalidChecksumException if invalid checksum exception occurred
     * @throws MalformedRdfException if malformed rdf exception occurred
     * @throws UnsupportedAlgorithmException if an unsupported algorithm exception occurs
     */
    @PUT
    @Consumes
    public Response createOrReplaceObjectRdf(
            @HeaderParam(CONTENT_TYPE) final MediaType requestContentType,
            final InputStream requestBodyStream,
            @HeaderParam(CONTENT_DISPOSITION) final String contentDispositionRaw,
            @HeaderParam("If-Match") final String ifMatch,
            @HeaderParam(LINK) final List rawLinks,
            @HeaderParam("Digest") final String digest,
            @HeaderParam(HTTP_HEADER_OVERWRITE_TOMBSTONE) final String overwriteTombstoneRaw)
            throws InvalidChecksumException, MalformedRdfException, UnsupportedAlgorithmException,
                   PathNotFoundException {
        LOGGER.info("PUT to create resource with ID: {}", externalPath());

        final var overwriteTombstone = Boolean.parseBoolean(overwriteTombstoneRaw);
        if (externalPath.contains("/" + FedoraTypes.FCR_VERSIONS)) {
            handleRequestDisallowedOnMemento();

            return status(METHOD_NOT_ALLOWED).build();
        }

        hasRestrictedPath(externalPath);

        final var transaction = transaction();

        try {
            final List links = unpackLinks(rawLinks);

            // If request is an external binary, verify link header before proceeding
            final ExternalContent extContent = extContentHandlerFactory.createFromLinks(links);

            final String interactionModel = checkInteractionModel(links);

            FedoraResource resource = null;
            final var isTombstoneOverwrite = new AtomicBoolean(false);
            final FedoraId fedoraId = identifierConverter().pathToInternalId(externalPath());
            final boolean resourceExists = doesResourceExist(transaction, fedoraId, true);

            if (resourceExists) {

                if (httpConfiguration.putRequiresIfMatch() && StringUtils.isBlank(ifMatch)) {
                    throw new ClientErrorException("An If-Match header is required", 428);
                }

                resource = resource(overwriteTombstone);
                if (resource instanceof Tombstone) {
                    isTombstoneOverwrite.set(true);
                    resource = ((Tombstone) resource).getDeletedObject();
                }

                final String resInteractionModel = resource.getInteractionModel();
                if (StringUtils.isNoneBlank(resInteractionModel, interactionModel) &&
                        !Objects.equals(resInteractionModel, interactionModel)) {
                    throw new InteractionModelViolationException("Changing the interaction model " + resInteractionModel
                            + " to " + interactionModel + " is not allowed!");
                }
                evaluateRequestPreconditions(request, servletResponse, resource, transaction);
            }

            if (isGhostNode(transaction(), fedoraId)) {
                throw new GhostNodeException("Resource path " + externalPath() + " is an immutable resource.");
            }

            if (!resourceExists && fedoraId.isDescription()) {
                // Can't PUT a description to a non-existent binary.
                final String message;
                if (fedoraId.asBaseId().isRepositoryRoot()) {
                    message = "The root of the repository is not a binary, so /" + FCR_METADATA + " does not exist.";
                } else {
                    message = "Binary at path " + fedoraId.asBaseId().getFullIdPath() + " not found";
                }
                throw new PathNotFoundException(message);
            }

            final var providedContentType = getSimpleContentType(requestContentType);
            final var created = new AtomicBoolean(false);

            if ((resourceExists && resource instanceof Binary) ||
                    (!resourceExists && isBinary(interactionModel,
                                providedContentType,
                                requestBodyStream != null && providedContentType != null,
                                extContent != null))) {
                    ensureArchivalGroupHeaderNotPresentForBinaries(links);

                    final Collection checksums = parseDigestHeader(digest);
                    final var binaryType = requestContentType != null ?
                            requestContentType : DEFAULT_NON_RDF_CONTENT_TYPE;
                    final var contentType = extContent == null ?
                            binaryType.toString() : extContent.getContentType();

                    final String originalFileName;
                    final long contentSize;

                    if (StringUtils.isNotBlank(contentDispositionRaw)) {
                        final var contentDisposition = ContentDisposition.parse(contentDispositionRaw);
                        originalFileName = contentDisposition.getFilename();
                        contentSize = contentDisposition.getSize() == null ? -1L : contentDisposition.getSize();
                    } else {
                        originalFileName = "";
                        contentSize = -1L;
                    }

                    doInDbTx(() -> {
                        if (resourceExists && !(resource() instanceof Tombstone)) {
                            replaceBinariesService.perform(transaction,
                                    getUserPrincipal(),
                                    fedoraId,
                                    originalFileName,
                                    contentType,
                                    checksums,
                                    requestBodyStream,
                                    contentSize,
                                    extContent);
                        } else {
                            createResourceService.perform(transaction,
                                    getUserPrincipal(),
                                    fedoraId,
                                    contentType,
                                    originalFileName,
                                    contentSize,
                                    links,
                                    checksums,
                                    requestBodyStream,
                                    extContent);
                            created.set(true);
                        }
                        transaction.commitIfShortLived();
                    });
                } else {
                    final var contentType = requestContentType != null ? requestContentType : DEFAULT_RDF_CONTENT_TYPE;
                    final Model model = httpRdfService.bodyToInternalModel(fedoraId, requestBodyStream,
                            contentType, identifierConverter(), hasLenientPreferHeader());

                    doInDbTxWithRetry(() -> {
                        if (resourceExists && !(resource() instanceof Tombstone)) {
                            replacePropertiesService.perform(transaction, getUserPrincipal(), fedoraId, model);
                        } else {
                            createResourceService.perform(transaction, getUserPrincipal(), fedoraId, links, model,
                                                          isTombstoneOverwrite.get());
                            created.set(true);
                        }
                        transaction.commitIfShortLived();
                    });
                }

            LOGGER.debug("Finished creating resource with path: {}", externalPath());

            return createUpdateResponse(getFedoraResource(transaction, fedoraId), created.get());
        } finally {
            transaction.releaseResourceLocksIfShortLived();
        }
    }

    /**
     * Update an object using SPARQL-UPDATE
     *
     * @param requestBodyStream the request body stream
     * @return 201
     * @throws IOException if IO exception occurred
     */
    @PATCH
    @Consumes({contentTypeSPARQLUpdate})
    public Response updateSparql(final InputStream requestBodyStream)
            throws IOException {
        if (externalPath.contains("/" + FedoraTypes.FCR_VERSIONS)) {
            handleRequestDisallowedOnMemento();

            return status(METHOD_NOT_ALLOWED).build();
        }

        hasRestrictedPath(externalPath);

        if (null == requestBodyStream) {
            throw new BadRequestException("SPARQL-UPDATE requests must have content!");
        }

        if (resource() instanceof Binary) {
            throw new BadRequestException(resource().getFedoraId().getFullIdPath() +
                    " is not a valid object to receive a PATCH");
        }

        final var transaction = transaction();

        try {
            final String requestBody = IOUtils.toString(requestBodyStream, UTF_8);
            if (isBlank(requestBody)) {
                throw new BadRequestException("SPARQL-UPDATE requests must have content!");
            }

            evaluateRequestPreconditions(request, servletResponse, resource(), transaction);

            LOGGER.info("PATCH for '{}'", externalPath);
            final String newRequest = httpRdfService.patchRequestToInternalString(resource().getFedoraId(),
                    requestBody, identifierConverter());
            LOGGER.debug("PATCH request translated to '{}'", newRequest);

            doInDbTxWithRetry(() -> {
                patchResourcewithSparql(resource(), newRequest);
                transaction.commitIfShortLived();
            });

            addCacheControlHeaders(servletResponse, reloadResource(), transaction);

            return noContent().build();
        } catch (final IllegalArgumentException iae) {
            throw new BadRequestException(iae.getMessage());
        } catch (final AccessDeniedException e) {
            throw e;
        } catch ( final RuntimeException ex ) {
            final Throwable cause = ex.getCause();
            if (cause instanceof PathNotFoundRuntimeException) {
                // the sparql update referred to a repository resource that doesn't exist
                throw new BadRequestException(cause.getMessage());
            }
            throw ex;
        } finally {
            transaction.releaseResourceLocksIfShortLived();
        }
    }

    /**
     * Creates a new object.
     *
     * This originally used application/octet-stream;qs=1001 as a workaround
     * for JERSEY-2636, to ensure requests without a Content-Type get routed here.
     * This qs value does not parse with newer versions of Jersey, as qs values
     * must be between 0 and 1. We use qs=1.000 to mark where this historical
     * anomaly had been.
     *
     * @param contentDispositionRaw the content Disposition value
     * @param requestContentType the request content type
     * @param slug the slug value
     * @param requestBodyStream the request body stream
     * @param rawLinks the link values
     * @param digest the digest header
     * @return 201
     * @throws InvalidChecksumException if invalid checksum exception occurred
     * @throws MalformedRdfException if malformed rdf exception occurred
     * @throws UnsupportedAlgorithmException if an unsupported algorithm exception occurs
     */
    @POST
    @Consumes({MediaType.APPLICATION_OCTET_STREAM + ";qs=1.000", WILDCARD})
    @Produces({TURTLE_WITH_CHARSET + ";qs=1.0", JSON_LD + ";qs=0.8",
            N3_WITH_CHARSET, N3_ALT2_WITH_CHARSET, RDF_XML, NTRIPLES, TEXT_PLAIN_WITH_CHARSET,
            TEXT_HTML_WITH_CHARSET, "*/*"})
    public Response createObject(@HeaderParam(CONTENT_DISPOSITION) final String contentDispositionRaw,
                                 @HeaderParam(CONTENT_TYPE) final MediaType requestContentType,
                                 @HeaderParam("Slug") final String slug,
                                 final InputStream requestBodyStream,
                                 @HeaderParam(LINK) final List rawLinks,
                                 @HeaderParam("Digest") final String digest)
            throws InvalidChecksumException, MalformedRdfException, UnsupportedAlgorithmException {

        final var decodedSlug = slug != null ? URLDecoder.decode(slug, UTF_8) : null;
        final var transaction = transaction();

        try {
            final List links = unpackLinks(rawLinks);

            if (externalPath.contains("/" + FedoraTypes.FCR_VERSIONS)) {
                handleRequestDisallowedOnMemento();

                return status(METHOD_NOT_ALLOWED).build();
            }

            // If request is an external binary, verify link header before proceeding
            final ExternalContent extContent = extContentHandlerFactory.createFromLinks(links);

            final String interactionModel = checkInteractionModel(links);

            final FedoraId fedoraId = identifierConverter().pathToInternalId(externalPath());
            // If the resource doesn't exist and it's not a ghost node, throw an exception.
            // Ghost node checking is done further down in the code and returns a 400 Bad Request error.
            if (!doesResourceExist(transaction, fedoraId, false) && !isGhostNode(transaction, fedoraId)) {
                throw new PathNotFoundRuntimeException(String.format("Path %s not found", fedoraId.getFullIdPath()));
            }
            final FedoraId newFedoraId = mintNewPid(fedoraId, decodedSlug);
            final var providedContentType = getSimpleContentType(requestContentType);

            LOGGER.info("POST to create resource with ID: {}, slug: {}", newFedoraId.getFullIdPath(), decodedSlug);

            if (isBinary(interactionModel,
                    providedContentType,
                    requestBodyStream != null && providedContentType != null,
                    extContent != null)) {
                ensureArchivalGroupHeaderNotPresentForBinaries(links);

                final Collection checksums = parseDigestHeader(digest);

                final String originalFileName;
                final long contentSize;

                if (StringUtils.isNotBlank(contentDispositionRaw)) {
                    final var contentDisposition = ContentDisposition.parse(contentDispositionRaw);
                    originalFileName = contentDisposition.getFilename();
                    contentSize = contentDisposition.getSize() == null ? -1L : contentDisposition.getSize();
                } else {
                    originalFileName = "";
                    contentSize = -1L;
                }

                final var binaryType = requestContentType != null ?
                        requestContentType : DEFAULT_NON_RDF_CONTENT_TYPE;
                final var contentType = extContent == null ? binaryType.toString() : extContent.getContentType();

                doInDbTx(() -> {
                    createResourceService.perform(transaction,
                            getUserPrincipal(),
                            newFedoraId,
                            contentType,
                            originalFileName,
                            contentSize,
                            links,
                            checksums,
                            requestBodyStream,
                            extContent);

                    transaction.commitIfShortLived();
                });
            } else {
                final var contentType = requestContentType != null ? requestContentType : DEFAULT_RDF_CONTENT_TYPE;
                final Model model = httpRdfService.bodyToInternalModel(newFedoraId, requestBodyStream,
                        contentType, identifierConverter(), hasLenientPreferHeader());

                doInDbTxWithRetry(() -> {
                    createResourceService.perform(transaction,
                            getUserPrincipal(),
                            newFedoraId,
                            links,
                            model);

                    transaction.commitIfShortLived();
                });
            }

            LOGGER.debug("Finished creating resource with path: {}", externalPath());

            try {
                final var resource = getFedoraResource(transaction, newFedoraId);
                return createUpdateResponse(resource, true);
            } catch (final PathNotFoundException e) {
                throw new PathNotFoundRuntimeException(e.getMessage(), e);
            }
        } finally {
            transaction.releaseResourceLocksIfShortLived();
        }
    }

    @Override
    protected void addResourceHttpHeaders(final FedoraResource resource) {
        addResourceHttpHeaders(resource, false);
    }

    @Override
    protected void addResourceHttpHeaders(final FedoraResource resource, final boolean dispositionInline) {
        addResourceHttpHeaders(resource, dispositionInline, false);
    }

    protected void addResourceHttpHeaders(final FedoraResource resource,
                                          final boolean dispositionInline,
                                          final boolean isRedirect) {
        super.addResourceHttpHeaders(resource, dispositionInline, isRedirect);

        if (!transaction().isShortLived()) {
            final String canonical = identifierConverter().toExternalId(resource.getFedoraId().getFullId())
                    .replaceFirst("/tx:[^/]+", "");

            servletResponse.addHeader(LINK, "<" + canonical + ">;rel=\"canonical\"");

        }
        addExternalContentHeaders(resource);
        addTransactionHeaders(resource);
    }

    @Override
    protected String externalPath() {
        return externalPath;
    }

    /**
     * Determine based on several factors whether the interaction model should be ldp:NonRdfSource
     * @param interactionModel the interaction model from the links.
     * @param contentType the content type.
     * @param contentPresent is there a request body.
     * @param contentExternal is there an external content header.
     * @return Use ldp:NonRdfSource as the interaction model.
     */
    private boolean isBinary(final String interactionModel, final String contentType,
                             final boolean contentPresent, final boolean contentExternal) {
        final String simpleContentType = contentPresent ? contentType : null;
        final boolean isRdfContent = isRdfContentType(simpleContentType);
        return NON_RDF_SOURCE.getURI().equals(interactionModel) || contentExternal ||
                (contentPresent && interactionModel == null && !isRdfContent);
    }

    private String handleWantDigestHeader(final Binary binary, final String wantDigest)
            throws UnsupportedAlgorithmException {
        // handle the Want-Digest header with fixity check
        final Collection preferredDigests = parseWantDigestHeader(wantDigest);
        if (preferredDigests.isEmpty()) {
            throw new UnsupportedAlgorithmException(
                    "Unsupported digest algorithm provided in 'Want-Digest' header: " + wantDigest);
        }

        final Collection checksumResults = fixityService.getFixity(binary, preferredDigests);
        return checksumResults.stream().map(uri -> uri.toString().replaceFirst("urn:", "")
                .replaceFirst(":", "=").replaceFirst("sha1=", "sha=")).collect(Collectors.joining(","));
    }

    private static void ensureArchivalGroupHeaderNotPresentForBinaries(final List links) {
        if (links == null) {
            return;
        }

        if (links.stream().map(Link::valueOf)
                      .filter(l -> l.getUri().toString().equals(ARCHIVAL_GROUP.getURI()))
                      .anyMatch(l -> l.getRel().equals("type"))) {
            throw new ClientErrorException("Binary resources cannot be created as an" +
                    " ArchiveGroup. Please remove the ArchiveGroup link header and try again", BAD_REQUEST);
        }
    }

    private static String checkInteractionModel(final List links) {
        if (links == null) {
            return null;
        }

        try {
            for (final String link : links) {
                final Link linq = Link.valueOf(link);
                if ("type".equals(linq.getRel())) {
                    //skip ArchivalGroup types
                    if (linq.getUri().toString().equals(ARCHIVAL_GROUP.getURI())) {
                        continue;
                    }
                    final Resource type = createResource(linq.getUri().toString());
                    if (INTERACTION_MODEL_RESOURCES.contains(type)) {
                        return type.getURI();
                    } else if (type.equals(VERSIONED_RESOURCE)) {
                        // skip if versioned resource link header
                        // NB: the versioned resource header is used for enabling
                        // versioning on a resource and is thus orthogonal to
                        // issue of interaction models. Nevertheless, it is
                        // a possible link header and, therefore, must be ignored.
                    } else {
                        LOGGER.info("Invalid interaction model: {}", type);
                        throw new CannotCreateResourceException("Invalid interaction model: " + type);
                    }
                }
            }
        } catch (final RuntimeException e) {
            if (e instanceof IllegalArgumentException || e instanceof UriBuilderException) {
                throw new ClientErrorException("Invalid link specified: " + String.join(", ", links), BAD_REQUEST);
            }
            throw e;
        }

        return null;
    }

    /**
     * Parse the RFC-3230 Want-Digest header value.
     * @param wantDigest The Want-Digest header value with optional q value in format:
     *    'md5', 'md5, sha', 'MD5;q=0.3, sha;q=1' etc.
     * @return Digest algorithms that are supported
     */
    private static Collection parseWantDigestHeader(final String wantDigest) {
        final Map digestPairs = new HashMap<>();
        try {
            final List algs = Splitter.on(',').omitEmptyStrings().trimResults().splitToList(wantDigest);
            // Parse the optional q value with default 1.0, and 0 ignore. Format could be: SHA-1;qvalue=0.1
            for (final String alg : algs) {
                final String[] tokens = alg.split(";", 2);
                final double qValue = tokens.length == 1 || !tokens[1].contains("=") ?
                        1.0 : Double.parseDouble(tokens[1].split("=", 2)[1]);
                digestPairs.put(tokens[0], qValue);
            }

            return digestPairs.entrySet().stream().filter(entry -> entry.getValue() > 0)
                    .map(Map.Entry::getKey)
                    .filter(DigestAlgorithm::isSupportedAlgorithm)
                    .collect(Collectors.toSet());
        } catch (final NumberFormatException e) {
            throw new ClientErrorException("Invalid 'Want-Digest' header value: " + wantDigest, SC_BAD_REQUEST, e);
        } catch (final RuntimeException e) {
            if (e instanceof IllegalArgumentException) {
                throw new ClientErrorException("Invalid 'Want-Digest' header value: " + wantDigest + "\n", BAD_REQUEST);
            }
            throw e;
        }
    }

    private void handleRequestDisallowedOnMemento() {
        try {
            addLinkAndOptionsHttpHeaders(resource());
        } catch (final Exception ex) {
            // Catch the exception to ensure status 405 for any requests on memento.
            LOGGER.debug("Unable to add link and options headers for PATCH request to memento path {}: {}.",
                externalPath, ex.getMessage());
        }

        LOGGER.info("Unable to handle {} request on a path containing {}. Path was: {}", request.getMethod(),
            FedoraTypes.FCR_VERSIONS, externalPath);
    }

    private FedoraId mintNewPid(final FedoraId fedoraId, final String slug) {
        final String pid;

        if (isGhostNode(transaction(), fedoraId)) {
            LOGGER.debug("Resource with path {} is an immutable resource; it cannot be POSTed to.", fedoraId);
            throw new CannotCreateResourceException("Cannot create resource as child of the immutable resource at " +
                    fedoraId.getFullIdPath());
        }
        if (!isBlank(slug)) {
            pid = slug;
        } else if (pidMinter != null) {
            pid = pidMinter.get();
        } else {
            pid = defaultPidMinter.get();
        }

        final FedoraId fullTestPath = fedoraId.resolve(pid);
        hasRestrictedPath(fullTestPath.getFullIdPath());

        if (doesResourceExist(transaction(), fullTestPath, true) || isGhostNode(transaction(), fullTestPath)) {
            LOGGER.debug("Resource with path {} already exists or is an immutable resource; minting new path instead",
                    fullTestPath);
            return mintNewPid(fedoraId, null);
        }

        return fullTestPath;
    }

}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy