1   package eu.fbk.knowledgestore.server.http.jaxrs;
2   
3   import java.io.InputStream;
4   import java.util.Date;
5   
6   import javax.ws.rs.Consumes;
7   import javax.ws.rs.DELETE;
8   import javax.ws.rs.DefaultValue;
9   import javax.ws.rs.GET;
10  import javax.ws.rs.HeaderParam;
11  import javax.ws.rs.POST;
12  import javax.ws.rs.PUT;
13  import javax.ws.rs.Path;
14  import javax.ws.rs.Produces;
15  import javax.ws.rs.QueryParam;
16  import javax.ws.rs.core.HttpHeaders;
17  import javax.ws.rs.core.MediaType;
18  import javax.ws.rs.core.Response;
19  import javax.ws.rs.core.Response.Status;
20  
21  import org.codehaus.enunciate.jaxrs.ResponseCode;
22  import org.codehaus.enunciate.jaxrs.ResponseHeader;
23  import org.codehaus.enunciate.jaxrs.ResponseHeaders;
24  import org.codehaus.enunciate.jaxrs.StatusCodes;
25  import org.codehaus.enunciate.jaxrs.TypeHint;
26  import org.glassfish.jersey.media.multipart.BodyPart;
27  import org.glassfish.jersey.media.multipart.ContentDisposition;
28  import org.glassfish.jersey.media.multipart.FormDataBodyPart;
29  import org.glassfish.jersey.media.multipart.FormDataMultiPart;
30  import org.openrdf.model.URI;
31  import org.openrdf.model.Value;
32  import org.slf4j.Logger;
33  import org.slf4j.LoggerFactory;
34  
35  import eu.fbk.knowledgestore.Operation;
36  import eu.fbk.knowledgestore.Outcome;
37  import eu.fbk.knowledgestore.data.Data;
38  import eu.fbk.knowledgestore.data.Record;
39  import eu.fbk.knowledgestore.data.Representation;
40  import eu.fbk.knowledgestore.data.Stream;
41  import eu.fbk.knowledgestore.internal.jaxrs.Protocol;
42  import eu.fbk.knowledgestore.vocabulary.NFO;
43  import eu.fbk.knowledgestore.vocabulary.NIE;
44  
45  /**
46   * Manages a collection of files.
47   * <p>
48   * This root REST resource allows the download, upload and removal of resource files in the
49   * KnowledgeStore.
50   * </p>
51   * <p>
52   * File download is performed via GET requests and supports caching and conditional requests based
53   * on file modification date and ETag (MD5 hash of file content); file metadata is taken from the
54   * <tt>ks:storedAs</tt> resource property and is returned via standard HTTP headers.
55   * </p>
56   * <p>
57   * File upload can be performed via PUT requests whose body is the file content, or via POST
58   * request with <tt>multipart/form-data</tt> body; the PUT approach should be preferred in client
59   * libraries supporting the PUT operation, whereas the POST approach can be used when uploading
60   * from an HTML form using a browser. File metadata can be supplied either via standard HTTP
61   * headers or via custom <tt>X-KS-Content-Meta</tt> key-value headers.
62   * </p>
63   * <p>
64   * File deletion can be performed either with DELETE requests or via POST requests lacking a
65   * <tt>file</tt> form parameter.
66   * </p>
67   */
68  @Path("/" + Protocol.PATH_REPRESENTATIONS)
69  public class Files extends Resource {
70  
71      private static final Logger LOGGER = LoggerFactory.getLogger(Files.class);
72  
73      /**
74       * Retrieves a file. Technically, this operation returns the representation of a file <i>HTTP
75       * resource</i> whose URI is fully determined by the <tt>id</tt> query parameter that encodes
76       * the URI of the KnowledgeStore resource the file refers to. The operation:
77       * <ul>
78       * <li>supports the use of HTTP preconditions in the form of If-Match, If-None-Match,
79       * If-Modified-Since, If-Unmodified-Since headers;</li>
80       * <li>allows the client to accept only representations in a certain MIME type, via Accept
81       * header;</li>
82       * <li>can enable / disable the use of server-side caches via header Cache-Control (specify
83       * <tt>no-cache</tt> or <tt>no-store</tt> to disable caches).</li>
84       * </ul>
85       *
86       * @param id
87       *            the URI identifier of the KnowledgeStore resource (mandatory)
88       * @param accept
89       *            the MIME type accepted by the client (optional); a 406 NOT ACCEPTABLE response
90       *            will be returned if the file representation has a non-compatible MIME type
91       * @return the file content, on success, encoded using the specific file MIME type
92       * @throws Exception
93       *             on error
94       */
95      @GET
96      @Produces("*/*")
97      @TypeHint(InputStream.class)
98      @StatusCodes({
99              @ResponseCode(code = 200, condition = "if the file is found and its representation "
100                     + "is returned"),
101             @ResponseCode(code = 404, condition = "if the requested file does not exist (the "
102                     + "associated resource may exist or not)") })
103     @ResponseHeaders({
104             @ResponseHeader(name = "Content-Language", description = "the 2-letters ISO 639 "
105                     + "language code for file representation, if known"),
106             @ResponseHeader(name = "Content-Disposition", description = "a content disposition "
107                     + "directive for browsers, including the suggested file name and date for "
108                     + "saving the file"),
109             @ResponseHeader(name = "Content-MD5", description = "the MD5 hash of the file "
110                     + "representation") })
111     public Response get(@QueryParam(Protocol.PARAMETER_ID) final URI id,
112             @HeaderParam(HttpHeaders.ACCEPT) @DefaultValue(MediaType.WILDCARD) final String accept)
113             throws Exception {
114 
115         // Check query string parameters
116         checkNotNull(id, Outcome.Status.ERROR_INVALID_INPUT, "Missing 'id' query parameter");
117 
118         // Retrieve the file to return
119         final Representation representation = getSession() //
120                 .download(id) //
121                 .timeout(getTimeout()) //
122                 .accept(accept.split(",")) //
123                 .caching(isCachingEnabled()) //
124                 .exec();
125 
126         // Fail if file does not exist
127         checkNotNull(representation, Outcome.Status.ERROR_OBJECT_NOT_FOUND,
128                 "Specified file does not exist");
129         closeOnCompletion(representation);
130 
131         // Retrieve file metadata and build the resulting Content-Disposition header
132         final Record metadata = representation.getMetadata();
133         final Long fileSize = metadata.getUnique(NFO.FILE_SIZE, Long.class, null);
134         final String fileName = metadata.getUnique(NFO.FILE_NAME, String.class, null);
135         final String mimeType = metadata.getUnique(NIE.MIME_TYPE, String.class, null);
136         final Date lastModified = extractLastModified(representation);
137         final String tag = extractMD5(representation);
138         final ContentDisposition disposition = ContentDisposition.type("attachment")
139                 .fileName(fileName) //
140                 .modificationDate(lastModified) //
141                 .size(fileSize != null ? fileSize : -1) //
142                 .build();
143 
144         // Validate client preconditions, do negotiation and handle probe requests
145         init(false, mimeType, lastModified, tag);
146 
147         // Stream the file to the client. Note that Content-Length is not set as it will not be
148         // valid after GZIP compression is applied (Jersey should remove it, but it doesn't)
149         return newResponseBuilder(Status.OK, representation, null).header(
150                 HttpHeaders.CONTENT_DISPOSITION, disposition).build();
151     }
152 
153     /**
154      * Creates or updates a file, uploading its content as the entity of the HTTP request.
155      * Technically, this operation stores the representation of a file <i>HTTP resource</i> whose
156      * URI is fully determined by the <tt>id</tt> query parameter that encodes the URI of the
157      * KnowledgeStore resource the file refers to. The operation:
158      * <ul>
159      * <li>can result either in the file being created or updated;</li>
160      * <li>supports the use of HTTP preconditions in the form of If-Match, If-None-Match,
161      * If-Modified-Since, If-Unmodified-Since headers;</li>
162      * <li>can supply arbitrary metadata about the file using zero or more occurrences of the
163      * X-KS-Content-Meta <tt>property value</tt> non-standard header, where properties and values
164      * are encoded using the Turtle syntax.</li>
165      * </ul>
166      *
167      * @param id
168      *            the URI identifier of the KnowledgeStore resource (mandatory, must refer to an
169      *            existing resource for the request to be valid)
170      * @param representation
171      *            the file to store
172      * @return the operation outcome, encoded in one of the supported RDF MIME types
173      * @throws Exception
174      *             on error
175      */
176     @PUT
177     @Consumes(MediaType.WILDCARD)
178     @Produces(Protocol.MIME_TYPES_RDF)
179     @TypeHint(Stream.class)
180     @StatusCodes({ @ResponseCode(code = 200, condition = "if the file has been updated"),
181             @ResponseCode(code = 201, condition = "if the file has been created") })
182     public Response put(@QueryParam(Protocol.PARAMETER_ID) final URI id,
183             final Representation representation) throws Exception {
184 
185         // Schedule closing of input entity
186         closeOnCompletion(representation);
187 
188         // Check query string parameters
189         checkNotNull(id, Outcome.Status.ERROR_INVALID_INPUT, "Missing 'id' query parameter");
190 
191         // Setup the UPLOAD operation, returning an error if parameters are wrong
192         final Operation.Upload operation;
193         try {
194             operation = getSession().upload(id).timeout(getTimeout())
195                     .representation(representation);
196         } catch (final RuntimeException ex) {
197             throw newException(Outcome.Status.ERROR_INVALID_INPUT, ex, null);
198         }
199 
200         // Retrieve old file for the same resource
201         Representation oldRepresentation = null;
202         try {
203             oldRepresentation = getSession().download(id).timeout(getTimeout()).exec();
204             closeOnCompletion(oldRepresentation);
205         } catch (final Throwable ex) {
206             LOGGER.error("Error retrieving current files associated to resource " + id, ex);
207         }
208 
209         // Handle two cases for validating preconditions, doing negotiation and handling probes
210         if (oldRepresentation == null) {
211             // No old file: new file will be stored
212             init(true, null);
213 
214         } else {
215             // Old file exists: check preconditions based on its ETag and last modified
216             final Date getLastModified = extractLastModified(oldRepresentation);
217             final String getTag = extractMD5(oldRepresentation);
218             oldRepresentation.close();
219             init(true, null, getLastModified, getTag);
220         }
221 
222         // Perform the operation
223         final Outcome outcome = operation.exec();
224 
225         // Setup the response stream
226         final int httpStatus = outcome.getStatus().getHTTPStatus();
227         final Stream<Outcome> entity = Stream.create(outcome);
228 
229         // Stream the Outcome result to the client
230         return newResponseBuilder(httpStatus, entity, Protocol.STREAM_OF_OUTCOMES).build();
231     }
232 
233     /**
234      * Creates, updates or deletes a file, using a multipart form data HTTP entity that is
235      * compatible with the POST submission HTML forms. Technically, the operation targets the
236      * <tt>Files</tt> <i>HTTP resource</i> (controller), supplying all the data necessary for
237      * uploading the file in a single multipart message. The operation:
238      * <ul>
239      * <li>can result either in the file being created, updated or deleted (deletion occurs if no
240      * file content is included in the multipart message);</li>
241      * <li>can supply arbitrary metadata about the file using either <tt>property = value</tt>
242      * form parameters encoded in the multipart message or by sending zero or more occurrences of
243      * the X-KS Content-Meta non-standard <tt>property value</tt> header; in both cases,
244      * properties and values are encoded using Turtle syntax.</li>
245      * </ul>
246      *
247      * @param formData
248      *            a multipart form data entity containing a body part for the <tt>id</tt> URI
249      *            parameter (must denote an existing resource for the request to be valid), a body
250      *            part for the <tt>file</tt> parameter and optional body parts for additional
251      *            metadata attributes about the uploaded file
252      * @return the operation outcome, encoded in one of the supported RDF MIME types
253      * @throws Exception
254      *             on error
255      */
256     @POST
257     @Consumes(MediaType.MULTIPART_FORM_DATA)
258     @Produces(Protocol.MIME_TYPES_RDF)
259     @TypeHint(Stream.class)
260     @StatusCodes({
261             @ResponseCode(code = 200, condition = "if the file has been updated or deleted"),
262             @ResponseCode(code = 201, condition = "if the file has been created") })
263     @ResponseHeaders({ @ResponseHeader(name = "Location", description = "the URI of "
264             + "the created file") })
265     public Response post(final FormDataMultiPart formData) throws Exception {
266 
267         // Validate preconditions and handle probe requests here, before body is consumed
268         // POST URI does not support GET, hence no tag and last modified
269         init(true, null);
270 
271         // Process the form parameters encoded in the request body
272         URI id = null;
273         Representation representation = null;
274         final Record record = Record.create();
275         for (final BodyPart bodyPart : formData.getBodyParts()) {
276 
277             // Handle three types of parameters
278             final FormDataBodyPart part = (FormDataBodyPart) bodyPart;
279             final String name = part.getName();
280             if ("id".equals(name)) {
281                 // 'id' parameter: the ID of the resource
282                 id = Data.getValueFactory().createURI(name);
283 
284             } else if ("file".equals(name)) {
285                 // 'file' parameter: the uploaded file, with some metadata
286                 representation = closeOnCompletion(part.getEntityAs(Representation.class));
287                 final ContentDisposition disposition = checkNotNull(part.getContentDisposition(),
288                         Outcome.Status.ERROR_INVALID_INPUT,
289                         "Missing Content-Disposition header for body part " + part.getName());
290                 final Record metadata = representation.getMetadata();
291                 metadata.set(NFO.FILE_NAME, disposition.getFileName());
292                 metadata.set(NFO.FILE_LAST_MODIFIED, disposition.getModificationDate());
293 
294             } else {
295                 // other parameters: treat them as additional file metadata
296                 final URI property = (URI) Data.parseValue(name, Data.getNamespaceMap());
297                 final Value value = Data.parseValue(part.getEntityAs(String.class),
298                         Data.getNamespaceMap());
299                 record.add(property, value);
300             }
301         }
302 
303         // Check the ID parameters was supplied
304         checkNotNull(id, Outcome.Status.ERROR_INVALID_INPUT, "Missing 'id' form parameter");
305         assert id != null;
306 
307         // If a file was uploaded, extend it with the additional metadata
308         if (representation != null) {
309             // Protocol.decodeMetadata(encodedMetadata, metadata);
310             final Record metadata = representation.getMetadata();
311             for (final URI property : record.getProperties()) {
312                 metadata.set(property, record.get(property));
313             }
314         }
315 
316         // Perform the operation
317         final Outcome outcome = getSession().upload(id).timeout(getTimeout())
318                 .representation(representation).exec();
319 
320         // Setup the response stream
321         final int httpStatus = outcome.getStatus().getHTTPStatus();
322         final Stream<Outcome> entity = Stream.create(outcome);
323 
324         // Stream the result to the client
325         return newResponseBuilder(httpStatus, entity, Protocol.STREAM_OF_OUTCOMES).build();
326     }
327 
328     /**
329      * Deletes a file. Technically, the operation targets a file <i>HTTP resource</i> whose URI is
330      * fully determined by the <tt>id</tt> query parameter that encodes the URI of the
331      * KnowledgeStore resource the file refers to. The operation supports the use of HTTP
332      * preconditions in the form of If-Match, If-None-Match, If-Modified-Since,
333      * If-Unmodified-Since headers.
334      *
335      * @param id
336      *            the URI identifier of the KnowledgeStore resource (mandatory)
337      * @return the operation outcome, encoded in one of the supported RDF MIME types
338      * @throws Exception
339      *             on error
340      */
341     @DELETE
342     @Produces(Protocol.MIME_TYPES_RDF)
343     @TypeHint(Outcome.class)
344     @StatusCodes({
345             @ResponseCode(code = 200, condition = "if the file has been deleted"),
346             @ResponseCode(code = 404, condition = "if the file does not exist (the associated "
347                     + "resource may exist or not)") })
348     public Response delete(@QueryParam(Protocol.PARAMETER_ID) final URI id) throws Exception {
349 
350         // Check query string parameters
351         checkNotNull(id, Outcome.Status.ERROR_INVALID_INPUT, "Missing 'id' query parameter");
352 
353         // Retrieve the file to delete and fail if it does not exist
354         final Representation oldRepresentation = getSession().download(id).timeout(getTimeout())
355                 .exec();
356         closeOnCompletion(oldRepresentation);
357         checkNotNull(oldRepresentation, Outcome.Status.ERROR_OBJECT_NOT_FOUND,
358                 "Specified file does not exist.");
359 
360         // Retrieve ETag and last modified for validation of preconditions
361         final Date getLastModified = extractLastModified(oldRepresentation);
362         final String getTag = extractMD5(oldRepresentation);
363         oldRepresentation.close();
364 
365         // Setup the UPLOAD operation, returning an error if parameters are wrong
366         final Operation.Upload operation;
367         try {
368             operation = getSession().upload(id).timeout(getTimeout()).representation(null);
369         } catch (final RuntimeException ex) {
370             throw newException(Outcome.Status.ERROR_INVALID_INPUT, ex, null);
371         }
372 
373         // Validate preconditions, do negotiation and handle probing
374         init(true, null, getLastModified, getTag);
375 
376         // Perform the operation
377         final Outcome outcome = operation.exec();
378 
379         // Setup the resulting stream
380         final int httpStatus = outcome.getStatus().getHTTPStatus();
381         final Stream<Outcome> entity = Stream.create(outcome);
382 
383         // Stream the Outcome result to the client
384         return newResponseBuilder(httpStatus, entity, Protocol.STREAM_OF_OUTCOMES).build();
385     }
386 
387     private static Date extractLastModified(final Representation representation) {
388         final Record metadata = representation.getMetadata();
389         return metadata.getUnique(NFO.FILE_LAST_MODIFIED, Date.class, null);
390     }
391 
392     private static String extractMD5(final Representation representation) {
393         final Record metadata = representation.getMetadata();
394         final Record hash = metadata.getUnique(NFO.HAS_HASH, Record.class, null);
395         if (hash != null && "MD5".equals(hash.getUnique(NFO.HASH_ALGORITHM, String.class, null))) {
396             return hash.getUnique(NFO.HASH_VALUE, String.class, null);
397         }
398         return null;
399     }
400 
401 }