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