/** * Copyright (c) Codice Foundation * <p/> * This is free software: you can redistribute it and/or modify it under the terms of the GNU Lesser * General Public License as published by the Free Software Foundation, either version 3 of the * License, or any later version. * <p/> * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without * even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * Lesser General Public License for more details. A copy of the GNU Lesser General Public License * is distributed along with this program and can be found at * <http://www.gnu.org/licenses/lgpl.html>. */ package ddf.content.endpoint.rest; import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.InputStream; import java.util.Arrays; import java.util.List; import java.util.Set; import javax.ws.rs.DELETE; import javax.ws.rs.DefaultValue; import javax.ws.rs.GET; import javax.ws.rs.HeaderParam; import javax.ws.rs.POST; import javax.ws.rs.PUT; import javax.ws.rs.Path; import javax.ws.rs.PathParam; import javax.ws.rs.core.Context; import javax.ws.rs.core.HttpHeaders; import javax.ws.rs.core.Response; import javax.ws.rs.core.UriBuilder; import javax.ws.rs.core.UriInfo; import org.apache.commons.io.FilenameUtils; import org.apache.commons.io.IOUtils; import org.apache.commons.lang.StringUtils; import org.apache.commons.lang.SystemUtils; import org.apache.cxf.jaxrs.ext.multipart.Attachment; import org.apache.cxf.jaxrs.ext.multipart.MultipartBody; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import com.google.common.io.FileBackedOutputStream; import ddf.content.ContentFramework; import ddf.content.ContentFrameworkException; import ddf.content.data.ContentItem; import ddf.content.data.impl.IncomingContentItem; import ddf.content.operation.CreateRequest; import ddf.content.operation.CreateResponse; import ddf.content.operation.DeleteRequest; import ddf.content.operation.DeleteResponse; import ddf.content.operation.ReadRequest; import ddf.content.operation.ReadResponse; import ddf.content.operation.Request; import ddf.content.operation.UpdateRequest; import ddf.content.operation.UpdateResponse; import ddf.content.operation.impl.CreateRequestImpl; import ddf.content.operation.impl.DeleteRequestImpl; import ddf.content.operation.impl.ReadRequestImpl; import ddf.content.operation.impl.UpdateRequestImpl; import ddf.mime.MimeTypeMapper; import ddf.mime.MimeTypeResolutionException; /** * The REST Endpoint for the Content Framework that provides URLs to create, read, update, and * delete content in the Content Repository. * * @author rodgersh * @author ddf.isgs@lmco.com */ @Path("/") public class ContentEndpoint { public static final int KB = 1024; public static final int MB = 1024 * KB; static final String CONTENT_DISPOSITION = "Content-Disposition"; static final String DEFAULT_MIME_TYPE = "application/octet-stream"; /** * Basic mime types that will be attempted to refine to a more accurate mime type * based on the file extension of the filename specified in the create request. */ static final List<String> REFINEABLE_MIME_TYPES = Arrays .asList(DEFAULT_MIME_TYPE, "text/plain"); static final String DEFAULT_FILE_NAME = "file"; static final String DEFAULT_FILE_EXTENSION = "bin"; private static final Logger LOGGER = LoggerFactory.getLogger(ContentEndpoint.class); private static final String DEFAULT_DIRECTIVE = "STORE_AND_PROCESS"; private static final String DIRECTIVE_ATTACHMENT_CONTENT_ID = "directive"; private static final String FILE_ATTACHMENT_CONTENT_ID = "file"; private static final String FILENAME_CONTENT_DISPOSITION_PARAMETER_NAME = "filename"; private static final String CONTENT_ID_HTTP_HEADER = "Content-ID"; private static final String CONTENT_URI_HTTP_HEADER = "Content-URI"; private static final int DEFAULT_FILE_BACKED_OUTPUT_STREAM_THRESHOLD = 1 * MB; private ContentFramework contentFramework; private MimeTypeMapper mimeTypeMapper; public ContentEndpoint(ContentFramework framework, MimeTypeMapper mimeTypeMapper) { LOGGER.debug("ENTERING: ContentEndpoint constructor"); this.contentFramework = framework; this.mimeTypeMapper = mimeTypeMapper; LOGGER.debug("EXITING: ContentEndpoint constructor"); } /** * Create an entry in the Content Repository and/or the Metadata Catalog based on the request's * directive. The input request is in multipart/form-data format, with the expected parts of the * body being the directive (STORE, PROCESS, STORE_AND_PROCESS), and the file, with optional * filename specified, followed by the contents to be stored. If the filename is not specified * for the contents in the body of the input request, then the default filename "file" will be * used, with the file extension determined based upon the MIME type. * <p/> * A sample multipart/form-data request would look like: Content-Type: multipart/form-data; * boundary=ARCFormBoundaryfqeylm5unubx1or * <p/> * --ARCFormBoundaryfqeylm5unubx1or Content-Disposition: form-data; name="directive" * <p/> * STORE_AND_PROCESS --ARCFormBoundaryfqeylm5unubx1or-- Content-Disposition: form-data; * name="myfile.json"; filename="C:\DDF\geojson_valid.json" Content-Type: * application/json;id=geojson * <p/> * <contents to store go here> * * @param multipartBody the multipart/form-data formatted body of the request * @param requestUriInfo * @return * @throws ContentEndpointException */ @POST @Path("/") public Response create(MultipartBody multipartBody, @Context UriInfo requestUriInfo) throws ContentEndpointException { LOGGER.trace("ENTERING: create"); String directive = multipartBody .getAttachmentObject(DIRECTIVE_ATTACHMENT_CONTENT_ID, String.class); LOGGER.debug("directive = {}", directive); String contentUri = multipartBody.getAttachmentObject("contentUri", String.class); LOGGER.debug("contentUri = {}", contentUri); InputStream stream = null; String filename = null; String contentType = null; // TODO: For DDF-1970 (multiple files in single create request) // Would access List<Attachment> = multipartBody.getAllAttachments() and loop // through them getting all of the "file" attachments (and skipping the "directive") // But how to support a "contentUri" parameter *per* file attachment? Can it be // just another parameter to the name="file" Content-Disposition? Attachment contentPart = multipartBody.getAttachment(FILE_ATTACHMENT_CONTENT_ID); if (contentPart != null) { CreateInfo createInfo = parseAttachment(contentPart); stream = createInfo.getStream(); filename = createInfo.getFilename(); contentType = createInfo.getContentType(); } else { LOGGER.debug("No file contents attachment found"); } Response response = doCreate(stream, contentType, directive, filename, contentUri, requestUriInfo); LOGGER.trace("EXITING: create"); return response; } CreateInfo parseAttachment(Attachment contentPart) { CreateInfo createInfo = new CreateInfo(); InputStream stream = null; FileBackedOutputStream fbos = null; String filename = null; String contentType = null; // Get the file contents as an InputStream and ensure the stream is positioned // at the beginning try { stream = contentPart.getDataHandler().getInputStream(); if (stream != null && stream.available() == 0) { stream.reset(); } createInfo.setStream(stream); } catch (IOException e) { LOGGER.warn("IOException reading stream from file attachment in multipart body", e); } // Example Content-Type header: // Content-Type: application/json;id=geojson if (contentPart.getContentType() != null) { contentType = contentPart.getContentType().toString(); } filename = contentPart.getContentDisposition() .getParameter(FILENAME_CONTENT_DISPOSITION_PARAMETER_NAME); // Only interested in attachments for file uploads. Any others should be covered by // the FormParam arguments. // If the filename was not specified, then generate a default filename based on the // specified content type. if (StringUtils.isEmpty(filename)) { LOGGER.debug("No filename parameter provided - generating default filename"); String fileExtension = DEFAULT_FILE_EXTENSION; try { fileExtension = mimeTypeMapper.getFileExtensionForMimeType(contentType); // DDF-2307 if (StringUtils.isEmpty(fileExtension)) { fileExtension = DEFAULT_FILE_EXTENSION; } } catch (MimeTypeResolutionException e) { LOGGER.debug("Exception getting file extension for contentType = {}", contentType); } filename = DEFAULT_FILE_NAME + "." + fileExtension; // DDF-2263 LOGGER.debug("No filename parameter provided - default to {}", filename); } else { filename = FilenameUtils.getName(filename); // DDF-908: filename with extension was specified by the client. If the // contentType is null or the browser default, try to refine the contentType // by determining the mime type based on the filename's extension. if (StringUtils.isEmpty(contentType) || REFINEABLE_MIME_TYPES.contains(contentType)) { String fileExtension = FilenameUtils.getExtension(filename); LOGGER.debug("fileExtension = {}, contentType before refinement = {}", fileExtension, contentType); if (fileExtension.equals("xml")) { // FBOS reads file into byte array in memory up to this threshold, then it transitions // to writing to a file. fbos = new FileBackedOutputStream(DEFAULT_FILE_BACKED_OUTPUT_STREAM_THRESHOLD); try { IOUtils.copy(stream, fbos); // Using fbos.asByteSource().openStream() allows us to pass in a copy of the InputStream contentType = mimeTypeMapper .guessMimeType(fbos.asByteSource().openStream(), fileExtension); createInfo.setStream(fbos.asByteSource().openStream()); } catch (IOException | MimeTypeResolutionException e) { LOGGER.debug( "Unable to refine contentType {} based on filename extension {}", contentType, fileExtension); } } else { try { contentType = mimeTypeMapper.getMimeTypeForFileExtension(fileExtension); } catch (MimeTypeResolutionException e) { LOGGER.debug( "Unable to refine contentType {} based on filename extension {}", contentType, fileExtension); } } LOGGER.debug("Refined contentType = {}", contentType); } } createInfo.setContentType(contentType); createInfo.setFilename(filename); return createInfo; } @GET @Path("/{id}") public Response read(@PathParam("id") String id) throws ContentEndpointException { LOGGER.trace("ENTERING: read"); Response response = doRead(id); LOGGER.trace("EXITING: read"); return response; } @PUT @Path("/{id}") public Response update(InputStream stream, @PathParam("id") String id, @HeaderParam("Content-Type") String contentType, @HeaderParam("directive") @DefaultValue("STORE_AND_PROCESS") String directive) throws ContentEndpointException { LOGGER.trace("ENTERING: update"); LOGGER.debug("directive = {}", directive); Response response = doUpdate(stream, id, contentType, directive, null); LOGGER.trace("EXITING: update"); return response; } // Used to only update an entry in the Metadata Catalog, accessing the existing catalog entry // via the content URI (which maps to the DAD URI of the catalog entry) @PUT @Path("/") public Response updateCatalogOnly(InputStream stream, @HeaderParam("Content-Type") String contentType, @HeaderParam("contentUri") String contentUri) throws ContentEndpointException { LOGGER.trace("ENTERING: update"); LOGGER.debug("contentUri = {}", contentUri); Response response = doUpdate(stream, null, contentType, Request.Directive.PROCESS.toString(), contentUri); LOGGER.trace("EXITING: update"); return response; } @DELETE @Path("/{id}") public Response delete(@PathParam("id") String id, @HeaderParam("directive") @DefaultValue("STORE_AND_PROCESS") String directive) throws ContentEndpointException { LOGGER.trace("ENTERING: delete"); LOGGER.debug("directive = {}", directive); Response response = executeDelete(id, directive, null); LOGGER.trace("EXITING: delete"); return response; } // Used to only delete an entry in the Metadata Catalog, accessing the existing catalog entry // via the content URI (which maps to the DAD URI of the catalog entry) @DELETE @Path("/") public Response deleteCatalogOnly(@HeaderParam("contentUri") String contentUri) throws ContentEndpointException { LOGGER.trace("ENTERING: delete"); LOGGER.debug("contentUri = {}", contentUri); Response response = executeDelete(null, Request.Directive.PROCESS.toString(), contentUri); LOGGER.trace("EXITING: delete"); return response; } protected Response doCreate(InputStream stream, String contentType, String directive, String filename, String contentUri, UriInfo uriInfo) throws ContentEndpointException { LOGGER.trace("ENTERING: doCreate"); if (stream == null) { throw new ContentEndpointException("Cannot create content. InputStream is null.", Response.Status.BAD_REQUEST); } if (contentType == null) { throw new ContentEndpointException("Cannot create content. Content-Type is null.", Response.Status.BAD_REQUEST); } if (StringUtils.isEmpty(directive)) { directive = DEFAULT_DIRECTIVE; } else { // Ensure directive has no extraneous whitespace or newlines - this tends to occur // on the values assigned in multipart/form-data. // (Was seeing this when testing with Google Chrome Advanced REST Client) directive = directive.trim().replace(SystemUtils.LINE_SEPARATOR, ""); } Request.Directive requestDirective = Request.Directive.valueOf(directive); String createdContentId = ""; Response response = null; try { LOGGER.debug("Preparing content item for contentType = {}", contentType); ContentItem newItem = new IncomingContentItem(stream, contentType, filename); // DDF-1856 newItem.setUri(contentUri); LOGGER.debug("Creating content item."); CreateRequest createRequest = new CreateRequestImpl(newItem, null); CreateResponse createResponse = contentFramework .create(createRequest, requestDirective); if (createResponse != null) { ContentItem contentItem = createResponse.getCreatedContentItem(); if (contentItem != null) { createdContentId = contentItem.getId(); } Response.ResponseBuilder responseBuilder; if (createResponse.getCreatedMetadata() != null) { ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream( createResponse.getCreatedMetadata()); responseBuilder = Response .ok(byteArrayInputStream, createResponse.getCreatedMetadataMimeType()); } else { responseBuilder = Response.ok(); } // If content was stored in content repository, i.e., STORE or STORE_AND_PROCESS, // then set location URI in HTTP header. However, the location URI is not the // physical location in the content repository as ths is hidden from the client. if (requestDirective != Request.Directive.PROCESS) { responseBuilder.status(Response.Status.CREATED); // responseBuilder.location( new URI( "/" + createdContentId ) ); UriBuilder uriBuilder = UriBuilder.fromUri(uriInfo.getBaseUri()); uriBuilder = uriBuilder.path("/" + createdContentId); responseBuilder.location(uriBuilder.build()); responseBuilder.header(CONTENT_ID_HTTP_HEADER, createdContentId); if (contentItem != null) { LOGGER.debug("Content-URI = {}", contentItem.getUri()); responseBuilder.header(CONTENT_URI_HTTP_HEADER, contentItem.getUri()); } } addHttpHeaders(createResponse, responseBuilder); response = responseBuilder.build(); } else { Response.ResponseBuilder responseBuilder = Response.notModified(); response = responseBuilder.build(); } } catch (Exception e) { LOGGER.warn("Exception caught during create", e); throw new ContentEndpointException("Bad request, could not create content", Response.Status.BAD_REQUEST); } LOGGER.debug("createdContentId = [{}]", createdContentId); LOGGER.trace("EXITING: doCreate"); return response; } protected Response doRead(String id) throws ContentEndpointException { LOGGER.trace("ENTERING: doRead"); if (id == null) { throw new ContentEndpointException("Cannot read content. ID is null.", Response.Status.BAD_REQUEST); } Response response = null; try { ReadRequest readRequest = new ReadRequestImpl(id, null); ReadResponse readResponse = contentFramework.read(readRequest); ContentItem item = readResponse.getContentItem(); InputStream result = item.getInputStream(); Response.ResponseBuilder builder = Response.ok(result); String fileName = item.getFilename(); if (fileName != null) { // TODO replace with HTTPHeaders.CONTENT_DISPOSITION when upgraded to v2.0 of // javax.ws.rs jar file builder.header(CONTENT_DISPOSITION, "inline; filename=" + fileName); } String mimeType = item.getMimeTypeRawData(); if (mimeType != null) { builder.type(mimeType); } else { LOGGER.warn("Unable to determine mime type, defaulting to {}.", DEFAULT_MIME_TYPE); builder.type(DEFAULT_MIME_TYPE); } try { builder.header(HttpHeaders.CONTENT_LENGTH, item.getSize()); } catch (IOException e) { LOGGER.debug( "Total number of bytes is unknown, not sending a length with the response: ", e); } response = builder.build(); } catch (Exception e) { LOGGER.error("Error retrieving item from content framework.", e); throw new ContentEndpointException("Content Item " + id + " not found.", Response.Status.NOT_FOUND); } LOGGER.trace("EXITING: doRead"); return response; } protected Response doUpdate(InputStream stream, String id, String contentType, String directive, String contentUri) throws ContentEndpointException { LOGGER.trace("ENTERING: doUpdate"); Request.Directive requestDirective = Request.Directive.valueOf(directive); if (stream == null) { throw new ContentEndpointException("Cannot update content. InputStream is null.", Response.Status.BAD_REQUEST); } if (id == null && requestDirective != Request.Directive.PROCESS) { throw new ContentEndpointException("Cannot update content. ID is null.", Response.Status.BAD_REQUEST); } if (contentUri == null && requestDirective == Request.Directive.PROCESS) { throw new ContentEndpointException("Cannot update content. Content URI is null.", Response.Status.BAD_REQUEST); } if (contentType == null) { throw new ContentEndpointException("Cannot update content. Content-Type is null.", Response.Status.BAD_REQUEST); } Response response = null; LOGGER.debug("Preparing content item"); ContentItem itemToUpdate = new IncomingContentItem(id, stream, contentType); itemToUpdate.setUri(contentUri); ContentItem updatedItem = null; try { UpdateRequest updateRequest = new UpdateRequestImpl(itemToUpdate, null); UpdateResponse updateResponse = contentFramework .update(updateRequest, requestDirective); if (updateResponse != null) { updatedItem = updateResponse.getUpdatedContentItem(); Response.ResponseBuilder responseBuilder; if (updateResponse.getUpdatedMetadata() != null) { ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream( updateResponse.getUpdatedMetadata()); responseBuilder = Response .ok(byteArrayInputStream, updateResponse.getUpdatedMetadataMimeType()); } else { responseBuilder = Response.ok(); } responseBuilder.header(CONTENT_ID_HTTP_HEADER, updatedItem.getId()); addHttpHeaders(updateResponse, responseBuilder); response = responseBuilder.build(); } else { Response.ResponseBuilder responseBuilder = Response.notModified(); response = responseBuilder.build(); } } catch (Exception e) { LOGGER.error("Error updating item in content framework", e); throw new ContentEndpointException("Content Item " + id + " not found.", Response.Status.NOT_FOUND); } LOGGER.trace("EXITING: doUpdate"); return response; } protected Response executeDelete(String id, String directive, String contentUri) throws ContentEndpointException { LOGGER.trace("ENTERING: executeDelete"); Request.Directive requestDirective = Request.Directive.valueOf(directive); if (id == null && requestDirective != Request.Directive.PROCESS) { throw new ContentEndpointException("Cannot delete content. ID is null.", Response.Status.BAD_REQUEST); } if (contentUri == null && requestDirective == Request.Directive.PROCESS) { throw new ContentEndpointException("Cannot delete content. Content URI is null.", Response.Status.BAD_REQUEST); } ContentItem itemToDelete = new IncomingContentItem(id, null, null); itemToDelete.setUri(contentUri); Response response = null; try { DeleteRequest deleteRequest = new DeleteRequestImpl(itemToDelete, null); DeleteResponse deleteResponse = contentFramework .delete(deleteRequest, requestDirective); if (requestDirective == Request.Directive.PROCESS) { LOGGER.debug("Deleted content item with URI = {}", contentUri); } else { LOGGER.debug("Deleted content item with id = {}", id); } if (deleteResponse != null && deleteResponse.isFileDeleted()) { Response.ResponseBuilder responseBuilder = Response.ok(); responseBuilder.status(Response.Status.NO_CONTENT); responseBuilder .header(CONTENT_ID_HTTP_HEADER, deleteResponse.getContentItem().getId()); addHttpHeaders(deleteResponse, responseBuilder); response = responseBuilder.build(); } else { Response.ResponseBuilder responseBuilder = Response .ok("Content Item " + id + " not deleted"); responseBuilder.status(Response.Status.NOT_FOUND); response = responseBuilder.build(); } } catch (ContentFrameworkException e) { LOGGER.error("Error deleting item from content framework", e); throw new ContentEndpointException("Content Item " + id + " not found.", Response.Status.NOT_FOUND); } LOGGER.trace("EXITING: executeDelete"); return response; } // Add all response properties as HTTP headers in response. // Endpoint does not care what the response properties are - the component // that added them, e.g., ContentPlugin, by putting them in the responseProperties // vs. properties of the Response intended them for public distribution. private <T extends Request> void addHttpHeaders(ddf.content.operation.Response<T> response, Response.ResponseBuilder responseBuilder) { if (response.hasResponseProperties()) { for (String propertyName : (Set<String>) response.getResponsePropertyNames()) { String propertyValue = response.getResponsePropertyValue(propertyName); if (propertyValue != null && !propertyValue.isEmpty()) { LOGGER.debug("propertyName = [{}] has value [{}]", propertyName, propertyValue); responseBuilder.header(propertyName, propertyValue); } } } } protected class CreateInfo { InputStream stream = null; String filename = null; String contentType = null; public InputStream getStream() { return stream; } public void setStream(InputStream stream) { this.stream = stream; } public String getFilename() { return filename; } public void setFilename(String filename) { this.filename = filename; } public String getContentType() { return contentType; } public void setContentType(String contentType) { this.contentType = contentType; } } }