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