Merge branch 'atmire-DS-3651_Range-Header-support'

This commit is contained in:
Andrea Bollini
2017-12-03 04:48:42 +01:00
36 changed files with 1978 additions and 175 deletions

View File

@@ -453,4 +453,8 @@ public class BitstreamServiceImpl extends DSpaceObjectServiceImpl<Bitstream> imp
public List<Bitstream> getNotReferencedBitstreams(Context context) throws SQLException {
return bitstreamDAO.getNotReferencedBitstreams(context);
}
public Long getLastModified(Bitstream bitstream) {
return bitstreamStorageService.getLastModified(bitstream);
}
}

View File

@@ -202,4 +202,6 @@ public interface BitstreamService extends DSpaceObjectService<Bitstream>, DSpace
int countBitstreamsWithoutPolicy(Context context) throws SQLException;
List<Bitstream> getNotReferencedBitstreams(Context context) throws SQLException;
public Long getLastModified(Bitstream bitstream);
}

View File

@@ -8,6 +8,7 @@
package org.dspace.google;
import org.apache.commons.lang3.StringUtils;
import org.apache.http.NameValuePair;
import org.apache.http.client.entity.UrlEncodedFormEntity;
import org.apache.http.client.methods.CloseableHttpResponse;
@@ -19,10 +20,11 @@ import org.apache.log4j.Logger;
import org.dspace.content.factory.ContentServiceFactory;
import org.dspace.core.ConfigurationManager;
import org.dspace.core.Constants;
import org.dspace.services.factory.DSpaceServicesFactory;
import org.dspace.services.ConfigurationService;
import org.dspace.services.model.Event;
import org.dspace.usage.AbstractUsageEventListener;
import org.dspace.usage.UsageEvent;
import org.springframework.beans.factory.annotation.Autowired;
import javax.servlet.http.HttpServletRequest;
import java.io.IOException;
@@ -46,14 +48,25 @@ public class GoogleRecorderEventListener extends AbstractUsageEventListener {
private CloseableHttpClient httpclient;
private String GoogleURL = "https://www.google-analytics.com/collect";
private static Logger log = Logger.getLogger(GoogleRecorderEventListener.class);
protected ContentServiceFactory contentServiceFactory = ContentServiceFactory.getInstance();
protected ContentServiceFactory contentServiceFactory;
protected ConfigurationService configurationService;
public GoogleRecorderEventListener() {
// httpclient is threadsafe so we only need one.
httpclient = HttpClients.createDefault();
}
@Autowired
public void setContentServiceFactory(ContentServiceFactory contentServiceFactory) {
this.contentServiceFactory = contentServiceFactory;
}
@Autowired
public void setConfigurationService(final ConfigurationService configurationService) {
this.configurationService = configurationService;
}
@Override
public void receiveEvent(Event event) {
if((event instanceof UsageEvent))
@@ -61,12 +74,9 @@ public class GoogleRecorderEventListener extends AbstractUsageEventListener {
log.debug("Usage event received " + event.getName());
// This is a wee bit messy but these keys should be combined in future.
analyticsKey = DSpaceServicesFactory.getInstance().getConfigurationService().getProperty("jspui.google.analytics.key");
if (analyticsKey == null ) {
analyticsKey = DSpaceServicesFactory.getInstance().getConfigurationService().getProperty("xmlui.google.analytics.key");
}
analyticsKey = configurationService.getProperty("google.analytics.key");
if (analyticsKey != null ) {
if (StringUtils.isNotBlank(analyticsKey)) {
try {
UsageEvent ue = (UsageEvent)event;

View File

@@ -10,12 +10,12 @@ package org.dspace.statistics;
import org.apache.log4j.Logger;
import org.dspace.eperson.EPerson;
import org.dspace.services.model.Event;
import org.dspace.statistics.factory.StatisticsServiceFactory;
import org.dspace.statistics.service.SolrLoggerService;
import org.dspace.usage.AbstractUsageEventListener;
import org.dspace.usage.UsageEvent;
import org.dspace.usage.UsageSearchEvent;
import org.dspace.usage.UsageWorkflowEvent;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.util.CollectionUtils;
/**
@@ -31,8 +31,9 @@ public class SolrLoggerUsageEventListener extends AbstractUsageEventListener {
protected SolrLoggerService solrLoggerService;
public SolrLoggerUsageEventListener() {
solrLoggerService = StatisticsServiceFactory.getInstance().getSolrLoggerService();
@Autowired
public void setSolrLoggerService(SolrLoggerService solrLoggerService) {
this.solrLoggerService = solrLoggerService;
}
@Override
@@ -43,7 +44,7 @@ public class SolrLoggerUsageEventListener extends AbstractUsageEventListener {
log.debug("Usage event received " + event.getName());
try{
UsageEvent ue = (UsageEvent)event;
EPerson currentUser = ue.getContext() == null ? null : ue.getContext().getCurrentUser();
if(UsageEvent.Action.VIEW == ue.getAction()){
@@ -74,7 +75,7 @@ public class SolrLoggerUsageEventListener extends AbstractUsageEventListener {
log.error(e.getMessage());
}
}
}
}

View File

@@ -339,6 +339,17 @@ public class BitstreamStorageServiceImpl implements BitstreamStorageService, Ini
}
}
public Long getLastModified(Bitstream bitstream) {
Map wantedMetadata = new HashMap();
wantedMetadata.put("modified", null);
try {
wantedMetadata = stores.get(incoming).about(bitstream, wantedMetadata);
} catch (IOException e) {
log.error(e);
}
return Long.valueOf(wantedMetadata.get("modified").toString());
}
/**
*
* @param context

View File

@@ -190,4 +190,12 @@ public interface BitstreamStorageService {
*/
public void migrate(Context context, Integer assetstoreSource, Integer assetstoreDestination, boolean deleteOld, Integer batchCommitSize) throws IOException, SQLException, AuthorizeException;
/**
* Get the last modified timestamp of the file linked to the given bitstream
* @param bitstream The bitstream for which to get the last modified timestamp
* @return The last modified timestamp in milliseconds
*/
public Long getLastModified(Bitstream bitstream);
}

View File

@@ -1,22 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:context="http://www.springframework.org/schema/context"
xmlns:util="http://www.springframework.org/schema/util"
xsi:schemaLocation="http://www.springframework.org/schema/beans
http://www.springframework.org/schema/beans/spring-beans-3.0.xsd
http://www.springframework.org/schema/context
http://www.springframework.org/schema/context/spring-context-3.0.xsd
http://www.springframework.org/schema/util
http://www.springframework.org/schema/util/spring-util-3.0.xsd"
default-autowire-candidates="*Service,*DAO,javax.sql.DataSource">
<context:annotation-config /> <!-- allows us to use spring annotations in beans -->
<!-- Run an internal SOLR server for the Discovery search service -->
<bean class="org.dspace.discovery.MockSolrServiceImpl" id="org.dspace.discovery.SearchService"/>
<!-- Run an internal SOLR server for the statistics service -->
<bean class="org.dspace.statistics.MockSolrLoggerServiceImpl" id="solrLoggerService" lazy-init="true"/>
</beans>

View File

@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
The contents of this file are subject to the license and copyright
detailed in the LICENSE and NOTICE files at the root of the source
tree and available online at
http://www.dspace.org/license/
-->
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:context="http://www.springframework.org/schema/context"
xsi:schemaLocation="http://www.springframework.org/schema/beans
http://www.springframework.org/schema/beans/spring-beans-2.5.xsd
http://www.springframework.org/schema/context
http://www.springframework.org/schema/context/spring-context-2.5.xsd"
default-autowire-candidates="*Service,*DAO,javax.sql.DataSource">
<context:annotation-config /> <!-- allows us to use spring annotations in beans -->
<bean class="org.dspace.discovery.MockSolrServiceImpl" id="org.dspace.discovery.SearchService"/>
<alias name="org.dspace.discovery.SearchService" alias="org.dspace.discovery.IndexingService"/>
<!--<bean class="org.dspace.discovery.SolrServiceIndexOutputPlugin" id="solrServiceIndexOutputPlugin"/>-->
<!-- Statistics services are both lazy loaded (by name), as you are likely just using ONE of them and not both -->
<bean id="elasticSearchLoggerService" class="org.dspace.statistics.ElasticSearchLoggerServiceImpl" lazy-init="true"/>
<bean id="solrLoggerService" class="org.dspace.statistics.MockSolrLoggerServiceImpl" lazy-init="true"/>
</beans>

View File

@@ -9,17 +9,27 @@ package org.dspace.app.rest;
import java.io.IOException;
import java.io.InputStream;
import java.sql.SQLException;
import java.util.UUID;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.ws.rs.core.Response;
import org.apache.catalina.connector.ClientAbortException;
import org.apache.log4j.Logger;
import org.dspace.app.rest.model.BitstreamRest;
import org.dspace.app.rest.repository.BitstreamRestRepository;
import org.dspace.core.Utils;
import org.dspace.app.rest.utils.ContextUtil;
import org.dspace.app.rest.utils.MultipartFileSender;
import org.dspace.authorize.AuthorizeException;
import org.dspace.authorize.service.AuthorizeService;
import org.dspace.content.Bitstream;
import org.dspace.content.service.BitstreamService;
import org.dspace.core.Constants;
import org.dspace.core.Context;
import org.dspace.services.EventService;
import org.dspace.usage.UsageEvent;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.hateoas.Link;
import org.springframework.hateoas.ResourceSupport;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
@@ -27,45 +37,98 @@ import org.springframework.web.bind.annotation.RestController;
/**
* This is a specialized controller to provide access to the bitstream binary content
*
*
* @author Andrea Bollini (andrea.bollini at 4science.it)
* @author Tom Desair (tom dot desair at atmire dot com)
* @author Frederic Van Reet (frederic dot vanreet at atmire dot com)
*
*/
@RestController
@RequestMapping("/api/"+BitstreamRest.CATEGORY +"/"+ BitstreamRest.PLURAL_NAME + "/{uuid:[0-9a-fxA-FX]{8}-[0-9a-fxA-FX]{4}-[0-9a-fxA-FX]{4}-[0-9a-fxA-FX]{4}-[0-9a-fxA-FX]{12}}/content")
public class BitstreamContentRestController {
@Autowired
private BitstreamRestRepository bitstreamRestRepository;
@RequestMapping(method = RequestMethod.GET)
public void retrieve(@PathVariable UUID uuid, HttpServletResponse response,
HttpServletRequest request) throws IOException {
BitstreamRest bit = bitstreamRestRepository.findOne(uuid);
if (bit == null) {
response.sendError(HttpServletResponse.SC_NOT_FOUND);
return;
}
response.setHeader("ETag", bit.getCheckSum().getValue());
response.setContentLengthLong(bit.getSizeBytes());
// Check for if-modified-since header
long modSince = request.getDateHeader("If-Modified-Since");
// we should keep last modification date on the bitstream
// if (modSince != -1 && item.getLastModified().getTime() < modSince)
// {
// // Item has not been modified since requested date,
// // hence bitstream has not; return 304
// response.setStatus(HttpServletResponse.SC_NOT_MODIFIED);
// return;
// }
// Pipe the bits
InputStream is = bitstreamRestRepository.retrieve(uuid);
// Set the response MIME type
response.setContentType(bit.getFormat().getMimetype());
Utils.bufferedCopy(is, response.getOutputStream());
is.close();
response.getOutputStream().flush();
private static final Logger log = Logger.getLogger(BitstreamContentRestController.class);
//Most file systems are configured to use block sizes of 4096 or 8192 and our buffer should be a multiple of that.
private static final int BUFFER_SIZE = 4096 * 10;
@Autowired
private BitstreamService bitstreamService;
@Autowired
private EventService eventService;
@Autowired
private AuthorizeService authorizeService;
@RequestMapping(method = {RequestMethod.GET, RequestMethod.HEAD})
public void retrieve(@PathVariable UUID uuid, HttpServletResponse response,
HttpServletRequest request) throws IOException, SQLException, AuthorizeException {
Context context = ContextUtil.obtainContext(request);
Bitstream bit = getBitstream(context, uuid, response);
if (bit == null) {
//The bitstream was not found or we're not authorized to read it.
return;
}
Long lastModified = bitstreamService.getLastModified(bit);
String mimetype = bit.getFormat(context).getMIMEType();
// Pipe the bits
try(InputStream is = bitstreamService.retrieve(context, bit)) {
MultipartFileSender sender = MultipartFileSender
.fromInputStream(is)
.withBufferSize(BUFFER_SIZE)
.withFileName(bit.getName())
.withLength(bit.getSize())
.withChecksum(bit.getChecksum())
.withMimetype(mimetype)
.withLastModified(lastModified)
.with(request)
.with(response);
if (sender.isNoRangeRequest() && isNotAnErrorResponse(response)) {
//We only log a download request when serving a request without Range header. This is because
//a browser always sends a regular request first to check for Range support.
eventService.fireEvent(
new UsageEvent(
UsageEvent.Action.VIEW,
request,
context,
bit));
}
//We have all the data we need, close the connection to the database so that it doesn't stay open during
//download/streaming
context.complete();
//Send the data
if (sender.isValid()) {
sender.serveResource();
}
} catch(ClientAbortException ex) {
log.debug("Client aborted the request before the download was completed. Client is probably switching to a Range request.", ex);
}
}
private Bitstream getBitstream(Context context, @PathVariable UUID uuid, HttpServletResponse response) throws SQLException, IOException, AuthorizeException {
Bitstream bit = bitstreamService.find(context, uuid);
if (bit == null) {
response.sendError(HttpServletResponse.SC_NOT_FOUND);
} else {
authorizeService.authorizeAction(context, bit, Constants.READ);
}
return bit;
}
private boolean isNotAnErrorResponse(HttpServletResponse response) {
Response.Status.Family responseCode = Response.Status.Family.familyOf(response.getStatus());
return responseCode.equals(Response.Status.Family.SUCCESSFUL)
|| responseCode.equals(Response.Status.Family.REDIRECTION);
}
}

View File

@@ -0,0 +1,43 @@
/**
* The contents of this file are subject to the license and copyright
* detailed in the LICENSE and NOTICE files at the root of the source
* tree and available online at
*
* http://www.dspace.org/license/
*/
package org.dspace.app.rest.configuration;
import java.io.File;
import java.net.MalformedURLException;
import org.dspace.kernel.config.SpringLoader;
import org.dspace.services.ConfigurationService;
/**
* Utility class that will load the Spring XML configuration files related to the REST webapp
*
* @author Tom Desair (tom dot desair at atmire dot com)
* @author Kevin Van de Velde (kevin at atmire dot com)
*/
public class RESTSpringLoader implements SpringLoader {
@Override
public String[] getResourcePaths(ConfigurationService configurationService) {
StringBuffer filePath = new StringBuffer();
filePath.append(configurationService.getProperty("dspace.dir"));
filePath.append(File.separator);
filePath.append("config");
filePath.append(File.separator);
filePath.append("spring");
filePath.append(File.separator);
filePath.append("rest");
filePath.append(File.separator);
try {
return new String[]{new File(filePath.toString()).toURI().toURL().toString() + XML_SUFFIX};
} catch (MalformedURLException e) {
return new String[0];
}
}
}

View File

@@ -0,0 +1,65 @@
/**
* The contents of this file are subject to the license and copyright
* detailed in the LICENSE and NOTICE files at the root of the source
* tree and available online at
*
* http://www.dspace.org/license/
*/
package org.dspace.app.rest.exception;
import static org.springframework.web.servlet.DispatcherServlet.EXCEPTION_ATTRIBUTE;
import java.io.IOException;
import java.sql.SQLException;
import java.util.Date;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.dspace.authorize.AuthorizeException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;
/**
* This Controller advice will handle all exceptions thrown by the DSpace API module
*
* @author Tom Desair (tom dot desair at atmire dot com)
* @author Frederic Van Reet (frederic dot vanreet at atmire dot com)
* @author Andrea Bollini (andrea.bollini at 4science.it)
*
*/
@ControllerAdvice
public class DSpaceApiExceptionControllerAdvice extends ResponseEntityExceptionHandler{
@ExceptionHandler(AuthorizeException.class)
protected void handleAuthorizeException(HttpServletRequest request, HttpServletResponse response, Exception ex) throws IOException {
sendErrorResponse(request, response, ex, ex.getMessage(), HttpServletResponse.SC_UNAUTHORIZED);
}
@ExceptionHandler(SQLException.class)
protected void handleSQLException(HttpServletRequest request, HttpServletResponse response, Exception ex) throws IOException {
sendErrorResponse(request, response, ex,
"An internal database error occurred", HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
}
@ExceptionHandler(IOException.class)
protected void handleIOException(HttpServletRequest request, HttpServletResponse response, Exception ex) throws IOException {
sendErrorResponse(request, response, ex,
"An internal read or write operation failed (IO Exception)", HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
}
private void sendErrorResponse(final HttpServletRequest request, final HttpServletResponse response,
final Exception ex, final String message, final int statusCode) throws IOException {
//Make sure Spring picks up this exception
request.setAttribute(EXCEPTION_ATTRIBUTE, ex);
//Exception properties will be set by org.springframework.boot.web.support.ErrorPageFilter
response.sendError(statusCode, message);
}
}

View File

@@ -0,0 +1,474 @@
/**
* The contents of this file are subject to the license and copyright
* detailed in the LICENSE and NOTICE files at the root of the source
* tree and available online at
*
* http://www.dspace.org/license/
*/
package org.dspace.app.rest.utils;
import static java.util.Objects.isNull;
import static java.util.Objects.nonNull;
import java.io.BufferedInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import javax.servlet.ServletOutputStream;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Utility class to send an input stream with Range header and ETag support.
* Based on https://github.com/davinkevin/Podcast-Server/blob/v1.0.0/src/main/java/lan/dk/podcastserver/service/MultiPartFileSenderService.java
*
* @author Tom Desair (tom dot desair at atmire dot com)
* @author Frederic Van Reet (frederic dot vanreet at atmire dot com)
*
*/
public class MultipartFileSender {
protected final Logger log = LoggerFactory.getLogger(this.getClass());
private static final String METHOD_HEAD = "HEAD";
private static final String MULTIPART_BOUNDARY = "MULTIPART_BYTERANGES";
private static final String CONTENT_TYPE_MULTITYPE_WITH_BOUNDARY = "multipart/byteranges; boundary=" + MULTIPART_BOUNDARY;
private static final String CONTENT_DISPOSITION_INLINE = "inline";
private static final String CONTENT_DISPOSITION_ATTACHMENT = "attachment";
private static final String IF_NONE_MATCH = "If-None-Match";
private static final String IF_MODIFIED_SINCE = "If-Modified-Since";
private static final String ETAG = "ETag";
private static final String IF_MATCH = "If-Match";
private static final String IF_UNMODIFIED_SINCE = "If-Unmodified-Since";
private static final String RANGE = "Range";
private static final String CONTENT_RANGE = "Content-Range";
private static final String IF_RANGE = "If-Range";
private static final String CONTENT_TYPE = "Content-Type";
private static final String ACCEPT_RANGES = "Accept-Ranges";
private static final String BYTES = "bytes";
private static final String LAST_MODIFIED = "Last-Modified";
private static final String EXPIRES = "Expires";
private static final String APPLICATION_OCTET_STREAM = "application/octet-stream";
private static final String IMAGE = "image";
private static final String ACCEPT = "Accept";
private static final String CONTENT_DISPOSITION = "Content-Disposition";
private static final String CONTENT_LENGTH = "Content-Length";
private static final String BYTES_RANGE_FORMAT = "bytes %d-%d/%d";
private static final String CONTENT_DISPOSITION_FORMAT = "%s;filename=\"%s\"";
private static final String BYTES_DINVALID_BYTE_RANGE_FORMAT = "bytes */%d";
private static final String CACHE_CONTROL = "Cache-Control";
private int bufferSize = 1000000;
private static final long DEFAULT_EXPIRE_TIME = 60L * 60L * 1000L;
//no-cache so request is always performed for logging
private static final String CACHE_CONTROL_SETTING = "private,no-cache";
private BufferedInputStream inputStream;
private HttpServletRequest request;
private HttpServletResponse response;
private String contentType;
private String disposition;
private long lastModified;
private long length;
private String fileName;
private String checksum;
public MultipartFileSender(final InputStream inputStream) {
//Convert to BufferedInputStream so we can re-read the stream
this.inputStream = new BufferedInputStream(inputStream);
}
public static MultipartFileSender fromInputStream(InputStream inputStream) {
return new MultipartFileSender(inputStream);
}
public MultipartFileSender with(HttpServletRequest httpRequest) {
request = httpRequest;
return this;
}
public MultipartFileSender with(HttpServletResponse httpResponse) {
response = httpResponse;
return this;
}
public MultipartFileSender withLength(long length) {
this.length = length;
return this;
}
public MultipartFileSender withFileName(String fileName) {
this.fileName = fileName;
return this;
}
public MultipartFileSender withChecksum(String checksum) {
this.checksum = checksum;
return this;
}
public MultipartFileSender withMimetype(String mimetype) {
this.contentType = mimetype;
return this;
}
public MultipartFileSender withLastModified(long lastModified) {
this.lastModified = lastModified;
return this;
}
public MultipartFileSender withBufferSize(int bufferSize) {
if(bufferSize > 0) {
this.bufferSize = bufferSize;
}
return this;
}
public void serveResource() throws IOException {
// Validate and process range -------------------------------------------------------------
// Prepare some variables. The full Range represents the complete file.
Range full = getFullRange();
List<Range> ranges = getRanges(full);
if (ranges == null) {
//The supplied range values were invalid
return;
}
log.debug("Content-Type : {}", contentType);
// Initialize response.
response.reset();
response.setBufferSize(bufferSize);
response.setHeader(CONTENT_TYPE, contentType);
response.setHeader(ACCEPT_RANGES, BYTES);
response.setHeader(ETAG, checksum);
response.setDateHeader(LAST_MODIFIED, lastModified);
response.setDateHeader(EXPIRES, System.currentTimeMillis() + DEFAULT_EXPIRE_TIME);
//No-cache so that we can log every download
response.setHeader(CACHE_CONTROL, CACHE_CONTROL_SETTING);
if (isNullOrEmpty(disposition)) {
if (contentType == null) {
contentType = APPLICATION_OCTET_STREAM;
} else if (!contentType.startsWith(IMAGE)) {
String accept = request.getHeader(ACCEPT);
disposition = accept != null && accepts(accept, contentType) ? CONTENT_DISPOSITION_INLINE : CONTENT_DISPOSITION_ATTACHMENT;
}
response.setHeader(CONTENT_DISPOSITION, String.format(CONTENT_DISPOSITION_FORMAT, disposition, fileName));
log.debug("Content-Disposition : {}", disposition);
}
// Content phase
if (METHOD_HEAD.equals(request.getMethod())) {
log.debug("HEAD request - skipping content");
return;
}
// Send requested file (part(s)) to client ------------------------------------------------
// Prepare streams.
try (OutputStream output = response.getOutputStream()) {
if (hasNoRanges(full, ranges)) {
// Return full file.
log.debug("Return full file");
response.setContentType(contentType);
response.setHeader(CONTENT_LENGTH, String.valueOf(length));
Range.copy(inputStream, output, length, 0, length, bufferSize);
} else if (ranges.size() == 1) {
// Return single part of file.
Range r = ranges.get(0);
log.debug("Return 1 part of file : from ({}) to ({})", r.start, r.end);
response.setContentType(contentType);
response.setHeader(CONTENT_RANGE, String.format(BYTES_RANGE_FORMAT, r.start, r.end, r.total));
response.setHeader(CONTENT_LENGTH, String.valueOf(r.length));
response.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT); // 206.
// Copy single part range.
Range.copy(inputStream, output, length, r.start, r.length, bufferSize);
} else {
// Return multiple parts of file.
response.setContentType(CONTENT_TYPE_MULTITYPE_WITH_BOUNDARY);
response.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT); // 206.
// Cast back to ServletOutputStream to get the easy println methods.
ServletOutputStream sos = (ServletOutputStream) output;
// Copy multi part range.
for (Range r : ranges) {
log.debug("Return multi part of file : from ({}) to ({})", r.start, r.end);
// Add multipart boundary and header fields for every range.
sos.println("--" + MULTIPART_BOUNDARY);
sos.println(CONTENT_TYPE + ": " + contentType);
sos.println(CONTENT_RANGE + ": " + String.format(BYTES_RANGE_FORMAT, r.start, r.end, r.total));
//Mark position of inputstream so we can return to it later
inputStream.mark(0);
// Copy single part range of multi part range.
Range.copy(inputStream, output, length, r.start, r.length, bufferSize);
inputStream.reset();
sos.println();
}
// End with multipart boundary.
sos.println("--" + MULTIPART_BOUNDARY + "--");
}
}
}
public boolean isValid() throws IOException {
if (response == null || request == null) {
return false;
}
if (inputStream == null) {
log.error("Input stream has no content");
response.sendError(HttpServletResponse.SC_NOT_FOUND);
return false;
}
if (StringUtils.isEmpty(fileName)) {
response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
return false;
}
// Validate request headers for caching ---------------------------------------------------
// If-None-Match header should contain "*" or ETag. If so, then return 304.
String ifNoneMatch = request.getHeader(IF_NONE_MATCH);
if (nonNull(ifNoneMatch) && matches(ifNoneMatch, checksum)) {
log.debug("If-None-Match header should contain \"*\" or ETag. If so, then return 304.");
response.setHeader(ETAG, checksum); // Required in 304.
response.sendError(HttpServletResponse.SC_NOT_MODIFIED);
return false;
}
// If-Modified-Since header should be greater than LastModified. If so, then return 304.
// This header is ignored if any If-None-Match header is specified.
long ifModifiedSince = request.getDateHeader(IF_MODIFIED_SINCE);
if (isNull(ifNoneMatch) && ifModifiedSince != -1 && ifModifiedSince + 1000 > lastModified) {
log.debug("If-Modified-Since header should be greater than LastModified. If so, then return 304.");
response.setHeader(ETAG, checksum); // Required in 304.
response.sendError(HttpServletResponse.SC_NOT_MODIFIED);
return false;
}
// Validate request headers for resume ----------------------------------------------------
// If-Match header should contain "*" or ETag. If not, then return 412.
String ifMatch = request.getHeader(IF_MATCH);
if (nonNull(ifMatch) && !matches(ifMatch, checksum)) {
log.error("If-Match header should contain \"*\" or ETag. If not, then return 412.");
response.sendError(HttpServletResponse.SC_REQUESTED_RANGE_NOT_SATISFIABLE);
return false;
}
// If-Unmodified-Since header should be greater than LastModified. If not, then return 412.
long ifUnmodifiedSince = request.getDateHeader(IF_UNMODIFIED_SINCE);
if (ifUnmodifiedSince != -1 && ifUnmodifiedSince + 1000 <= lastModified) {
log.error("If-Unmodified-Since header should be greater than LastModified. If not, then return 412.");
response.sendError(HttpServletResponse.SC_PRECONDITION_FAILED);
return false;
}
return true;
}
public boolean isNoRangeRequest() throws IOException {
Range full = getFullRange();
List<Range> ranges = getRanges(full);
if(hasNoRanges(full, ranges)) {
return true;
} else {
return false;
}
}
private boolean hasNoRanges(final Range full, final List<Range> ranges) {
return ranges != null && (ranges.isEmpty() || ranges.get(0) == full);
}
private Range getFullRange() {
return new Range(0, length - 1, length);
}
private List<Range> getRanges(final Range fullRange) throws IOException {
List<Range> ranges = new ArrayList<>();
// Validate and process Range and If-Range headers.
String range = request.getHeader(RANGE);
if (nonNull(range)) {
// Range header should match format "bytes=n-n,n-n,n-n...". If not, then return 416.
if (!range.matches("^bytes=\\d*-\\d*(,\\d*-\\d*)*$")) {
log.error("Range header should match format \"bytes=n-n,n-n,n-n...\". If not, then return 416.");
response.setHeader(CONTENT_RANGE, String.format(BYTES_DINVALID_BYTE_RANGE_FORMAT, length)); // Required in 416.
response.sendError(HttpServletResponse.SC_REQUESTED_RANGE_NOT_SATISFIABLE);
return null;
}
String ifRange = request.getHeader(IF_RANGE);
if (nonNull(ifRange) && !ifRange.equals(fileName)) {
try {
//Assume that the If-Range contains a date
long ifRangeTime = request.getDateHeader(IF_RANGE); // Throws IAE if invalid.
if (ifRangeTime == -1 || ifRangeTime + 1000 <= lastModified) {
//Our file has been updated, send the full range
ranges.add(fullRange);
}
} catch (IllegalArgumentException ignore) {
//Assume that the If-Range contains an ETag
if (!matches(ifRange, checksum)) {
//Our file has been updated, send the full range
ranges.add(fullRange);
}
}
}
// If any valid If-Range header, then process each part of byte range.
if (ranges.isEmpty()) {
log.debug("If any valid If-Range header, then process each part of byte range.");
for (String part : range.substring(6).split(",")) {
// Assuming a file with length of 100, the following examples returns bytes at:
// 50-80 (50 to 80), 40- (40 to length=100), -20 (length-20=80 to length=100).
long start = Range.sublong(part, 0, part.indexOf("-"));
long end = Range.sublong(part, part.indexOf("-") + 1, part.length());
if (start == -1) {
start = length - end;
end = length - 1;
} else if (end == -1 || end > length - 1) {
end = length - 1;
}
// Check if Range is syntactically valid. If not, then return 416.
if (start > end) {
log.warn("Check if Range is syntactically valid. If not, then return 416.");
response.setHeader(CONTENT_RANGE, String.format(BYTES_DINVALID_BYTE_RANGE_FORMAT, length)); // Required in 416.
response.sendError(HttpServletResponse.SC_REQUESTED_RANGE_NOT_SATISFIABLE);
return null;
}
// Add range.
ranges.add(new Range(start, end, length));
}
}
}
return ranges;
}
private static boolean isNullOrEmpty(String disposition) {
return StringUtils.isBlank(disposition);
}
private static class Range {
long start;
long end;
long length;
long total;
/**
* Construct a byte range.
*
* @param start Start of the byte range.
* @param end End of the byte range.
* @param total Total length of the byte source.
*/
public Range(long start, long end, long total) {
this.start = start;
this.end = end;
this.length = this.end - this.start + 1;
this.total = total;
}
private static List<Range> relativize(List<Range> ranges) {
List<Range> builder = new ArrayList<>(ranges.size());
Range prevRange = null;
for (Range r : ranges) {
Range newRange = isNull(prevRange) ? r : new Range(r.start - prevRange.end - 1, r.end - prevRange.end - 1, r.total);
builder.add(newRange);
prevRange = r;
}
return builder;
}
public static long sublong(String value, int beginIndex, int endIndex) {
String substring = value.substring(beginIndex, endIndex);
return (substring.length() > 0) ? Long.parseLong(substring) : -1;
}
private static void copy(InputStream input, OutputStream output, long inputSize, long start, long length, int bufferSize) throws IOException {
byte[] buffer = new byte[bufferSize];
int read;
if (inputSize == length) {
// Write full range.
while ((read = input.read(buffer)) > 0) {
output.write(buffer, 0, read);
output.flush();
}
} else {
input.skip(start);
long toRead = length;
while ((read = input.read(buffer)) > 0) {
if ((toRead -= read) > 0) {
output.write(buffer, 0, read);
output.flush();
} else {
output.write(buffer, 0, (int) toRead + read);
output.flush();
break;
}
}
}
}
}
private static boolean accepts(String acceptHeader, String toAccept) {
String[] acceptValues = acceptHeader.split("\\s*(,|;)\\s*");
Arrays.sort(acceptValues);
return Arrays.binarySearch(acceptValues, toAccept) > -1
|| Arrays.binarySearch(acceptValues, toAccept.replaceAll("/.*$", "/*")) > -1
|| Arrays.binarySearch(acceptValues, "*/*") > -1;
}
private static boolean matches(String matchHeader, String toMatch) {
String[] matchValues = matchHeader.split("\\s*,\\s*");
Arrays.sort(matchValues);
return Arrays.binarySearch(matchValues, toMatch) > -1 || Arrays.binarySearch(matchValues, "*") > -1;
}
}

View File

@@ -117,7 +117,7 @@ public class AuthenticationRestControllerIT extends AbstractControllerIntegratio
//The group we try to add to our token
context.turnOffAuthorisationSystem();
Group internalGroup = new GroupBuilder().createGroup(context)
Group internalGroup = GroupBuilder.createGroup(context)
.withName("Internal Group")
.build();
context.restoreAuthSystemState();

View File

@@ -0,0 +1,307 @@
/**
* The contents of this file are subject to the license and copyright
* detailed in the LICENSE and NOTICE files at the root of the source
* tree and available online at
*
* http://www.dspace.org/license/
*/
package org.dspace.app.rest;
import static org.junit.Assert.assertEquals;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.head;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
import java.io.IOException;
import java.io.InputStream;
import java.util.UUID;
import org.apache.commons.io.IOUtils;
import org.apache.commons.lang3.CharEncoding;
import org.apache.solr.client.solrj.SolrQuery;
import org.apache.solr.client.solrj.SolrServerException;
import org.apache.solr.client.solrj.response.QueryResponse;
import org.dspace.app.rest.builder.BitstreamBuilder;
import org.dspace.app.rest.builder.CollectionBuilder;
import org.dspace.app.rest.builder.CommunityBuilder;
import org.dspace.app.rest.builder.GroupBuilder;
import org.dspace.app.rest.builder.ItemBuilder;
import org.dspace.app.rest.test.AbstractControllerIntegrationTest;
import org.dspace.content.Bitstream;
import org.dspace.content.Collection;
import org.dspace.content.Item;
import org.dspace.eperson.Group;
import org.dspace.solr.MockSolrServer;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
/**
* Integration test to test the /api/core/bitstreams/[id]/content endpoint
*
* @author Tom Desair (tom dot desair at atmire dot com)
* @author Frederic Van Reet (frederic dot vanreet at atmire dot com)
*/
public class BitstreamContentRestControllerIT extends AbstractControllerIntegrationTest {
private MockSolrServer mockSolrServer;
@Before
public void setup() throws Exception {
super.setUp();
mockSolrServer = new MockSolrServer("statistics");
mockSolrServer.getSolrServer().deleteByQuery("*:*");
mockSolrServer.getSolrServer().commit();
}
@After
public void destroy() throws Exception {
super.destroy();
mockSolrServer.destroy();
}
@Test
public void retrieveFullBitstream() throws Exception {
context.turnOffAuthorisationSystem();
//** GIVEN **
//1. A community-collection structure with one parent community and one collections.
parentCommunity = CommunityBuilder.createCommunity(context)
.withName("Parent Community")
.build();
Collection col1 = CollectionBuilder.createCollection(context, parentCommunity).withName("Collection 1").build();
//2. A public item with a bitstream
String bitstreamContent = "0123456789";
try(InputStream is = IOUtils.toInputStream(bitstreamContent, CharEncoding.UTF_8)) {
Item publicItem1 = ItemBuilder.createItem(context, col1)
.withTitle("Public item 1")
.withIssueDate("2017-10-17")
.withAuthor("Smith, Donald").withAuthor("Doe, John")
.build();
Bitstream bitstream = BitstreamBuilder
.createBitstream(context, publicItem1, is)
.withName("Test bitstream")
.withDescription("This is a bitstream to test range requests")
.withMimeType("text/plain")
.build();
//** WHEN **
//We download the bitstream
getClient().perform(get("/api/core/bitstreams/" + bitstream.getID() + "/content"))
//** THEN **
.andExpect(status().isOk())
//The Content Length must match the full length
.andExpect(header().longValue("Content-Length", bitstreamContent.getBytes().length))
//The server should indicate we support Range requests
.andExpect(header().string("Accept-Ranges", "bytes"))
//The ETag has to be based on the checksum
.andExpect(header().string("ETag", bitstream.getChecksum()))
//We expect the content type to match the bitstream mime type
.andExpect(content().contentType("text/plain"))
//THe bytes of the content must match the original content
.andExpect(content().bytes(bitstreamContent.getBytes()));
//A If-None-Match HEAD request on the ETag must tell is the bitstream is not modified
getClient().perform(head("/api/core/bitstreams/" + bitstream.getID() + "/content")
.header("If-None-Match", bitstream.getChecksum()))
.andExpect(status().isNotModified());
//The download and head request should also be logged as a statistics record
checkNumberOfStatsRecords(bitstream, 2);
}
}
@Test
public void retrieveRangeBitstream() throws Exception {
context.turnOffAuthorisationSystem();
//** GIVEN **
//1. A community-collection structure with one parent community and one collections.
parentCommunity = CommunityBuilder.createCommunity(context)
.withName("Parent Community")
.build();
Collection col1 = CollectionBuilder.createCollection(context, parentCommunity).withName("Collection 1").build();
//2. A public item with a bitstream
String bitstreamContent = "0123456789";
try(InputStream is = IOUtils.toInputStream(bitstreamContent, CharEncoding.UTF_8)) {
Item publicItem1 = ItemBuilder.createItem(context, col1)
.withTitle("Public item 1")
.withIssueDate("2017-10-17")
.withAuthor("Smith, Donald").withAuthor("Doe, John")
.build();
Bitstream bitstream = BitstreamBuilder
.createBitstream(context, publicItem1, is)
.withName("Test bitstream")
.withDescription("This is a bitstream to test range requests")
.withMimeType("text/plain")
.build();
//** WHEN **
//We download only a specific byte range of the bitstream
getClient().perform(get("/api/core/bitstreams/" + bitstream.getID() + "/content")
.header("Range", "bytes=1-3"))
//** THEN **
.andExpect(status().is(206))
//The Content Length must match the requested range
.andExpect(header().longValue("Content-Length", 3))
//The server should indicate we support Range requests
.andExpect(header().string("Accept-Ranges", "bytes"))
//The ETag has to be based on the checksum
.andExpect(header().string("ETag", bitstream.getChecksum()))
//The response should give us details about the range
.andExpect(header().string("Content-Range", "bytes 1-3/10"))
//We expect the content type to match the bitstream mime type
.andExpect(content().contentType("text/plain"))
//We only expect the bytes 1, 2 and 3
.andExpect(content().bytes("123".getBytes()));
//** WHEN **
//We download the rest of the range
getClient().perform(get("/api/core/bitstreams/" + bitstream.getID() + "/content")
.header("Range", "bytes=4-"))
//** THEN **
.andExpect(status().is(206))
//The Content Length must match the requested range
.andExpect(header().longValue("Content-Length", 6))
//The server should indicate we support Range requests
.andExpect(header().string("Accept-Ranges", "bytes"))
//The ETag has to be based on the checksum
.andExpect(header().string("ETag", bitstream.getChecksum()))
//The response should give us details about the range
.andExpect(header().string("Content-Range", "bytes 4-9/10"))
//We expect the content type to match the bitstream mime type
.andExpect(content().contentType("text/plain"))
//We all remaining bytes, starting at byte 4
.andExpect(content().bytes("456789".getBytes()));
//Check that NO statistics record was logged for the Range requests
checkNumberOfStatsRecords(bitstream, 0);
}
}
@Test
public void testBitstreamNotFound() throws Exception {
getClient().perform(get("/api/core/bitstreams/" + UUID.randomUUID() + "/content"))
.andExpect(status().isNotFound());
}
@Test
public void testEmbargoedBitstream() throws Exception {
context.turnOffAuthorisationSystem();
//** GIVEN **
//1. A community-collection structure with one parent community and one collections.
parentCommunity = CommunityBuilder.createCommunity(context)
.withName("Parent Community")
.build();
Collection col1 = CollectionBuilder.createCollection(context, parentCommunity).withName("Collection 1").build();
//2. A public item with an embargoed bitstream
String bitstreamContent = "Embargoed!";
try(InputStream is = IOUtils.toInputStream(bitstreamContent, CharEncoding.UTF_8)) {
Item publicItem1 = ItemBuilder.createItem(context, col1)
.withTitle("Public item 1")
.withIssueDate("2017-10-17")
.withAuthor("Smith, Donald").withAuthor("Doe, John")
.build();
Bitstream bitstream = BitstreamBuilder
.createBitstream(context, publicItem1, is)
.withName("Test Embargoed Bitstream")
.withDescription("This bitstream is embargoed")
.withMimeType("text/plain")
.withEmbargoPeriod("6 months")
.build();
//** WHEN **
//We download the bitstream
getClient().perform(get("/api/core/bitstreams/" + bitstream.getID() + "/content"))
//** THEN **
.andExpect(status().isUnauthorized());
//An unauthorized request should not log statistics
checkNumberOfStatsRecords(bitstream, 0);
}
}
@Test
public void testPrivateBitstream() throws Exception {
context.turnOffAuthorisationSystem();
//** GIVEN **
//1. A community-collection structure with one parent community and one collections.
parentCommunity = CommunityBuilder.createCommunity(context)
.withName("Parent Community")
.build();
Collection col1 = CollectionBuilder.createCollection(context, parentCommunity).withName("Collection 1").build();
//2. A public item with a private bitstream
Item publicItem1 = ItemBuilder.createItem(context, col1)
.withTitle("Public item 1")
.withIssueDate("2017-10-17")
.withAuthor("Smith, Donald").withAuthor("Doe, John")
.build();
Group internalGroup = GroupBuilder.createGroup(context)
.withName("Internal Group")
.build();
String bitstreamContent = "Private!";
try(InputStream is = IOUtils.toInputStream(bitstreamContent, CharEncoding.UTF_8)) {
Bitstream bitstream = BitstreamBuilder
.createBitstream(context, publicItem1, is)
.withName("Test Embargoed Bitstream")
.withDescription("This bitstream is embargoed")
.withMimeType("text/plain")
.withReaderGroup(internalGroup)
.build();
//** WHEN **
//We download the bitstream
getClient().perform(get("/api/core/bitstreams/" + bitstream.getID() + "/content"))
//** THEN **
.andExpect(status().isUnauthorized());
//An unauthorized request should not log statistics
checkNumberOfStatsRecords(bitstream, 0);
} finally {
//** CLEANUP **
GroupBuilder.cleaner().delete(internalGroup);
}
}
private void checkNumberOfStatsRecords(Bitstream bitstream, int expectedNumberOfStatsRecords) throws SolrServerException, IOException {
mockSolrServer.getSolrServer().commit();
SolrQuery query = new SolrQuery("id:\"" + bitstream.getID() + "\"")
.setRows(0)
.setStart(0);
QueryResponse queryResponse = mockSolrServer.getSolrServer().query(query);
assertEquals(expectedNumberOfStatsRecords, queryResponse.getResults().getNumFound());
}
}

View File

@@ -6,9 +6,17 @@
* http://www.dspace.org/license/
*/
package org.dspace.app.rest;
import static org.hamcrest.Matchers.*;
import static org.hamcrest.Matchers.contains;
import static org.hamcrest.Matchers.containsInAnyOrder;
import static org.hamcrest.Matchers.hasItem;
import static org.hamcrest.Matchers.hasSize;
import static org.hamcrest.Matchers.is;
import static org.hamcrest.Matchers.not;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import org.dspace.app.rest.builder.CollectionBuilder;
import org.dspace.app.rest.builder.CommunityBuilder;
@@ -28,6 +36,9 @@ import org.springframework.test.web.servlet.result.MockMvcResultHandlers;
/**
* Integration test to test the /api/discover/browses endpoint
* (Class has to start or end with IT to be picked up by the failsafe plugin)
*
* @author Tom Desair (tom dot desair at atmire dot com)
* @author Raf Ponsaerts (raf dot ponsaerts at atmire dot com)
*/
public class BrowsesResourceControllerIT extends AbstractControllerIntegrationTest {
@@ -121,31 +132,31 @@ public class BrowsesResourceControllerIT extends AbstractControllerIntegrationTe
//** GIVEN **
//1. A community-collection structure with one parent community with sub-community and two collections.
parentCommunity = new CommunityBuilder().createCommunity(context)
parentCommunity = CommunityBuilder.createCommunity(context)
.withName("Parent Community")
.build();
Community child1 = new CommunityBuilder().createSubCommunity(context, parentCommunity)
Community child1 = CommunityBuilder.createSubCommunity(context, parentCommunity)
.withName("Sub Community")
.build();
Collection col1 = new CollectionBuilder().createCollection(context, child1).withName("Collection 1").build();
Collection col2 = new CollectionBuilder().createCollection(context, child1).withName("Collection 2").build();
Collection col1 = CollectionBuilder.createCollection(context, child1).withName("Collection 1").build();
Collection col2 = CollectionBuilder.createCollection(context, child1).withName("Collection 2").build();
//2. Three public items that are readable by Anonymous with different subjects
Item publicItem1 = new ItemBuilder().createItem(context, col1)
Item publicItem1 = ItemBuilder.createItem(context, col1)
.withTitle("Public item 1")
.withIssueDate("2017-10-17")
.withAuthor("Smith, Donald").withAuthor("Doe, John")
.withSubject("ExtraEntry")
.build();
Item publicItem2 = new ItemBuilder().createItem(context, col2)
Item publicItem2 = ItemBuilder.createItem(context, col2)
.withTitle("Public item 2")
.withIssueDate("2016-02-13")
.withAuthor("Smith, Maria").withAuthor("Doe, Jane")
.withSubject("TestingForMore").withSubject("ExtraEntry")
.build();
Item publicItem3 = new ItemBuilder().createItem(context, col2)
Item publicItem3 = ItemBuilder.createItem(context, col2)
.withTitle("Public item 2")
.withIssueDate("2016-02-13")
.withAuthor("Smith, Maria").withAuthor("Doe, Jane")
@@ -200,32 +211,32 @@ public class BrowsesResourceControllerIT extends AbstractControllerIntegrationTe
//** GIVEN **
//1. A community-collection structure with one parent community with sub-community and two collections.
parentCommunity = new CommunityBuilder().createCommunity(context)
parentCommunity = CommunityBuilder.createCommunity(context)
.withName("Parent Community")
.build();
Community child1 = new CommunityBuilder().createSubCommunity(context, parentCommunity)
Community child1 = CommunityBuilder.createSubCommunity(context, parentCommunity)
.withName("Sub Community")
.build();
Collection col1 = new CollectionBuilder().createCollection(context, child1).withName("Collection 1").build();
Collection col2 = new CollectionBuilder().createCollection(context, child1).withName("Collection 2").build();
Collection col1 = CollectionBuilder.createCollection(context, child1).withName("Collection 1").build();
Collection col2 = CollectionBuilder.createCollection(context, child1).withName("Collection 2").build();
//2. Two public items with the same subject and another public item that contains that same subject, but also another one
// All of the items are readable by an Anonymous user
Item publicItem1 = new ItemBuilder().createItem(context, col1)
Item publicItem1 = ItemBuilder.createItem(context, col1)
.withTitle("zPublic item more")
.withIssueDate("2017-10-17")
.withAuthor("Smith, Donald").withAuthor("Doe, John")
.withSubject("ExtraEntry").withSubject("AnotherTest")
.build();
Item publicItem2 = new ItemBuilder().createItem(context, col2)
Item publicItem2 = ItemBuilder.createItem(context, col2)
.withTitle("Public item 2")
.withIssueDate("2016-02-13")
.withAuthor("Smith, Maria").withAuthor("Doe, Jane")
.withSubject("AnotherTest")
.build();
Item publicItem3 = new ItemBuilder().createItem(context, col2)
Item publicItem3 = ItemBuilder.createItem(context, col2)
.withTitle("Public item 3")
.withIssueDate("2016-02-14")
.withAuthor("Smith, Maria").withAuthor("Doe, Jane")
@@ -274,24 +285,24 @@ public class BrowsesResourceControllerIT extends AbstractControllerIntegrationTe
//** GIVEN **
//1. A community-collection structure with one parent community with sub-community and two collections.
parentCommunity = new CommunityBuilder().createCommunity(context)
parentCommunity = CommunityBuilder.createCommunity(context)
.withName("Parent Community")
.build();
Community child1 = new CommunityBuilder().createSubCommunity(context, parentCommunity)
Community child1 = CommunityBuilder.createSubCommunity(context, parentCommunity)
.withName("Sub Community")
.build();
Collection col1 = new CollectionBuilder().createCollection(context, child1).withName("Collection 1").build();
Collection col2 = new CollectionBuilder().createCollection(context, child1).withName("Collection 2").build();
Collection col1 = CollectionBuilder.createCollection(context, child1).withName("Collection 1").build();
Collection col2 = CollectionBuilder.createCollection(context, child1).withName("Collection 2").build();
//2. Two public items that are readable by Anonymous
Item publicItem1 = new ItemBuilder().createItem(context, col1)
Item publicItem1 = ItemBuilder.createItem(context, col1)
.withTitle("Public item 1")
.withIssueDate("2017-10-17")
.withAuthor("Smith, Donald").withAuthor("Doe, John")
.withSubject("Java").withSubject("Unit Testing")
.build();
Item publicItem2 = new ItemBuilder().createItem(context, col2)
Item publicItem2 = ItemBuilder.createItem(context, col2)
.withTitle("Public item 2")
.withIssueDate("2016-02-13")
.withAuthor("Smith, Maria").withAuthor("Doe, Jane")
@@ -299,7 +310,7 @@ public class BrowsesResourceControllerIT extends AbstractControllerIntegrationTe
.build();
//3. An item that has been made private
Item privateItem = new ItemBuilder().createItem(context, col1)
Item privateItem = ItemBuilder.createItem(context, col1)
.withTitle("This is a private item")
.withIssueDate("2015-03-12")
.withAuthor("Duck, Donald")
@@ -308,7 +319,7 @@ public class BrowsesResourceControllerIT extends AbstractControllerIntegrationTe
.build();
//4. An item with an item-level embargo
Item embargoedItem = new ItemBuilder().createItem(context, col2)
Item embargoedItem = ItemBuilder.createItem(context, col2)
.withTitle("An embargoed publication")
.withIssueDate("2017-08-10")
.withAuthor("Mouse, Mickey")
@@ -317,11 +328,11 @@ public class BrowsesResourceControllerIT extends AbstractControllerIntegrationTe
.build();
//5. An item that is only readable for an internal groups
Group internalGroup = new GroupBuilder().createGroup(context)
Group internalGroup = GroupBuilder.createGroup(context)
.withName("Internal Group")
.build();
Item internalItem = new ItemBuilder().createItem(context, col2)
Item internalItem = ItemBuilder.createItem(context, col2)
.withTitle("Internal publication")
.withIssueDate("2016-09-19")
.withAuthor("Doe, John")
@@ -368,7 +379,7 @@ public class BrowsesResourceControllerIT extends AbstractControllerIntegrationTe
;
//** CLEANUP **
new GroupBuilder().delete(internalGroup);
GroupBuilder.cleaner().delete(internalGroup);
}
@Test
@@ -377,47 +388,47 @@ public class BrowsesResourceControllerIT extends AbstractControllerIntegrationTe
//** GIVEN **
//1. A community-collection structure with one parent community with sub-community and two collections.
parentCommunity = new CommunityBuilder().createCommunity(context)
parentCommunity = CommunityBuilder.createCommunity(context)
.withName("Parent Community")
.build();
Community child1 = new CommunityBuilder().createSubCommunity(context, parentCommunity)
Community child1 = CommunityBuilder.createSubCommunity(context, parentCommunity)
.withName("Sub Community")
.build();
Collection col1 = new CollectionBuilder().createCollection(context, child1).withName("Collection 1").build();
Collection col2 = new CollectionBuilder().createCollection(context, child1).withName("Collection 2").build();
Collection col1 = CollectionBuilder.createCollection(context, child1).withName("Collection 1").build();
Collection col2 = CollectionBuilder.createCollection(context, child1).withName("Collection 2").build();
//2. 7 public items that are readable by Anonymous
Item item1 = new ItemBuilder().createItem(context, col1)
Item item1 = ItemBuilder.createItem(context, col1)
.withTitle("Item 1")
.withIssueDate("2017-10-17")
.build();
Item item2 = new ItemBuilder().createItem(context, col2)
Item item2 = ItemBuilder.createItem(context, col2)
.withTitle("Item 2")
.withIssueDate("2016-02-13")
.build();
Item item3 = new ItemBuilder().createItem(context, col1)
Item item3 = ItemBuilder.createItem(context, col1)
.withTitle("Item 3")
.withIssueDate("2016-02-12")
.build();
Item item4 = new ItemBuilder().createItem(context, col2)
Item item4 = ItemBuilder.createItem(context, col2)
.withTitle("Item 4")
.withIssueDate("2016-02-11")
.build();
Item item5 = new ItemBuilder().createItem(context, col1)
Item item5 = ItemBuilder.createItem(context, col1)
.withTitle("Item 5")
.withIssueDate("2016-02-10")
.build();
Item item6 = new ItemBuilder().createItem(context, col2)
Item item6 = ItemBuilder.createItem(context, col2)
.withTitle("Item 6")
.withIssueDate("2016-01-13")
.build();
Item item7 = new ItemBuilder().createItem(context, col1)
Item item7 = ItemBuilder.createItem(context, col1)
.withTitle("Item 7")
.withIssueDate("2016-01-12")
.build();

View File

@@ -16,6 +16,9 @@ import org.junit.Test;
/**
* Integration test for the {@link RootRestResourceController}
*
* @author Tom Desair (tom dot desair at atmire dot com)
* @author Raf Ponsaerts (raf dot ponsaerts at atmire dot com)
*/
public class RootRestResourceControllerIT extends AbstractControllerIntegrationTest {

View File

@@ -7,10 +7,6 @@
*/
package org.dspace.app.rest.builder;
import java.io.IOException;
import java.sql.SQLException;
import java.util.Date;
import org.apache.log4j.Logger;
import org.dspace.authorize.AuthorizeException;
import org.dspace.authorize.ResourcePolicy;
@@ -20,14 +16,7 @@ import org.dspace.authorize.service.ResourcePolicyService;
import org.dspace.content.DSpaceObject;
import org.dspace.content.Item;
import org.dspace.content.factory.ContentServiceFactory;
import org.dspace.content.service.BitstreamService;
import org.dspace.content.service.BundleService;
import org.dspace.content.service.CollectionService;
import org.dspace.content.service.CommunityService;
import org.dspace.content.service.DSpaceObjectService;
import org.dspace.content.service.InstallItemService;
import org.dspace.content.service.ItemService;
import org.dspace.content.service.WorkspaceItemService;
import org.dspace.content.service.*;
import org.dspace.core.Constants;
import org.dspace.core.Context;
import org.dspace.discovery.IndexingService;
@@ -41,8 +30,15 @@ import org.joda.time.DateTimeZone;
import org.joda.time.MutablePeriod;
import org.joda.time.format.PeriodFormat;
import java.io.IOException;
import java.sql.SQLException;
import java.util.Date;
/**
* Abstract builder to construct DSpace Objects
*
* @author Tom Desair (tom dot desair at atmire dot com)
* @author Raf Ponsaerts (raf dot ponsaerts at atmire dot com)
*/
public abstract class AbstractBuilder<T extends DSpaceObject> {
@@ -55,6 +51,7 @@ public abstract class AbstractBuilder<T extends DSpaceObject> {
static GroupService groupService;
static BundleService bundleService;
static BitstreamService bitstreamService;
static BitstreamFormatService bitstreamFormatService;
static AuthorizeService authorizeService;
static ResourcePolicyService resourcePolicyService;
static IndexingService indexingService;
@@ -74,6 +71,7 @@ public abstract class AbstractBuilder<T extends DSpaceObject> {
groupService = EPersonServiceFactory.getInstance().getGroupService();
bundleService = ContentServiceFactory.getInstance().getBundleService();
bitstreamService = ContentServiceFactory.getInstance().getBitstreamService();
bitstreamFormatService = ContentServiceFactory.getInstance().getBitstreamFormatService();
authorizeService = AuthorizeServiceFactory.getInstance().getAuthorizeService();
resourcePolicyService = AuthorizeServiceFactory.getInstance().getResourcePolicyService();
indexingService = DSpaceServicesFactory.getInstance().getServiceManager().getServiceByName(IndexingService.class.getName(),IndexingService.class);

View File

@@ -0,0 +1,130 @@
/**
* The contents of this file are subject to the license and copyright
* detailed in the LICENSE and NOTICE files at the root of the source
* tree and available online at
*
* http://www.dspace.org/license/
*/
package org.dspace.app.rest.builder;
import java.io.IOException;
import java.io.InputStream;
import java.sql.SQLException;
import java.util.List;
import org.dspace.authorize.AuthorizeException;
import org.dspace.content.Bitstream;
import org.dspace.content.BitstreamFormat;
import org.dspace.content.Bundle;
import org.dspace.content.Item;
import org.dspace.content.service.DSpaceObjectService;
import org.dspace.core.Context;
import org.dspace.eperson.Group;
/**
* Builder class to build bitstreams in test cases
*
* @author Tom Desair (tom dot desair at atmire dot com)
* @author Raf Ponsaerts (raf dot ponsaerts at atmire dot com)
*/
public class BitstreamBuilder extends AbstractBuilder<Bitstream>{
public static final String ORIGINAL = "ORIGINAL";
private Bitstream bitstream;
private Item item;
private Group readerGroup;
protected BitstreamBuilder() {
}
public static BitstreamBuilder createBitstream(Context context, Item item, InputStream is) throws SQLException, AuthorizeException, IOException {
BitstreamBuilder builder = new BitstreamBuilder();
return builder.create(context, item, is);
}
private BitstreamBuilder create(Context context, Item item, InputStream is) throws SQLException, AuthorizeException, IOException {
this.context = context;
this.item = item;
Bundle originalBundle = getOriginalBundle(item);
bitstream = bitstreamService.create(context, originalBundle, is);
return this;
}
public BitstreamBuilder withName(String name) throws SQLException {
bitstream.setName(context, name);
return this;
}
public BitstreamBuilder withDescription(String description) throws SQLException {
bitstream.setDescription(context, description);
return this;
}
public BitstreamBuilder withMimeType(String mimeType) throws SQLException {
BitstreamFormat bf = bitstreamFormatService
.findByMIMEType(context, mimeType);
if (bf != null) {
bitstream.setFormat(context, bf);
}
return this;
}
private Bundle getOriginalBundle(Item item) throws SQLException, AuthorizeException {
List<Bundle> bundles = itemService.getBundles(item, ORIGINAL);
Bundle targetBundle = null;
if( bundles.size() < 1 )
{
// not found, create a new one
targetBundle = bundleService.create(context, item, ORIGINAL);
}
else
{
// put bitstreams into first bundle
targetBundle = bundles.iterator().next();
}
return targetBundle;
}
public BitstreamBuilder withEmbargoPeriod(String embargoPeriod) {
return setEmbargo(embargoPeriod, bitstream);
}
public BitstreamBuilder withReaderGroup(Group group) {
readerGroup = group;
return this;
}
public Bitstream build() {
try {
bitstreamService.update(context, bitstream);
itemService.update(context, item);
//Check if we need to make this bitstream private.
if(readerGroup != null) {
setOnlyReadPermission(bitstream, readerGroup, null);
}
context.dispatchEvents();
indexingService.commit();
} catch (Exception e) {
return null;
}
return bitstream;
}
protected DSpaceObjectService<Bitstream> getDsoService() {
return bitstreamService;
}
}

View File

@@ -7,24 +7,32 @@
*/
package org.dspace.app.rest.builder;
import org.dspace.authorize.AuthorizeException;
import org.dspace.content.Collection;
import org.dspace.content.Community;
import org.dspace.content.MetadataSchema;
import org.dspace.content.service.DSpaceObjectService;
import org.dspace.core.Context;
import org.dspace.discovery.SearchServiceException;
import java.sql.SQLException;
/**
* Builder to construct Collection objects
*
* @author Tom Desair (tom dot desair at atmire dot com)
* @author Raf Ponsaerts (raf dot ponsaerts at atmire dot com)
*/
public class CollectionBuilder extends AbstractBuilder<Collection> {
private Collection collection;
public CollectionBuilder createCollection(final Context context, final Community parent) {
protected CollectionBuilder() {
}
public static CollectionBuilder createCollection(final Context context, final Community parent) {
CollectionBuilder builder = new CollectionBuilder();
return builder.create(context, parent);
}
private CollectionBuilder create(final Context context, final Community parent) {
this.context = context;
try {
this.collection = collectionService.create(context, parent);

View File

@@ -11,20 +11,36 @@ import org.dspace.content.Community;
import org.dspace.content.MetadataSchema;
import org.dspace.content.service.DSpaceObjectService;
import org.dspace.core.Context;
import org.dspace.discovery.SearchServiceException;
/**
* Builder to construct Community objects
*
* @author Tom Desair (tom dot desair at atmire dot com)
* @author Raf Ponsaerts (raf dot ponsaerts at atmire dot com)
*/
public class CommunityBuilder extends AbstractBuilder<Community> {
private Community community;
public CommunityBuilder createCommunity(final Context context) {
protected CommunityBuilder() {
}
public static CommunityBuilder createCommunity(final Context context) {
CommunityBuilder builder = new CommunityBuilder();
return builder.create(context);
}
private CommunityBuilder create(final Context context) {
return createSubCommunity(context, null);
}
public CommunityBuilder createSubCommunity(final Context context, final Community parent) {
public static CommunityBuilder createSubCommunity(final Context context, final Community parent) {
CommunityBuilder builder = new CommunityBuilder();
return builder.createSub(context, parent);
}
private CommunityBuilder createSub(final Context context, final Community parent) {
this.context = context;
try {
community = communityService.create(parent, context);

View File

@@ -14,11 +14,33 @@ import org.dspace.eperson.Group;
/**
* Builder to construct Group objects
*
* @author Tom Desair (tom dot desair at atmire dot com)
* @author Raf Ponsaerts (raf dot ponsaerts at atmire dot com)
*/
public class GroupBuilder extends AbstractBuilder<Group> {
private Group group;
protected GroupBuilder() {
}
public static GroupBuilder createGroup(final Context context) {
GroupBuilder builder = new GroupBuilder();
return builder.create(context);
}
private GroupBuilder create(final Context context) {
this.context = context;
try {
group = groupService.create(context);
} catch (Exception e) {
return handleException(e);
}
return this;
}
@Override
protected DSpaceObjectService<Group> getDsoService() {
return groupService;
@@ -29,16 +51,6 @@ public class GroupBuilder extends AbstractBuilder<Group> {
return group;
}
public GroupBuilder createGroup(final Context context) {
this.context = context;
try {
group = groupService.create(context);
} catch (Exception e) {
return handleException(e);
}
return this;
}
public GroupBuilder withName(String groupName) {
try {
groupService.setName(group, groupName);
@@ -65,4 +77,9 @@ public class GroupBuilder extends AbstractBuilder<Group> {
}
return this;
}
public static AbstractBuilder<Group> cleaner() {
return new GroupBuilder();
}
}

View File

@@ -7,24 +7,40 @@
*/
package org.dspace.app.rest.builder;
import org.dspace.content.*;
import org.dspace.content.Collection;
import org.dspace.content.DCDate;
import org.dspace.content.Item;
import org.dspace.content.MetadataSchema;
import org.dspace.content.WorkspaceItem;
import org.dspace.content.service.DSpaceObjectService;
import org.dspace.core.Context;
import org.dspace.eperson.Group;
/**
* Builder to construct Item objects
*
* @author Tom Desair (tom dot desair at atmire dot com)
* @author Raf Ponsaerts (raf dot ponsaerts at atmire dot com)
*/
public class ItemBuilder extends AbstractBuilder<Item> {
private WorkspaceItem workspaceItem;
private Group readerGroup = null;
public ItemBuilder createItem(final Context context, final Collection col1) {
protected ItemBuilder() {
}
public static ItemBuilder createItem(final Context context, final Collection col) {
ItemBuilder builder = new ItemBuilder();
return builder.create(context, col);
}
private ItemBuilder create(final Context context, final Collection col) {
this.context = context;
try {
workspaceItem = workspaceItemService.create(context, col1, false);
workspaceItem = workspaceItemService.create(context, col, false);
} catch (Exception e) {
return handleException(e);
}

View File

@@ -7,12 +7,20 @@
*/
package org.dspace.app.rest.matcher;
import org.hamcrest.Matcher;
import static com.jayway.jsonpath.matchers.JsonPathMatchers.hasJsonPath;
import static org.dspace.app.rest.test.AbstractControllerIntegrationTest.REST_SERVER_URL;
import static org.hamcrest.Matchers.*;
import static org.hamcrest.Matchers.allOf;
import static org.hamcrest.Matchers.is;
import static org.hamcrest.Matchers.startsWith;
import org.hamcrest.Matcher;
/**
* Class to match JSON browse entries in ITs
*
* @author Tom Desair (tom dot desair at atmire dot com)
* @author Raf Ponsaerts (raf dot ponsaerts at atmire dot com)
*/
public class BrowseEntryResourceMatcher {
public static Matcher<? super Object> matchBrowseEntry(String value, int expectedCount) {
return allOf(

View File

@@ -9,7 +9,10 @@ package org.dspace.app.rest.matcher;
import static com.jayway.jsonpath.matchers.JsonPathMatchers.hasJsonPath;
import static org.dspace.app.rest.test.AbstractControllerIntegrationTest.REST_SERVER_URL;
import static org.hamcrest.Matchers.*;
import static org.hamcrest.Matchers.allOf;
import static org.hamcrest.Matchers.contains;
import static org.hamcrest.Matchers.containsInAnyOrder;
import static org.hamcrest.Matchers.is;
import static org.hamcrest.text.IsEqualIgnoringCase.equalToIgnoringCase;
import org.hamcrest.Matcher;
@@ -17,6 +20,9 @@ import org.hamcrest.Matchers;
/**
* Utility class to construct a Matcher for a browse index
*
* @author Tom Desair (tom dot desair at atmire dot com)
* @author Raf Ponsaerts (raf dot ponsaerts at atmire dot com)
*/
public class BrowseIndexMatcher {

View File

@@ -20,6 +20,9 @@ import org.hamcrest.Matcher;
/**
* Utility class to construct a Matcher for an item
*
* @author Tom Desair (tom dot desair at atmire dot com)
* @author Raf Ponsaerts (raf dot ponsaerts at atmire dot com)
*/
public class ItemMatcher {

View File

@@ -26,6 +26,7 @@ import org.junit.Assert;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.web.support.ErrorPageFilter;
import org.springframework.hateoas.MediaTypes;
import org.springframework.http.MediaType;
import org.springframework.http.converter.HttpMessageConverter;
@@ -46,6 +47,8 @@ import org.springframework.web.context.WebApplicationContext;
/**
* Abstract controller integration test class that will take care of setting up the
* environment to run the integration test
*
* @author Tom Desair (tom dot desair at atmire dot com)
*/
@RunWith(SpringJUnit4ClassRunner.class)
@SpringBootTest(classes = {Application.class, ApplicationConfig.class, WebSecurityConfiguration.class})
@@ -92,9 +95,10 @@ public class AbstractControllerIntegrationTest extends AbstractIntegrationTestWi
}
DefaultMockMvcBuilder mockMvcBuilder = webAppContextSetup(webApplicationContext)
//Always log the repsonse to debug
//Always log the response to debug
.alwaysDo(MockMvcResultHandlers.log())
//Add all filter implementations
.addFilters(new ErrorPageFilter())
.addFilters(requestFilters.toArray(new Filter[requestFilters.size()]));
if(StringUtils.isNotBlank(authToken)) {

View File

@@ -143,8 +143,7 @@ public class AbstractIntegrationTestWithDatabase extends AbstractDSpaceIntegrati
* but no execution order is guaranteed
*/
@After
public void destroy()
{
public void destroy() throws Exception {
// Cleanup our global context object
try {
if(context == null || !context.isValid()){

View File

@@ -16,6 +16,8 @@ import org.springframework.test.context.ContextCustomizerFactory;
/**
* Context customizer factory to set the parent context of our Spring Boot application in TEST mode
*
* @author Tom Desair (tom dot desair at atmire dot com)
*/
public class DSpaceKernelContextCustomizerFactory implements ContextCustomizerFactory {

View File

@@ -0,0 +1,528 @@
/**
* The contents of this file are subject to the license and copyright
* detailed in the LICENSE and NOTICE files at the root of the source
* tree and available online at
*
* http://www.dspace.org/license/
*/
package org.dspace.app.rest.utils;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.fail;
import static org.mockito.Matchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.io.IOException;
import java.io.InputStream;
import java.util.Date;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.apache.commons.io.IOUtils;
import org.apache.commons.lang3.CharEncoding;
import org.apache.log4j.Logger;
import org.dspace.authorize.AuthorizeException;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.web.util.ContentCachingRequestWrapper;
import org.springframework.web.util.ContentCachingResponseWrapper;
/**
* Test class for MultipartFileSender
*
* @author Tom Desair (tom dot desair at atmire dot com)
* @author Frederic Van Reet (frederic dot vanreet at atmire dot com)
*/
public class MultipartFileSenderTest {
/**
* log4j category
*/
private static final Logger log = Logger.getLogger(MultipartFileSenderTest.class);
private InputStream is;
private String mimeType;
private long lastModified;
private long length;
private String fileName;
private String checksum;
private HttpServletRequest request;
private HttpServletResponse response;
private ContentCachingRequestWrapper requestWrapper;
private ContentCachingResponseWrapper responseWrapper;
/**
* This method will be run before every test as per @Before. It will
* initialize resources required for the tests.
* <p>
* Other methods can be annotated with @Before here or in subclasses
* but no execution order is guaranteed
*/
@Before
public void init() throws AuthorizeException {
try {
String content = "0123456789";
this.is = IOUtils.toInputStream(content, CharEncoding.UTF_8);
this.fileName = "Test-Item.txt";
this.mimeType = "text/plain";
this.lastModified = new Date().getTime();
this.length = content.getBytes().length;
this.checksum = "testsum";
this.request = mock(HttpServletRequest.class);
this.response = new MockHttpServletResponse();
//Using wrappers so we can save the content of the bodies and use them for tests
this.requestWrapper = new ContentCachingRequestWrapper(request);
this.responseWrapper = new ContentCachingResponseWrapper(response);
} catch (IOException ex) {
log.error("IO Error in init", ex);
fail("SQL Error in init: " + ex.getMessage());
}
}
/**
* This method will be run after every test as per @After. It will
* clean resources initialized by the @Before methods.
* <p>
* Other methods can be annotated with @After here or in subclasses
* but no execution order is guaranteed
*/
@After
public void destroy() {
try {
is.close();
} catch (IOException e) {
e.printStackTrace();
}
}
/**
* Test if Range header is supported and gives back the right range
* @throws Exception
*/
@Test
public void testRangeHeader() throws Exception {
MultipartFileSender multipartFileSender = MultipartFileSender
.fromInputStream(is)
.with(requestWrapper)
.with(responseWrapper)
.withFileName(fileName)
.withChecksum(checksum)
.withMimetype(mimeType)
.withLength(length);
when(request.getHeader(eq("If-Range"))).thenReturn("not_file_to_serve.txt");
when(request.getHeader(eq("Range"))).thenReturn("bytes=1-3");
multipartFileSender.serveResource();
String content = new String(responseWrapper.getContentAsByteArray(), CharEncoding.UTF_8);
assertEquals("123", content);
}
/**
* Test if we can just request the full file without ranges
* @throws Exception
*/
@Test
public void testFullFileReturn() throws Exception {
MultipartFileSender multipartFileSender = MultipartFileSender
.fromInputStream(is)
.with(requestWrapper)
.with(responseWrapper)
.withFileName(fileName)
.withChecksum(checksum)
.withMimetype(mimeType)
.withLength(length);
multipartFileSender.serveResource();
String content = new String(responseWrapper.getContentAsByteArray(), CharEncoding.UTF_8);
assertEquals("0123456789", content);
assertEquals(checksum, responseWrapper.getHeader("ETag"));
}
/**
* Test for support of Open ranges
* @throws Exception
*/
@Test
public void testOpenRange() throws Exception {
MultipartFileSender multipartFileSender = MultipartFileSender
.fromInputStream(is)
.with(requestWrapper)
.with(responseWrapper)
.withFileName(fileName)
.withChecksum(checksum)
.withMimetype(mimeType)
.withLength(length);
when(request.getHeader(eq("Range"))).thenReturn("bytes=5-");
multipartFileSender.serveResource();
String content = new String(responseWrapper.getContentAsByteArray(), CharEncoding.UTF_8);
assertEquals("56789", content);
}
/**
* Test support for multiple ranges
* @throws Exception
*/
@Test
public void testMultipleRanges() throws Exception {
MultipartFileSender multipartFileSender = MultipartFileSender
.fromInputStream(is)
.with(requestWrapper)
.with(responseWrapper)
.withFileName(fileName)
.withChecksum(checksum)
.withMimetype(mimeType)
.withLength(length);
when(request.getHeader(eq("Range"))).thenReturn("bytes=1-2,3-4,5-9");
multipartFileSender.serveResource();
String content = new String(responseWrapper.getContentAsByteArray(), CharEncoding.UTF_8);
assertEquals("--MULTIPART_BYTERANGES" +
"Content-Type: text/plain" +
"Content-Range: bytes 1-2/10" +
"12" +
"--MULTIPART_BYTERANGES" +
"Content-Type: text/plain" +
"Content-Range: bytes 3-4/10" +
"34" +
"--MULTIPART_BYTERANGES" +
"Content-Type: text/plain" +
"Content-Range: bytes 5-9/10" +
"56789" +
"--MULTIPART_BYTERANGES--".replace("\n", "").replace("\r", "")
, content.replace("\n", "").replace("\r", "")
);
}
/**
* Test with a unvalid Range header, should return status 416
* @throws Exception
*/
@Test
public void testInvalidRange() throws Exception {
MultipartFileSender multipartFileSender = MultipartFileSender
.fromInputStream(is)
.with(requestWrapper)
.with(responseWrapper)
.withFileName(fileName)
.withChecksum(checksum)
.withMimetype(mimeType)
.withLength(length);
when(request.getHeader(eq("Range"))).thenReturn("bytes=invalid");
multipartFileSender.serveResource();
assertEquals(HttpServletResponse.SC_REQUESTED_RANGE_NOT_SATISFIABLE, responseWrapper.getStatusCode());
}
/**
* Test if the ETAG is in the response header
* @throws Exception
*/
@Test
public void testEtagInResponse() throws Exception {
MultipartFileSender multipartFileSender = MultipartFileSender
.fromInputStream(is)
.with(requestWrapper)
.with(responseWrapper)
.withFileName(fileName)
.withChecksum(checksum)
.withMimetype(mimeType)
.withLength(length);
when(request.getHeader(eq("Range"))).thenReturn("bytes=1-3");
multipartFileSender.serveResource();
String etag = responseWrapper.getHeader("Etag");
assertEquals(checksum, etag);
}
//Check that a head request doesn't return any body, but returns the headers
@Test
public void testHeadRequest() throws Exception {
MultipartFileSender multipartFileSender = MultipartFileSender
.fromInputStream(is)
.with(requestWrapper)
.with(responseWrapper)
.withFileName(fileName)
.withChecksum(checksum)
.withMimetype(mimeType)
.withLength(length);
when(request.getMethod()).thenReturn("HEAD");
multipartFileSender.serveResource();
String content = new String(responseWrapper.getContentAsByteArray(), CharEncoding.UTF_8);
assertEquals("bytes", responseWrapper.getHeader("Accept-Ranges"));
assertEquals(checksum, responseWrapper.getHeader("ETag"));
assertEquals("", content);
assertEquals(200, responseWrapper.getStatusCode());
}
/**
* If ETAG is equal to that of the requested Resource then this should return 304
*
* @throws Exception
*/
@Test
public void testIfNoneMatchFail() throws Exception {
MultipartFileSender multipartFileSender = MultipartFileSender
.fromInputStream(is)
.with(requestWrapper)
.with(responseWrapper)
.withFileName(fileName)
.withChecksum(checksum)
.withMimetype(mimeType)
.withLength(length);
when(request.getHeader(eq("If-None-Match"))).thenReturn(checksum);
multipartFileSender.isValid();
assertEquals(HttpServletResponse.SC_NOT_MODIFIED, responseWrapper.getStatusCode());
}
/**
* Happy path of If-None-Match header
* @throws Exception
*/
@Test
public void testIfNoneMatchPass() throws Exception {
MultipartFileSender multipartFileSender = MultipartFileSender
.fromInputStream(is)
.with(requestWrapper)
.with(responseWrapper)
.withFileName(fileName)
.withChecksum(checksum)
.withMimetype(mimeType)
.withLength(length);
when(request.getHeader(eq("If-None-Match"))).thenReturn("pretendthisisarandomchecksumnotequaltotherequestedbitstream");
multipartFileSender.isValid();
multipartFileSender.serveResource();
assertEquals(HttpServletResponse.SC_OK, responseWrapper.getStatusCode());
}
/**
* If the bitstream has no filename this should throw an internal server error
* @throws Exception
*/
@Test
public void testNoFileName() throws Exception {
MultipartFileSender multipartFileSender = MultipartFileSender
.fromInputStream(is)
.with(requestWrapper)
.with(responseWrapper)
.withChecksum(checksum)
.withMimetype(mimeType)
.withLength(length);
multipartFileSender.isValid();
assertEquals(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, responseWrapper.getStatusCode());
}
/**
* Test if the Modified Since precondition works, should return 304 if it hasn't been modified
* @throws Exception
*/
@Test
public void testIfModifiedSinceNotModifiedSince() throws Exception {
Long time = new Date().getTime();
MultipartFileSender multipartFileSender = MultipartFileSender
.fromInputStream(is)
.with(requestWrapper)
.withFileName(fileName)
.with(responseWrapper)
.withChecksum(checksum)
.withMimetype(mimeType)
.withLength(length)
.withLastModified(time);
when(request.getDateHeader(eq("If-Modified-Since"))).thenReturn(time + 100000);
when(request.getDateHeader(eq("If-Unmodified-Since"))).thenReturn(-1L);
multipartFileSender.isValid();
assertEquals(HttpServletResponse.SC_NOT_MODIFIED, responseWrapper.getStatusCode());
}
/**
* Happy path for modified since
* @throws Exception
*/
@Test
public void testIfModifiedSinceModifiedSince() throws Exception {
Long time = new Date().getTime();
MultipartFileSender multipartFileSender = MultipartFileSender
.fromInputStream(is)
.with(requestWrapper)
.withFileName(fileName)
.with(responseWrapper)
.withChecksum(checksum)
.withMimetype(mimeType)
.withLength(length)
.withLastModified(time);
when(request.getDateHeader(eq("If-Modified-Since"))).thenReturn(time - 100000);
when(request.getDateHeader(eq("If-Unmodified-Since"))).thenReturn(-1L);
multipartFileSender.isValid();
multipartFileSender.serveResource();
assertEquals(HttpServletResponse.SC_OK, responseWrapper.getStatusCode());
}
/**
* If the If-Match doesn't match the ETAG then return 416 Status code
* @throws Exception
*/
@Test
public void testIfMatchNoMatch() throws Exception {
Long time = new Date().getTime();
MultipartFileSender multipartFileSender = MultipartFileSender
.fromInputStream(is)
.with(requestWrapper)
.withFileName(fileName)
.with(responseWrapper)
.withChecksum(checksum)
.withMimetype(mimeType)
.withLength(length)
.withLastModified(time);
when(request.getDateHeader(eq("If-Modified-Since"))).thenReturn(-1L);
when(request.getDateHeader(eq("If-Unmodified-Since"))).thenReturn(-1L);
when(request.getHeader(eq("If-Match"))).thenReturn("None-Matching-ETAG");
multipartFileSender.isValid();
assertEquals(HttpServletResponse.SC_REQUESTED_RANGE_NOT_SATISFIABLE, responseWrapper.getStatusCode());
}
/**
* If matches then just return resource
* @throws Exception
*/
@Test
public void testIfMatchMatch() throws Exception {
Long time = new Date().getTime();
MultipartFileSender multipartFileSender = MultipartFileSender
.fromInputStream(is)
.with(requestWrapper)
.withFileName(fileName)
.with(responseWrapper)
.withChecksum(checksum)
.withMimetype(mimeType)
.withLength(length)
.withLastModified(time);
when(request.getDateHeader(eq("If-Modified-Since"))).thenReturn(-1L);
when(request.getDateHeader(eq("If-Unmodified-Since"))).thenReturn(-1L);
when(request.getHeader(eq("If-Match"))).thenReturn(checksum);
multipartFileSender.isValid();
assertEquals(HttpServletResponse.SC_OK, responseWrapper.getStatusCode());
}
/**
* If not modified since given date then return resource
* @throws Exception
*/
@Test
public void testIfUnmodifiedSinceNotModifiedSince() throws Exception {
Long time = new Date().getTime();
MultipartFileSender multipartFileSender = MultipartFileSender
.fromInputStream(is)
.with(requestWrapper)
.withFileName(fileName)
.with(responseWrapper)
.withChecksum(checksum)
.withMimetype(mimeType)
.withLength(length)
.withLastModified(time);
when(request.getDateHeader(eq("If-Unmodified-Since"))).thenReturn(time + 100000);
when(request.getDateHeader(eq("If-Modified-Since"))).thenReturn(-1L);
multipartFileSender.isValid();
assertEquals(HttpServletResponse.SC_OK, responseWrapper.getStatusCode());
}
/**
* If modified since given date then return 412
* @throws Exception
*/
@Test
public void testIfUnmodifiedSinceModifiedSince() throws Exception {
Long time = new Date().getTime();
MultipartFileSender multipartFileSender = MultipartFileSender
.fromInputStream(is)
.with(requestWrapper)
.withFileName(fileName)
.with(responseWrapper)
.withChecksum(checksum)
.withMimetype(mimeType)
.withLength(length)
.withLastModified(time);
when(request.getDateHeader(eq("If-Unmodified-Since"))).thenReturn(time - 100000);
when(request.getDateHeader(eq("If-Modified-Since"))).thenReturn(-1L);
multipartFileSender.isValid();
assertEquals(HttpServletResponse.SC_PRECONDITION_FAILED, responseWrapper.getStatusCode());
}
}

View File

@@ -1813,7 +1813,7 @@ webui.suggest.enable = false
# inside that snipet is your Google Analytics key usually found in this line:
# _uacct = "UA-XXXXXXX-X"
# Take this key (just the UA-XXXXXX-X part) and place it here in this parameter.
# jspui.google.analytics.key=UA-XXXXXX-X
# google.analytics.key=UA-XXXXXX-X
#---------------------------------------------------------------#
#--------------XMLUI SPECIFIC CONFIGURATIONS--------------------#
@@ -1919,15 +1919,6 @@ mirage2.item-view.bitstream.href.label.2 = title
#xmlui.bitstream.mods = true
#xmlui.bitstream.mets = true
# If you would like to use Google Analytics to track general website statistics then
# use the following parameter to provide your Analytics key. First sign up for an
# account at http://analytics.google.com, then create an entry for your repository
# website. Analytics will give you a snipet of JavaScript code to place on your site,
# inside that snipet is your Google Analytics key usually found in this line:
# _uacct = "UA-XXXXXXX-X"
# Take this key (just the UA-XXXXXX-X part) and place it here in this parameter.
#xmlui.google.analytics.key=UA-XXXXXX-X
# Assign how many page views will be recorded and displayed in the control panel's
# activity viewer. The activity tab allows an administrator to debug problems in a
# running DSpace by understanding who and how their dspace is currently being used.

View File

@@ -7,5 +7,4 @@
# The class names of the modules which the dspace servicemanager will attempt to retrieve.
# These classes contain the paths to where to spring files are loaded
spring.springloader.modules=org.dspace.app.configuration.APISpringLoader,\
org.dspace.app.xmlui.configuration.XMLUISpringLoader,\
org.dspace.app.webui.configuration.JSPUISpringLoader
org.dspace.app.rest.configuration.RESTSpringLoader

View File

@@ -316,6 +316,33 @@
<extension>qt</extension>
</bitstream-type>
<bitstream-type>
<mimetype>video/mp4</mimetype>
<short_description>Video MP4</short_description>
<description>Video MP4</description>
<support_level>1</support_level>
<internal>false</internal>
<extension>mp4</extension>
</bitstream-type>
<bitstream-type>
<mimetype>video/ogg</mimetype>
<short_description>Video OGG</short_description>
<description>Video OGG</description>
<support_level>1</support_level>
<internal>false</internal>
<extension>ogg</extension>
</bitstream-type>
<bitstream-type>
<mimetype>video/webm</mimetype>
<short_description>Video WEBM</short_description>
<description>Video WEBM</description>
<support_level>1</support_level>
<internal>false</internal>
<extension>webm</extension>
</bitstream-type>
<bitstream-type>
<mimetype>audio/x-mpeg</mimetype>
<short_description>MPEG Audio</short_description>
@@ -736,4 +763,22 @@
<extension>epub</extension>
</bitstream-type>
<bitstream-type>
<mimetype>video/mp4</mimetype>
<short_description>mp4</short_description>
<description>mpeg4</description>
<support_level>1</support_level>
<internal>false</internal>
<extension>mp4</extension>
</bitstream-type>
<bitstream-type>
<mimetype>audio/mpeg</mimetype>
<short_description>mp3</short_description>
<description>MPEG audio</description>
<support_level>1</support_level>
<internal>false</internal>
<extension>mp3</extension>
</bitstream-type>
</dspace-bitstream-types>

View File

@@ -92,10 +92,6 @@
<bean class="org.dspace.license.CreativeCommonsServiceImpl"/>
<!-- Statistics services are both lazy loaded (by name), as you are likely just using ONE of them and not both -->
<bean id="elasticSearchLoggerService" class="org.dspace.statistics.ElasticSearchLoggerServiceImpl" lazy-init="true"/>
<bean id="solrLoggerService" class="org.dspace.statistics.SolrLoggerServiceImpl" lazy-init="true"/>
<bean id="spiderDetectorService" class="org.dspace.statistics.util.SpiderDetectorServiceImpl"/>
<bean class="org.dspace.versioning.VersionHistoryServiceImpl"/>

View File

@@ -9,24 +9,24 @@
-->
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:context="http://www.springframework.org/schema/context"
xsi:schemaLocation="http://www.springframework.org/schema/beans
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:context="http://www.springframework.org/schema/context"
xsi:schemaLocation="http://www.springframework.org/schema/beans
http://www.springframework.org/schema/beans/spring-beans-2.5.xsd
http://www.springframework.org/schema/context
http://www.springframework.org/schema/context/spring-context-2.5.xsd"
default-autowire-candidates="*Service,*DAO,javax.sql.DataSource">
default-autowire-candidates="*Service,*DAO,javax.sql.DataSource">
<context:annotation-config /> <!-- allows us to use spring annotations in beans -->
<!-- NOTE: I am not convinced this is a good idea, it is really slow and I think possibly dangerous -AZ -->
<!--
<context:component-scan base-package="org.dspace" name-generator="org.dspace.servicemanager.spring.FullPathBeanNameGenerator" />
-->
<bean class="org.dspace.discovery.SolrServiceImpl" id="org.dspace.discovery.SearchService"/>
<alias name="org.dspace.discovery.SearchService" alias="org.dspace.discovery.IndexingService"/>
<!--<bean class="org.dspace.discovery.SolrServiceIndexOutputPlugin" id="solrServiceIndexOutputPlugin"/>-->
<!-- Statistics services are both lazy loaded (by name), as you are likely just using ONE of them and not both -->
<bean id="elasticSearchLoggerService" class="org.dspace.statistics.ElasticSearchLoggerServiceImpl" lazy-init="true"/>
<bean id="solrLoggerService" class="org.dspace.statistics.SolrLoggerServiceImpl" lazy-init="true"/>
</beans>

View File

@@ -0,0 +1 @@
You should only add Spring XML definition files here if there is really no way to load them through automatic component scanning.

View File

@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<beans
xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:util="http://www.springframework.org/schema/util"
xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans-4.3.xsd
http://www.springframework.org/schema/util http://www.springframework.org/schema/util/spring-util-4.3.xsd">
<!-- Inject the Default LoggerUsageEventListener into the EventService -->
<bean class="org.dspace.usage.LoggerUsageEventListener">
<property name="eventService" ref="org.dspace.services.EventService"/>
</bean>
<!-- Inject the SolrLoggerUsageEventListener into the EventService -->
<bean class="org.dspace.statistics.SolrLoggerUsageEventListener">
<property name="eventService" ref="org.dspace.services.EventService"/>
</bean>
<!-- Google Analytics recording -->
<bean class="org.dspace.google.GoogleRecorderEventListener">
<property name="eventService" ref="org.dspace.services.EventService"/>
</bean>
</beans>