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