/** * 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 org.codice.ddf.endpoints.rest; import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.InputStream; import java.io.Serializable; import java.io.UnsupportedEncodingException; import java.net.URI; import java.net.URLDecoder; import java.nio.charset.StandardCharsets; import java.time.Instant; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; import java.util.Collections; import java.util.Date; import java.util.HashMap; import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Optional; import javax.activation.MimeType; import javax.activation.MimeTypeParseException; import javax.servlet.http.HttpServletRequest; import javax.ws.rs.Consumes; import javax.ws.rs.DELETE; import javax.ws.rs.Encoded; import javax.ws.rs.GET; import javax.ws.rs.HEAD; import javax.ws.rs.POST; import javax.ws.rs.PUT; import javax.ws.rs.Path; import javax.ws.rs.PathParam; import javax.ws.rs.QueryParam; import javax.ws.rs.core.Context; import javax.ws.rs.core.HttpHeaders; import javax.ws.rs.core.MultivaluedMap; import javax.ws.rs.core.Response; import javax.ws.rs.core.Response.ResponseBuilder; import javax.ws.rs.core.Response.Status; import javax.ws.rs.core.UriBuilder; import javax.ws.rs.core.UriInfo; import org.apache.commons.codec.CharEncoding; import org.apache.commons.io.FilenameUtils; import org.apache.commons.io.IOUtils; import org.apache.commons.lang.StringUtils; import org.apache.commons.lang.exception.ExceptionUtils; import org.apache.cxf.jaxrs.ext.multipart.Attachment; import org.apache.cxf.jaxrs.ext.multipart.MultipartBody; import org.codice.ddf.platform.util.TemporaryFileBackedOutputStream; import org.opengis.filter.Filter; import org.osgi.framework.Bundle; import org.osgi.framework.BundleContext; import org.osgi.framework.FrameworkUtil; import org.osgi.framework.InvalidSyntaxException; import org.osgi.framework.ServiceReference; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import com.google.common.io.ByteSource; import com.google.common.io.FileBackedOutputStream; import ddf.catalog.CatalogFramework; import ddf.catalog.Constants; import ddf.catalog.content.data.impl.ContentItemImpl; import ddf.catalog.content.operation.CreateStorageRequest; import ddf.catalog.content.operation.UpdateStorageRequest; import ddf.catalog.content.operation.impl.CreateStorageRequestImpl; import ddf.catalog.content.operation.impl.UpdateStorageRequestImpl; import ddf.catalog.data.Attribute; import ddf.catalog.data.AttributeDescriptor; import ddf.catalog.data.AttributeType; import ddf.catalog.data.BinaryContent; import ddf.catalog.data.ContentType; import ddf.catalog.data.Metacard; import ddf.catalog.data.MetacardCreationException; import ddf.catalog.data.MetacardType; import ddf.catalog.data.Result; import ddf.catalog.data.impl.AttributeImpl; import ddf.catalog.data.impl.BinaryContentImpl; import ddf.catalog.data.impl.MetacardImpl; import ddf.catalog.federation.FederationException; import ddf.catalog.filter.FilterBuilder; import ddf.catalog.operation.CreateRequest; import ddf.catalog.operation.CreateResponse; import ddf.catalog.operation.QueryResponse; import ddf.catalog.operation.SourceInfoResponse; import ddf.catalog.operation.UpdateRequest; import ddf.catalog.operation.impl.CreateRequestImpl; import ddf.catalog.operation.impl.DeleteRequestImpl; import ddf.catalog.operation.impl.QueryImpl; import ddf.catalog.operation.impl.QueryRequestImpl; import ddf.catalog.operation.impl.SourceInfoRequestEnterprise; import ddf.catalog.operation.impl.UpdateRequestImpl; import ddf.catalog.resource.DataUsageLimitExceededException; import ddf.catalog.resource.Resource; import ddf.catalog.source.IngestException; import ddf.catalog.source.InternalIngestException; import ddf.catalog.source.SourceDescriptor; import ddf.catalog.source.SourceUnavailableException; import ddf.catalog.source.UnsupportedQueryException; import ddf.catalog.transform.CatalogTransformerException; import ddf.catalog.transform.InputTransformer; import ddf.mime.MimeTypeMapper; import ddf.mime.MimeTypeResolutionException; import ddf.mime.MimeTypeResolver; import ddf.mime.MimeTypeToTransformerMapper; import net.minidev.json.JSONArray; import net.minidev.json.JSONObject; import net.minidev.json.JSONValue; @Path("/") public class RESTEndpoint implements RESTService { static final String DEFAULT_METACARD_TRANSFORMER = "xml"; private static final String DEFAULT_FILE_EXTENSION = "bin"; private static final String BYTES_TO_SKIP = "BytesToSkip"; private static final Logger LOGGER = LoggerFactory.getLogger(RESTEndpoint.class); private static final Logger INGEST_LOGGER = LoggerFactory.getLogger(Constants.INGEST_LOGGER_NAME); private static final String HEADER_RANGE = "Range"; private static final String HEADER_ACCEPT_RANGES = "Accept-Ranges"; private static final String HEADER_CONTENT_LENGTH = "Content-Length"; private static final String HEADER_CONTENT_DISPOSITION = "Content-Disposition"; private static final String FILE_ATTACHMENT_CONTENT_ID = "file"; private static final String FILENAME_CONTENT_DISPOSITION_PARAMETER_NAME = "filename"; private static final String BYTES = "bytes"; private static final String BYTES_EQUAL = "bytes="; private static final String JSON_MIME_TYPE_STRING = "application/json"; private static final String DEFAULT_MIME_TYPE = "application/octet-stream"; private static final String DEFAULT_FILE_NAME = "file"; /** * 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. */ private static final List<String> REFINEABLE_MIME_TYPES = Arrays.asList(DEFAULT_MIME_TYPE, "text/plain"); private static MimeType jsonMimeType = null; static { MimeType mime = null; try { mime = new MimeType(JSON_MIME_TYPE_STRING); } catch (MimeTypeParseException e) { LOGGER.info("Failed to create json mimetype."); } jsonMimeType = mime; } private List<MetacardType> metacardTypes; private MimeTypeMapper mimeTypeMapper; private FilterBuilder filterBuilder; private CatalogFramework catalogFramework; private MimeTypeToTransformerMapper mimeTypeToTransformerMapper; private MimeTypeResolver tikaMimeTypeResolver; public RESTEndpoint(CatalogFramework framework) { LOGGER.trace("Constructing REST Endpoint"); this.catalogFramework = framework; LOGGER.trace(("Rest Endpoint constructed successfully")); } BundleContext getBundleContext() { Bundle bundle = FrameworkUtil.getBundle(RESTEndpoint.class); return bundle.getBundleContext(); } /** * REST Head. Retrieves information regarding the entry specified by the id. * This can be used to verify that the Range header is supported (the Accept-Ranges header is returned) and to * get the size of the requested resource for use in Content-Range requests. * * @param id * @param uriInfo * @param httpRequest * @return */ @HEAD @Path("/{id}") public Response getHeaders(@PathParam("id") String id, @Context UriInfo uriInfo, @Context HttpServletRequest httpRequest) { return getHeaders(null, id, uriInfo, httpRequest); } /** * REST Head. Returns headers only. Primarily used to let the client know that range requests (though limited) * are accepted. * * @param sourceid * @param id * @param uriInfo * @param httpRequest * @return */ @HEAD @Path("/sources/{sourceid}/{id}") public Response getHeaders(@PathParam("sourceid") String sourceid, @PathParam("id") String id, @Context UriInfo uriInfo, @Context HttpServletRequest httpRequest) { Response response; Response.ResponseBuilder responseBuilder; QueryResponse queryResponse; Metacard card = null; LOGGER.trace("getHeaders"); URI absolutePath = uriInfo.getAbsolutePath(); MultivaluedMap<String, String> map = uriInfo.getQueryParameters(); if (id != null) { LOGGER.debug("Got id: {}", id); LOGGER.debug("Map of query parameters: \n{}", map.toString()); Map<String, Serializable> convertedMap = convert(map); convertedMap.put("url", absolutePath.toString()); LOGGER.debug("Map converted, retrieving product."); // default to xml if no transformer specified try { String transformer = DEFAULT_METACARD_TRANSFORMER; Filter filter = getFilterBuilder().attribute(Metacard.ID) .is() .equalTo() .text(id); Collection<String> sources = null; if (sourceid != null) { sources = new ArrayList<String>(); sources.add(sourceid); } QueryRequestImpl request = new QueryRequestImpl(new QueryImpl(filter), sources); request.setProperties(convertedMap); queryResponse = catalogFramework.query(request, null); // pull the metacard out of the blocking queue List<Result> results = queryResponse.getResults(); // TODO: should be poll? do we want to specify a timeout? (will // return null if timeout elapsed) if (results != null && !results.isEmpty()) { card = results.get(0) .getMetacard(); } if (card == null) { throw new ServerErrorException("Unable to retrieve requested metacard.", Status.NOT_FOUND); } LOGGER.debug("Calling transform."); final BinaryContent content = catalogFramework.transform(card, transformer, convertedMap); LOGGER.debug("Read and transform complete, preparing response."); responseBuilder = Response.noContent(); // Add the Accept-ranges header to let the client know that we accept ranges in bytes responseBuilder.header(HEADER_ACCEPT_RANGES, BYTES); String filename = null; if (content instanceof Resource) { // If we got a resource, we can extract the filename. filename = ((Resource) content).getName(); } else { String fileExtension = getFileExtensionForMimeType(content.getMimeTypeValue()); if (StringUtils.isNotBlank(fileExtension)) { filename = id + fileExtension; } } if (StringUtils.isNotBlank(filename)) { LOGGER.debug("filename: {}", filename); responseBuilder.header(HEADER_CONTENT_DISPOSITION, "inline; filename=\"" + filename + "\""); } long size = content.getSize(); if (size > 0) { responseBuilder.header(HEADER_CONTENT_LENGTH, size); } response = responseBuilder.build(); } catch (FederationException e) { String exceptionMessage = "READ failed due to unexpected exception: "; LOGGER.info(exceptionMessage, e); throw new ServerErrorException(exceptionMessage, Status.INTERNAL_SERVER_ERROR); } catch (CatalogTransformerException e) { String exceptionMessage = "Unable to transform Metacard. Try different transformer: "; LOGGER.info(exceptionMessage, e); throw new ServerErrorException(exceptionMessage, Status.INTERNAL_SERVER_ERROR); } catch (SourceUnavailableException e) { String exceptionMessage = "Cannot obtain query results because source is unavailable: "; LOGGER.info(exceptionMessage, e); throw new ServerErrorException(exceptionMessage, Status.INTERNAL_SERVER_ERROR); } catch (UnsupportedQueryException e) { String exceptionMessage = "Specified query is unsupported. Change query and resubmit: "; LOGGER.info(exceptionMessage, e); throw new ServerErrorException(exceptionMessage, Status.BAD_REQUEST); // The catalog framework will throw this if any of the transformers blow up. We need to // catch this exception // here or else execution will return to CXF and we'll lose this message and end up with // a huge stack trace // in a GUI or whatever else is connected to this endpoint } catch (IllegalArgumentException e) { throw new ServerErrorException(e, Status.BAD_REQUEST); } } else { throw new ServerErrorException("No ID specified.", Status.BAD_REQUEST); } return response; } /** * REST Get. Retrieves the metadata entry specified by the id. Transformer argument is optional, * but is used to specify what format the data should be returned. * * @param id * @param transformerParam (OPTIONAL) * @param uriInfo * @return * @throws ServerErrorException */ @GET @Path("/{id}") public Response getDocument(@PathParam("id") String id, @QueryParam("transform") String transformerParam, @Context UriInfo uriInfo, @Context HttpServletRequest httpRequest) { return getDocument(null, id, transformerParam, uriInfo, httpRequest); } /** * REST Get. Retrieves information regarding sources available. * * @param uriInfo * @param httpRequest * @return */ @GET @Path(SOURCES_PATH) public Response getDocument(@Context UriInfo uriInfo, @Context HttpServletRequest httpRequest) { BinaryContent content; ResponseBuilder responseBuilder; String sourcesString = null; JSONArray resultsList = new JSONArray(); SourceInfoResponse sources; try { SourceInfoRequestEnterprise sourceInfoRequestEnterprise = new SourceInfoRequestEnterprise(true); sources = catalogFramework.getSourceInfo(sourceInfoRequestEnterprise); for (SourceDescriptor source : sources.getSourceInfo()) { JSONObject sourceObj = new JSONObject(); sourceObj.put("id", source.getSourceId()); sourceObj.put("version", source.getVersion() != null ? source.getVersion() : ""); sourceObj.put("available", Boolean.valueOf(source.isAvailable())); JSONArray contentTypesObj = new JSONArray(); if (source.getContentTypes() != null) { for (ContentType contentType : source.getContentTypes()) { if (contentType != null && contentType.getName() != null) { JSONObject contentTypeObj = new JSONObject(); contentTypeObj.put("name", contentType.getName()); contentTypeObj.put("version", contentType.getVersion() != null ? contentType.getVersion() : ""); contentTypesObj.add(contentTypeObj); } } } sourceObj.put("contentTypes", contentTypesObj); resultsList.add(sourceObj); } } catch (SourceUnavailableException e) { LOGGER.info("Unable to retrieve Sources. {}", e.getMessage()); LOGGER.debug("Unable to retrieve Sources", e); } sourcesString = JSONValue.toJSONString(resultsList); content = new BinaryContentImpl(new ByteArrayInputStream(sourcesString.getBytes( StandardCharsets.UTF_8)), jsonMimeType); responseBuilder = Response.ok(content.getInputStream(), content.getMimeTypeValue()); // Add the Accept-ranges header to let the client know that we accept ranges in bytes responseBuilder.header(HEADER_ACCEPT_RANGES, BYTES); return responseBuilder.build(); } /** * REST Get. Retrieves the metadata entry specified by the id from the federated source * specified by sourceid. Transformer argument is optional, but is used to specify what format * the data should be returned. * * @param encodedSourceId * @param encodedId * @param transformerParam * @param uriInfo * @return */ @GET @Path("/sources/{sourceid}/{id}") public Response getDocument(@Encoded @PathParam("sourceid") String encodedSourceId, @Encoded @PathParam("id") String encodedId, @QueryParam("transform") String transformerParam, @Context UriInfo uriInfo, @Context HttpServletRequest httpRequest) { Response response = null; Response.ResponseBuilder responseBuilder; QueryResponse queryResponse; Metacard card = null; LOGGER.trace("GET"); URI absolutePath = uriInfo.getAbsolutePath(); MultivaluedMap<String, String> map = uriInfo.getQueryParameters(); if (encodedId != null) { LOGGER.debug("Got id: {}", encodedId); LOGGER.debug("Got service: {}", transformerParam); LOGGER.debug("Map of query parameters: \n{}", map.toString()); Map<String, Serializable> convertedMap = convert(map); convertedMap.put("url", absolutePath.toString()); LOGGER.debug("Map converted, retrieving product."); // default to xml if no transformer specified try { String id = URLDecoder.decode(encodedId, CharEncoding.UTF_8); String transformer = DEFAULT_METACARD_TRANSFORMER; if (transformerParam != null) { transformer = transformerParam; } Filter filter = getFilterBuilder().attribute(Metacard.ID) .is() .equalTo() .text(id); Collection<String> sources = null; if (encodedSourceId != null) { String sourceid = URLDecoder.decode(encodedSourceId, CharEncoding.UTF_8); sources = new ArrayList<String>(); sources.add(sourceid); } QueryRequestImpl request = new QueryRequestImpl(new QueryImpl(filter), sources); request.setProperties(convertedMap); queryResponse = catalogFramework.query(request, null); // pull the metacard out of the blocking queue List<Result> results = queryResponse.getResults(); // TODO: should be poll? do we want to specify a timeout? (will // return null if timeout elapsed) if (results != null && !results.isEmpty()) { card = results.get(0) .getMetacard(); } if (card == null) { throw new ServerErrorException("Unable to retrieve requested metacard.", Status.NOT_FOUND); } // Check for Range header set the value in the map appropriately so that the catalogFramework // can take care of the skipping long bytesToSkip = getRangeStart(httpRequest); if (bytesToSkip > 0) { LOGGER.debug("Bytes to skip: {}", String.valueOf(bytesToSkip)); convertedMap.put(BYTES_TO_SKIP, bytesToSkip); } LOGGER.debug("Calling transform."); final BinaryContent content = catalogFramework.transform(card, transformer, convertedMap); LOGGER.debug("Read and transform complete, preparing response."); responseBuilder = Response.ok(content.getInputStream(), content.getMimeTypeValue()); // Add the Accept-ranges header to let the client know that we accept ranges in bytes responseBuilder.header(HEADER_ACCEPT_RANGES, BYTES); String filename = null; if (content instanceof Resource) { // If we got a resource, we can extract the filename. filename = ((Resource) content).getName(); } else { String fileExtension = getFileExtensionForMimeType(content.getMimeTypeValue()); if (StringUtils.isNotBlank(fileExtension)) { filename = id + fileExtension; } } if (StringUtils.isNotBlank(filename)) { LOGGER.debug("filename: {}", filename); responseBuilder.header(HEADER_CONTENT_DISPOSITION, "inline; filename=\"" + filename + "\""); } long size = content.getSize(); if (size > 0) { responseBuilder.header(HEADER_CONTENT_LENGTH, size); } response = responseBuilder.build(); } catch (FederationException e) { String exceptionMessage = "READ failed due to unexpected exception: "; LOGGER.info(exceptionMessage, e); throw new ServerErrorException(exceptionMessage, Status.INTERNAL_SERVER_ERROR); } catch (CatalogTransformerException e) { String exceptionMessage = "Unable to transform Metacard. Try different transformer: "; LOGGER.info(exceptionMessage, e); throw new ServerErrorException(exceptionMessage, Status.INTERNAL_SERVER_ERROR); } catch (SourceUnavailableException e) { String exceptionMessage = "Cannot obtain query results because source is unavailable: "; LOGGER.info(exceptionMessage, e); throw new ServerErrorException(exceptionMessage, Status.INTERNAL_SERVER_ERROR); } catch (UnsupportedQueryException e) { String exceptionMessage = "Specified query is unsupported. Change query and resubmit: "; LOGGER.info(exceptionMessage, e); throw new ServerErrorException(exceptionMessage, Status.BAD_REQUEST); } catch (DataUsageLimitExceededException e) { String exceptionMessage = "Unable to process request. Data usage limit exceeded: "; LOGGER.debug(exceptionMessage, e); throw new ServerErrorException(exceptionMessage, Status.REQUEST_ENTITY_TOO_LARGE); // The catalog framework will throw this if any of the transformers blow up. // We need to catch this exception here or else execution will return to CXF and // we'll lose this message and end up with a huge stack trace in a GUI or whatever // else is connected to this endpoint } catch (RuntimeException | UnsupportedEncodingException e) { String exceptionMessage = "Unknown error occurred while processing request: "; LOGGER.info(exceptionMessage, e); throw new ServerErrorException(exceptionMessage, Status.INTERNAL_SERVER_ERROR); } } else { throw new ServerErrorException("No ID specified.", Status.BAD_REQUEST); } return response; } @POST @Path("/metacard") public Response createMetacard(MultipartBody multipartBody, @Context UriInfo requestUriInfo, @QueryParam("transform") String transformerParam) { LOGGER.trace("ENTERING: createMetacard"); String contentUri = multipartBody.getAttachmentObject("contentUri", String.class); LOGGER.debug("contentUri = {}", contentUri); InputStream stream = null; String filename = null; String contentType = null; Response response = null; String transformer = DEFAULT_METACARD_TRANSFORMER; if (transformerParam != null) { transformer = transformerParam; } Attachment contentPart = multipartBody.getAttachment(FILE_ATTACHMENT_CONTENT_ID); if (contentPart != null) { // Example Content-Type header: // Content-Type: application/json;id=geojson if (contentPart.getContentType() != null) { contentType = contentPart.getContentType() .toString(); } // 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(); } } catch (IOException e) { LOGGER.info("IOException reading stream from file attachment in multipart body", e); } } else { LOGGER.debug("No file contents attachment found"); } MimeType mimeType = null; if (contentType != null) { try { mimeType = new MimeType(contentType); } catch (MimeTypeParseException e) { LOGGER.debug("Unable to create MimeType from raw data {}", contentType); } } else { LOGGER.debug("No content type specified in request"); } try { Metacard metacard = generateMetacard(mimeType, "assigned-when-ingested", stream, null); String metacardId = metacard.getId(); LOGGER.debug("Metacard {} created", metacardId); LOGGER.debug("Transforming metacard {} to {} to be able to return it to client", metacardId, transformer); final BinaryContent content = catalogFramework.transform(metacard, transformer, null); LOGGER.debug("Metacard to {} transform complete for {}, preparing response.", transformer, metacardId); Response.ResponseBuilder responseBuilder = Response.ok(content.getInputStream(), content.getMimeTypeValue()); response = responseBuilder.build(); } catch (MetacardCreationException | CatalogTransformerException e) { throw new ServerErrorException("Unable to create metacard", Status.BAD_REQUEST); } LOGGER.trace("EXITING: createMetacard"); return response; } /** * REST Put. Updates the specified entry with the provided document. * * @param id * @param message * @return */ @PUT @Path("/{id}") @Consumes({"text/*", "application/*"}) public Response updateDocument(@PathParam("id") String id, @Context HttpHeaders headers, @Context HttpServletRequest httpRequest, @QueryParam("transform") String transformerParam, InputStream message) { return updateDocument(id, headers, httpRequest, null, transformerParam, message); } /** * REST Put. Updates the specified entry with the provided document. * * @param id * @param message * @return */ @PUT @Path("/{id}") @Consumes("multipart/*") public Response updateDocument(@PathParam("id") String id, @Context HttpHeaders headers, @Context HttpServletRequest httpRequest, MultipartBody multipartBody, @QueryParam("transform") String transformerParam, InputStream message) { LOGGER.trace("PUT"); Response response; try { if (id != null && message != null) { MimeType mimeType = getMimeType(headers); CreateInfo createInfo = null; if (multipartBody != null) { List<Attachment> contentParts = multipartBody.getAllAttachments(); if (contentParts != null && contentParts.size() > 0) { createInfo = parseAttachments(contentParts, transformerParam); } else { LOGGER.debug("No file contents attachment found"); } } if (createInfo == null) { UpdateRequest updateRequest = new UpdateRequestImpl(id, generateMetacard(mimeType, id, message, transformerParam)); catalogFramework.update(updateRequest); } else { UpdateStorageRequest streamUpdateRequest = new UpdateStorageRequestImpl( Collections.singletonList( new IncomingContentItem(id, createInfo.getStream(), createInfo.getContentType(), createInfo.getFilename(), 0, createInfo.getMetacard())), null); catalogFramework.update(streamUpdateRequest); } LOGGER.debug("Metacard {} updated.", id); response = Response.ok() .build(); } else { String errorResponseString = "Both ID and content are needed to perform UPDATE."; LOGGER.info(errorResponseString); throw new ServerErrorException(errorResponseString, Status.BAD_REQUEST); } } catch (SourceUnavailableException e) { String exceptionMessage = "Cannot update catalog entry: Source is unavailable: "; LOGGER.info(exceptionMessage, e); throw new ServerErrorException(exceptionMessage, Status.INTERNAL_SERVER_ERROR); } catch (InternalIngestException e) { String exceptionMessage = "Error cataloging updated metadata: "; LOGGER.info(exceptionMessage, e); throw new ServerErrorException(exceptionMessage, Status.INTERNAL_SERVER_ERROR); } catch (MetacardCreationException | IngestException e) { String exceptionMessage = "Error cataloging updated metadata: "; LOGGER.info(exceptionMessage, e); throw new ServerErrorException(exceptionMessage, Status.BAD_REQUEST); } return response; } @POST @Consumes({"text/*", "application/*"}) public Response addDocument(@Context HttpHeaders headers, @Context UriInfo requestUriInfo, @Context HttpServletRequest httpRequest, @QueryParam("transform") String transformerParam, InputStream message) { return addDocument(headers, requestUriInfo, httpRequest, null, transformerParam, message); } /** * REST Post. Creates a new metadata entry in the catalog. * * @param message * @return */ @POST @Consumes("multipart/*") public Response addDocument(@Context HttpHeaders headers, @Context UriInfo requestUriInfo, @Context HttpServletRequest httpRequest, MultipartBody multipartBody, @QueryParam("transform") String transformerParam, InputStream message) { LOGGER.debug("POST"); Response response; MimeType mimeType = getMimeType(headers); try { if (message != null) { CreateInfo createInfo = null; if (multipartBody != null) { List<Attachment> contentParts = multipartBody.getAllAttachments(); if (contentParts != null && contentParts.size() > 0) { createInfo = parseAttachments(contentParts, transformerParam); } else { LOGGER.debug("No file contents attachment found"); } } CreateResponse createResponse; if (createInfo == null) { CreateRequest createRequest = new CreateRequestImpl( generateMetacard(mimeType, null, message, transformerParam)); createResponse = catalogFramework.create(createRequest); } else { CreateStorageRequest streamCreateRequest = new CreateStorageRequestImpl( Collections.singletonList( new IncomingContentItem(createInfo.getStream(), createInfo.getContentType(), createInfo.getFilename(), createInfo.getMetacard())), null); createResponse = catalogFramework.create(streamCreateRequest); } String id = createResponse.getCreatedMetacards() .get(0) .getId(); LOGGER.debug("Create Response id [{}]", id); UriBuilder uriBuilder = requestUriInfo.getAbsolutePathBuilder() .path("/" + id); ResponseBuilder responseBuilder = Response.created(uriBuilder.build()); responseBuilder.header(Metacard.ID, id); response = responseBuilder.build(); LOGGER.debug("Entry successfully saved, id: {}", id); if (INGEST_LOGGER.isInfoEnabled()) { INGEST_LOGGER.info("Entry successfully saved, id: {}", id); } } else { String errorMessage = "No content found, cannot do CREATE."; LOGGER.info(errorMessage); throw new ServerErrorException(errorMessage, Status.BAD_REQUEST); } } catch (SourceUnavailableException e) { String exceptionMessage = "Cannot create catalog entry because source is unavailable: "; LOGGER.info(exceptionMessage, e); // Catalog framework logs these exceptions to the ingest logger so we don't have to. throw new ServerErrorException(exceptionMessage, Status.INTERNAL_SERVER_ERROR); } catch (InternalIngestException e) { String exceptionMessage = "Error while storing entry in catalog: "; LOGGER.info(exceptionMessage, e); // Catalog framework logs these exceptions to the ingest logger so we don't have to. throw new ServerErrorException(exceptionMessage, Status.INTERNAL_SERVER_ERROR); } catch (MetacardCreationException | IngestException e) { String exceptionMessage = "Error while storing entry in catalog: "; LOGGER.info(exceptionMessage, e); // Catalog framework logs these exceptions to the ingest logger so we don't have to. throw new ServerErrorException(exceptionMessage, Status.BAD_REQUEST); } finally { IOUtils.closeQuietly(message); } return response; } CreateInfo parseAttachments(List<Attachment> contentParts, String transformerParam) { if (contentParts.size() == 1) { Attachment contentPart = contentParts.get(0); return parseAttachment(contentPart); } List<Attribute> attributes = new ArrayList<>(contentParts.size()); Metacard metacard = null; CreateInfo createInfo = null; for (Attachment attachment : contentParts) { String name = attachment.getContentDisposition() .getParameter("name"); String parsedName = (name.startsWith("parse.")) ? name.substring(6) : name; try { InputStream inputStream = attachment.getDataHandler() .getInputStream(); if (name.equals("parse.resource")) { createInfo = parseAttachment(attachment); } else if (name.equals("parse.metadata")) { metacard = parseMetadata(transformerParam, metacard, attachment, inputStream); } else { parseOverrideAttributes(attributes, parsedName, inputStream); } } catch (IOException e) { LOGGER.debug( "Unable to get input stream for mime attachment. Ignoring override attribute: {}", name, e); } } if (createInfo == null) { throw new IllegalArgumentException("No parse.resource specified in request."); } if (metacard == null) { metacard = new MetacardImpl(); } for (Attribute attribute : attributes) { metacard.setAttribute(attribute); } createInfo.setMetacard(metacard); return createInfo; } private void parseOverrideAttributes(List<Attribute> attributes, String parsedName, InputStream inputStream) { Optional<AttributeType.AttributeFormat> attributeFormat = metacardTypes.stream() .map(metacardType -> metacardType.getAttributeDescriptor(parsedName)) .filter(Objects::nonNull) .findFirst() .map(AttributeDescriptor::getType) .map(AttributeType::getAttributeFormat); attributeFormat.ifPresent(attributeFormat1 -> { try { switch (attributeFormat1) { case XML: case GEOMETRY: case STRING: attributes.add(new AttributeImpl(parsedName, IOUtils.toString(inputStream))); break; case BOOLEAN: attributes.add(new AttributeImpl(parsedName, Boolean.valueOf(IOUtils.toString(inputStream)))); break; case SHORT: attributes.add(new AttributeImpl(parsedName, Short.valueOf(IOUtils.toString(inputStream)))); break; case LONG: attributes.add(new AttributeImpl(parsedName, Long.valueOf(IOUtils.toString(inputStream)))); break; case INTEGER: attributes.add(new AttributeImpl(parsedName, Integer.valueOf(IOUtils.toString(inputStream)))); break; case FLOAT: attributes.add(new AttributeImpl(parsedName, Float.valueOf(IOUtils.toString(inputStream)))); break; case DOUBLE: attributes.add(new AttributeImpl(parsedName, Double.valueOf(IOUtils.toString(inputStream)))); break; case DATE: Instant instant = Instant.parse(IOUtils.toString(inputStream)); if (instant == null) { break; } attributes.add(new AttributeImpl(parsedName, Date.from(instant))); break; case BINARY: attributes.add(new AttributeImpl(parsedName, IOUtils.toByteArray(inputStream))); break; case OBJECT: LOGGER.debug("Object type not supported for override"); break; } } catch (IOException e) { LOGGER.debug("Unable to read attribute to override", e); } finally { IOUtils.closeQuietly(inputStream); } }); } private Metacard parseMetadata(String transformerParam, Metacard metacard, Attachment attachment, InputStream inputStream) { String transformer = DEFAULT_METACARD_TRANSFORMER; if (transformerParam != null) { transformer = transformerParam; } try { MimeType mimeType = new MimeType(attachment.getContentType() .toString()); metacard = generateMetacard(mimeType, "assigned-when-ingested", inputStream, transformer); } catch (MimeTypeParseException | MetacardCreationException e) { LOGGER.debug("Unable to parse metadata {}", attachment.getContentType() .toString()); } finally { IOUtils.closeQuietly(inputStream); } return metacard; } 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.info("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(); } if (contentPart.getContentDisposition() != null) { 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); 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; } /** * REST Delete. Deletes a record from the catalog. * * @param id * @return */ @DELETE @Path("/{id}") public Response deleteDocument(@PathParam("id") String id, @Context HttpServletRequest httpRequest) { LOGGER.debug("DELETE"); Response response; try { if (id != null) { DeleteRequestImpl deleteReq = new DeleteRequestImpl(id); catalogFramework.delete(deleteReq); response = Response.ok(id) .build(); LOGGER.debug("Attempting to delete Metacard with id: {}", id); } else { String errorMessage = "ID of entry not specified, cannot do DELETE."; LOGGER.info(errorMessage); throw new ServerErrorException(errorMessage, Status.BAD_REQUEST); } } catch (SourceUnavailableException ce) { String exceptionMessage = "Could not delete entry from catalog since the source is unavailable: "; LOGGER.info(exceptionMessage, ce); throw new ServerErrorException(exceptionMessage, Status.INTERNAL_SERVER_ERROR); } catch (InternalIngestException e) { String exceptionMessage = "Error deleting entry from catalog: "; LOGGER.info(exceptionMessage, e); throw new ServerErrorException(exceptionMessage, Status.INTERNAL_SERVER_ERROR); } catch (IngestException e) { String exceptionMessage = "Error deleting entry from catalog: "; LOGGER.info(exceptionMessage, e); throw new ServerErrorException(exceptionMessage, Status.BAD_REQUEST); } return response; } private Map<String, Serializable> convert(MultivaluedMap<String, String> map) { Map<String, Serializable> convertedMap = new HashMap<String, Serializable>(); for (Map.Entry<String, List<String>> entry : map.entrySet()) { String key = entry.getKey(); List<String> value = entry.getValue(); if (value.size() == 1) { convertedMap.put(key, value.get(0)); } else { // List is not serializable so we make it a String array convertedMap.put(key, value.toArray()); } } return convertedMap; } private Metacard generateMetacard(MimeType mimeType, String id, InputStream message, String transformerId) throws MetacardCreationException { Metacard generatedMetacard = null; List<InputTransformer> listOfCandidates = mimeTypeToTransformerMapper.findMatches( InputTransformer.class, mimeType); List<String> stackTraceList = new ArrayList<>(); LOGGER.trace("Entering generateMetacard."); LOGGER.debug("List of matches for mimeType [{}]: {}", mimeType, listOfCandidates); try (TemporaryFileBackedOutputStream fileBackedOutputStream = new TemporaryFileBackedOutputStream()) { try { if (null != message) { IOUtils.copy(message, fileBackedOutputStream); } else { throw new MetacardCreationException( "Could not copy bytes of content message. Message was NULL."); } } catch (IOException e) { throw new MetacardCreationException("Could not copy bytes of content message.", e); } Iterator<InputTransformer> it = listOfCandidates.iterator(); if (StringUtils.isNotEmpty(transformerId)) { BundleContext bundleContext = getBundleContext(); Collection<ServiceReference<InputTransformer>> serviceReferences = bundleContext.getServiceReferences( InputTransformer.class, "(id=" + transformerId + ")"); it = serviceReferences.stream() .map(bundleContext::getService) .iterator(); } while (it.hasNext()) { InputTransformer transformer = it.next(); try (InputStream inputStreamMessageCopy = fileBackedOutputStream.asByteSource() .openStream()) { generatedMetacard = transformer.transform(inputStreamMessageCopy); } catch (CatalogTransformerException | IOException e) { List<String> stackTraces = Arrays.asList(ExceptionUtils.getRootCauseStackTrace(e)); stackTraceList.add(String.format("Transformer [%s] could not create metacard.", transformer)); stackTraceList.addAll(stackTraces); LOGGER.debug("Transformer [{}] could not create metacard.", transformer, e); } if (generatedMetacard != null) { break; } } if (generatedMetacard == null) { throw new MetacardCreationException(String.format( "Could not create metacard with mimeType %s : %s", mimeType, StringUtils.join(stackTraceList, "\n"))); } if (id != null) { generatedMetacard.setAttribute(new AttributeImpl(Metacard.ID, id)); } else { LOGGER.debug("Metacard had a null id"); } } catch (IOException e) { throw new MetacardCreationException("Could not create metacard.", e); } catch (InvalidSyntaxException e) { throw new MetacardCreationException("Could not determine transformer", e); } return generatedMetacard; } private MimeType getMimeType(HttpHeaders headers) { List<String> contentTypeList = headers.getRequestHeader(HttpHeaders.CONTENT_TYPE); String singleMimeType = null; if (contentTypeList != null && !contentTypeList.isEmpty()) { singleMimeType = contentTypeList.get(0); LOGGER.debug("Encountered [{}] {}", singleMimeType, HttpHeaders.CONTENT_TYPE); } MimeType mimeType = null; // Sending a null argument to MimeType causes NPE if (singleMimeType != null) { try { mimeType = new MimeType(singleMimeType); } catch (MimeTypeParseException e) { LOGGER.debug("Could not parse mime type from headers.", e); } } return mimeType; } private String getFileExtensionForMimeType(String mimeType) { String fileExtension = this.tikaMimeTypeResolver.getFileExtensionForMimeType(mimeType); LOGGER.debug("Mime Type [{}] resolves to file extension [{}].", mimeType, fileExtension); return fileExtension; } private boolean rangeHeaderExists(HttpServletRequest httpRequest) { boolean response = false; if (null != httpRequest) { if (null != httpRequest.getHeader(HEADER_RANGE)) { response = true; } } return response; } // Return 0 (beginning of stream) if the range header does not exist. private long getRangeStart(HttpServletRequest httpRequest) throws UnsupportedQueryException { long response = 0; if (httpRequest != null) { if (rangeHeaderExists(httpRequest)) { String rangeHeader = httpRequest.getHeader(HEADER_RANGE); String range = getRange(rangeHeader); if (range != null) { response = Long.parseLong(range); } } } return response; } private String getRange(String rangeHeader) throws UnsupportedQueryException { String response = null; if (rangeHeader != null) { if (rangeHeader.startsWith(BYTES_EQUAL)) { String tempString = rangeHeader.substring(BYTES_EQUAL.length()); if (tempString.contains("-")) { response = rangeHeader.substring(BYTES_EQUAL.length(), rangeHeader.lastIndexOf("-")); } else { response = rangeHeader.substring(BYTES_EQUAL.length()); } } else { throw new UnsupportedQueryException("Invalid range header: " + rangeHeader); } } return response; } public MimeTypeToTransformerMapper getMimeTypeToTransformerMapper() { return mimeTypeToTransformerMapper; } public void setMimeTypeToTransformerMapper( MimeTypeToTransformerMapper mimeTypeToTransformerMapper) { this.mimeTypeToTransformerMapper = mimeTypeToTransformerMapper; } public FilterBuilder getFilterBuilder() { return filterBuilder; } public void setFilterBuilder(FilterBuilder filterBuilder) { this.filterBuilder = filterBuilder; } public void setTikaMimeTypeResolver(MimeTypeResolver mimeTypeResolver) { this.tikaMimeTypeResolver = mimeTypeResolver; } public void setMimeTypeMapper(MimeTypeMapper mimeTypeMapper) { this.mimeTypeMapper = mimeTypeMapper; } public void setMetacardTypes(List<MetacardType> metacardTypes) { this.metacardTypes = metacardTypes; } protected static class CreateInfo { InputStream stream = null; String filename = null; String contentType = null; Metacard metacard = 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; } public Metacard getMetacard() { return metacard; } public void setMetacard(Metacard metacard) { this.metacard = metacard; } } protected static class IncomingContentItem extends ContentItemImpl { private InputStream inputStream; public IncomingContentItem(ByteSource byteSource, String mimeTypeRawData, String filename, Metacard metacard) { super(byteSource, mimeTypeRawData, filename, metacard); } public IncomingContentItem(InputStream inputStream, String mimeTypeRawData, String filename, Metacard metacard) { super(null, mimeTypeRawData, filename, metacard); this.inputStream = inputStream; } public IncomingContentItem(String id, InputStream inputStream, String mimeTypeRawData, String filename, long size, Metacard metacard) { super(id, null, mimeTypeRawData, filename, size, metacard); this.inputStream = inputStream; } @Override public InputStream getInputStream() { return inputStream; } } }