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