001/**
002 * Copyright 2014 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     */
224    @PUT
225    @Consumes
226    @Timed
227    public Response createOrReplaceObjectRdf(
228            @HeaderParam("Content-Type") final MediaType requestContentType,
229            @ContentLocation final InputStream requestBodyStream,
230            @QueryParam("checksum") final String checksum,
231            @HeaderParam("Content-Disposition") final ContentDisposition contentDisposition,
232            @HeaderParam("If-Match") final String ifMatch)
233            throws InvalidChecksumException, MalformedRdfException {
234
235        final FedoraResource resource;
236        final Response.ResponseBuilder response;
237
238        final String path = toPath(translator(), externalPath);
239
240        final MediaType contentType = getSimpleContentType(requestContentType);
241
242        if (nodeService.exists(session, path)) {
243            resource = resource();
244            response = noContent();
245        } else {
246            final MediaType effectiveContentType
247                    = requestBodyStream == null || requestContentType == null ? null : contentType;
248            resource = createFedoraResource(path, effectiveContentType, contentDisposition);
249
250            final URI location = getUri(resource);
251
252            response = created(location).entity(location.toString());
253        }
254
255        if (httpConfiguration.putRequiresIfMatch() && StringUtils.isBlank(ifMatch) && !resource.isNew()) {
256            throw new ClientErrorException("An If-Match header is required", 428);
257        }
258
259        evaluateRequestPreconditions(request, servletResponse, resource, session);
260
261        final RdfStream resourceTriples;
262
263        if (resource.isNew()) {
264            resourceTriples = new RdfStream();
265        } else {
266            resourceTriples = getResourceTriples();
267        }
268
269        LOGGER.info("PUT resource '{}'", externalPath);
270        if (resource instanceof FedoraBinary) {
271            replaceResourceBinaryWithStream((FedoraBinary) resource,
272                    requestBodyStream, contentDisposition, requestContentType, checksum);
273        } else if (isRdfContentType(contentType.toString())) {
274            try {
275                replaceResourceWithStream(resource, requestBodyStream, contentType, resourceTriples);
276            } catch (final RiotException e) {
277                throw new BadRequestException("RDF was not parsable", e);
278            }
279        } else if (!resource.isNew()) {
280            boolean emptyRequest = true;
281            try {
282                emptyRequest = requestBodyStream.read() == -1;
283            } catch (IOException ex) {
284                LOGGER.debug("Error checking for request body content", ex);
285            }
286
287            if (requestContentType == null && emptyRequest) {
288                throw new ClientErrorException("Resource Already Exists", CONFLICT);
289            } else {
290                throw new ClientErrorException("Invalid Content Type " + requestContentType, UNSUPPORTED_MEDIA_TYPE);
291            }
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     */
376    @POST
377    @Consumes({MediaType.APPLICATION_OCTET_STREAM + ";qs=1001", MediaType.WILDCARD})
378    @Timed
379    public Response createObject(@QueryParam("checksum") final String checksum,
380                                 @HeaderParam("Content-Disposition") final ContentDisposition contentDisposition,
381                                 @HeaderParam("Content-Type") final MediaType requestContentType,
382                                 @HeaderParam("Slug") final String slug,
383                                 @ContentLocation final InputStream requestBodyStream)
384            throws InvalidChecksumException, IOException, MalformedRdfException {
385
386        if (!(resource() instanceof Container)) {
387            throw new ClientErrorException("Object cannot have child nodes", CONFLICT);
388        }
389
390        final MediaType contentType = getSimpleContentType(requestContentType);
391
392        final String contentTypeString = contentType.toString();
393
394        final String newObjectPath = mintNewPid(slug);
395
396        LOGGER.info("Ingest with path: {}", newObjectPath);
397
398        final MediaType effectiveContentType
399                = requestBodyStream == null || requestContentType == null ? null : contentType;
400        final FedoraResource result = createFedoraResource(
401                newObjectPath,
402                effectiveContentType,
403                contentDisposition);
404
405        final RdfStream resourceTriples;
406
407        if (result.isNew()) {
408            resourceTriples = new RdfStream();
409        } else {
410            resourceTriples = getResourceTriples();
411        }
412
413        if (requestBodyStream == null) {
414            LOGGER.trace("No request body detected");
415        } else {
416            LOGGER.trace("Received createObject with a request body and content type \"{}\"", contentTypeString);
417
418            if ((result instanceof Container)
419                    && isRdfContentType(contentTypeString)) {
420                replaceResourceWithStream(result, requestBodyStream, contentType, resourceTriples);
421            } else if (result instanceof FedoraBinary) {
422                LOGGER.trace("Created a datastream and have a binary payload.");
423
424                replaceResourceBinaryWithStream((FedoraBinary) result,
425                        requestBodyStream, contentDisposition, requestContentType, checksum);
426
427            } else if (contentTypeString.equals(contentTypeSPARQLUpdate)) {
428                LOGGER.trace("Found SPARQL-Update content, applying..");
429                patchResourcewithSparql(result, IOUtils.toString(requestBodyStream), resourceTriples);
430            } else {
431                if (requestBodyStream.read() != -1) {
432                    throw new ClientErrorException("Invalid Content Type " + contentTypeString, UNSUPPORTED_MEDIA_TYPE);
433                }
434            }
435        }
436
437        try {
438            session.save();
439        } catch (final RepositoryException e) {
440            throw new RepositoryRuntimeException(e);
441        }
442
443        LOGGER.debug("Finished creating resource with path: {}", newObjectPath);
444
445        addCacheControlHeaders(servletResponse, result, session);
446
447        final URI location = getUri(result);
448
449        addResourceLinkHeaders(result, true);
450
451        return created(location).entity(location.toString()).build();
452
453    }
454
455    @Override
456    protected void addResourceHttpHeaders(final FedoraResource resource) {
457        super.addResourceHttpHeaders(resource);
458
459        if (getCurrentTransactionId(session) != null) {
460            final String canonical = translator().reverse()
461                    .convert(resource)
462                    .toString()
463                    .replaceFirst("/tx:[^/]+", "");
464
465
466            servletResponse.addHeader("Link", "<" + canonical + ">;rel=\"canonical\"");
467
468        }
469
470        addOptionsHttpHeaders();
471    }
472
473    @Override
474    protected String externalPath() {
475        return externalPath;
476    }
477
478    private void addOptionsHttpHeaders() {
479        final String options;
480
481        if (resource() instanceof FedoraBinary) {
482            options = "DELETE,HEAD,GET,PUT,OPTIONS";
483
484        } else if (resource() instanceof NonRdfSourceDescription) {
485            options = "MOVE,COPY,DELETE,POST,HEAD,GET,PUT,PATCH,OPTIONS";
486            servletResponse.addHeader("Accept-Patch", contentTypeSPARQLUpdate);
487
488        } else if (resource() instanceof Container) {
489            options = "MOVE,COPY,DELETE,POST,HEAD,GET,PUT,PATCH,OPTIONS";
490            servletResponse.addHeader("Accept-Patch", contentTypeSPARQLUpdate);
491
492            final String rdfTypes = TURTLE + "," + N3 + ","
493                    + N3_ALT2 + "," + RDF_XML + "," + NTRIPLES;
494            servletResponse.addHeader("Accept-Post", rdfTypes + "," + MediaType.MULTIPART_FORM_DATA
495                    + "," + contentTypeSPARQLUpdate);
496        } else {
497            options = "";
498        }
499
500        addResourceLinkHeaders(resource());
501
502        servletResponse.addHeader("Allow", options);
503    }
504
505    private void addResourceLinkHeaders(final FedoraResource resource) {
506        addResourceLinkHeaders(resource, false);
507    }
508
509    private void addResourceLinkHeaders(final FedoraResource resource, final boolean includeAnchor) {
510        if (resource instanceof NonRdfSourceDescription) {
511            final URI uri = getUri(((NonRdfSourceDescription) resource).getDescribedResource());
512            final Link link = Link.fromUri(uri).rel("describes").build();
513            servletResponse.addHeader("Link", link.toString());
514        } else if (resource instanceof FedoraBinary) {
515            final URI uri = getUri(((FedoraBinary) resource).getDescription());
516            final Link.Builder builder = Link.fromUri(uri).rel("describedby");
517
518            if (includeAnchor) {
519                builder.param("anchor", getUri(resource).toString());
520            }
521            servletResponse.addHeader("Link", builder.build().toString());
522        }
523
524
525    }
526
527    private String getRequestedObjectType(final MediaType requestContentType,
528                                          final ContentDisposition contentDisposition) {
529
530        if (requestContentType != null) {
531            final String s = requestContentType.toString();
532            if (!s.equals(contentTypeSPARQLUpdate) && !isRdfContentType(s) || s.equals(TEXT_PLAIN)) {
533                return FEDORA_BINARY;
534            }
535        }
536
537        if (contentDisposition != null && contentDisposition.getType().equals("attachment")) {
538            return FEDORA_BINARY;
539        }
540
541        return FEDORA_CONTAINER;
542    }
543
544    private FedoraResource createFedoraResource(final String path,
545                                                final MediaType requestContentType,
546                                                final ContentDisposition contentDisposition) {
547        final String objectType = getRequestedObjectType(requestContentType, contentDisposition);
548
549        final FedoraResource result;
550
551        if (objectType.equals(FEDORA_BINARY)) {
552            result = binaryService.findOrCreate(session, path);
553        } else {
554            result = containerService.findOrCreate(session, path);
555        }
556
557        return result;
558    }
559
560    @Override
561    protected Session session() {
562        return session;
563    }
564
565    private String mintNewPid(final String slug) {
566        String pid;
567
568        if (slug != null && !slug.isEmpty()) {
569            pid = slug;
570        } else {
571            pid = pidMinter.mintPid();
572        }
573        // reverse translate the proffered or created identifier
574        LOGGER.trace("Using external identifier {} to create new resource.", pid);
575        LOGGER.trace("Using prefixed external identifier {} to create new resource.", uriInfo.getBaseUri() + "/"
576                + pid);
577
578        final URI newResourceUri = uriInfo.getAbsolutePathBuilder().clone().path(FedoraLdp.class)
579                .resolveTemplate("path", pid, false).build();
580
581        pid = translator().asString(createResource(newResourceUri.toString()));
582        try {
583            pid = URLDecoder.decode(pid, "UTF-8");
584        } catch (UnsupportedEncodingException e) {
585            // noop
586        }
587        // remove leading slash left over from translation
588        LOGGER.trace("Using internal identifier {} to create new resource.", pid);
589
590        if (nodeService.exists(session, pid)) {
591            LOGGER.trace("Resource with path {} already exists; minting new path instead", pid);
592            return mintNewPid(null);
593        }
594
595        return pid;
596    }
597
598}