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