AmphibianController.java

/*
 * Copyright 2019 Global Crop Diversity Trust
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *   http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package org.genesys.server.api.v1;

import java.io.IOException;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;

import javax.servlet.http.HttpServletResponse;
import javax.validation.Valid;

import org.apache.commons.lang3.StringUtils;
import org.genesys.amphibian.client.model.DatasetTable;
import org.genesys.amphibian.client.model.HeatMap;
import org.genesys.amphibian.client.model.LongToWide;
import org.genesys.amphibian.client.model.ObservationChart;
import org.genesys.amphibian.client.model.ObservationHistogram;
import org.genesys.amphibian.client.model.Preview;
import org.genesys.amphibian.client.model.PreviewDataFilter;
import org.genesys.amphibian.client.model.ValueMapping;
import org.genesys.filerepository.InvalidRepositoryFileDataException;
import org.genesys.filerepository.InvalidRepositoryPathException;
import org.genesys.filerepository.NoSuchRepositoryFileException;
import org.genesys.filerepository.model.RepositoryFile;
import org.genesys.filerepository.service.RepositoryService;
import org.genesys.server.api.ApiBaseController;
import org.genesys.server.api.Pagination;
import org.genesys.server.component.aspect.DownloadEndpoint;
import org.genesys.server.exception.InvalidApiUsageException;
import org.genesys.server.exception.NotFoundElement;
import org.genesys.server.model.traits.Descriptor;
import org.genesys.server.service.AmphibianService;
import org.genesys.server.service.AmphibianService.AmphibianException;
import org.genesys.server.service.AmphibianService.NoAccessionsForFilterException;
import org.genesys.server.service.AmphibianService.PreviewAccessionRef;
import org.genesys.server.service.AmphibianService.TraitFilters;
import org.genesys.server.service.DatasetService;
import org.genesys.server.service.DescriptorService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Page;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.client.HttpClientErrorException;
import org.springframework.web.multipart.MultipartFile;

import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonUnwrapped;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import com.google.common.collect.ImmutableList;

import io.swagger.annotations.ApiOperation;
import io.swagger.annotations.ApiParam;

@RestController
@PreAuthorize("isAuthenticated()")
@RequestMapping(AmphibianController.CONTROLLER_URL)
public class AmphibianController extends ApiBaseController implements InitializingBean {
	private static final Logger LOG = LoggerFactory.getLogger(AmphibianController.class);

	/** The Constant CONTROLLER_URL. */
	public static final String CONTROLLER_URL = ApiBaseController.APIv1_BASE + "/amphibian";

	@Autowired
	private AmphibianService amphibianService;

	@Autowired
	private RepositoryService repositoryService;

	@Autowired
	private DescriptorService descriptorService;

	@Autowired
	private DatasetService datasetService;
	
	private ObjectMapper objectMapper;

	@Override
	public void afterPropertiesSet() throws Exception {
		objectMapper = new ObjectMapper();
		objectMapper.registerModule(new JavaTimeModule());

		objectMapper.enable(DeserializationFeature.ACCEPT_EMPTY_STRING_AS_NULL_OBJECT);
		objectMapper.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);
		objectMapper.configure(DeserializationFeature.FAIL_ON_MISSING_EXTERNAL_TYPE_ID_PROPERTY, false);
		objectMapper.enable(DeserializationFeature.ACCEPT_SINGLE_VALUE_AS_ARRAY);
	}

	/**
	 * Gets the preview.
	 *
	 * @param uuid the repositoryFile UUID
	 * @return the preview
	 * @throws NoSuchRepositoryFileException the no such repository file exception
	 * @throws IOException Signals that an I/O exception has occurred.
	 * @throws AmphibianException 
	 */
	@GetMapping(value = "/preview/{uuid:\\w{8}\\-\\w{4}\\-.{22}}")
	@ApiOperation(value = "Get preview of parsed data", notes = "Data will be parsed if the preview is not yet available")
	public Preview getPreview(@PathVariable(name = "uuid", required = true) UUID uuid) throws NoSuchRepositoryFileException, IOException, AmphibianException {
		RepositoryFile repositoryFile = repositoryService.getFile(uuid);

		try {
			return amphibianService.loadPreview(uuid);
		} catch (HttpClientErrorException e) {
			if (e.getStatusCode() == HttpStatus.NOT_FOUND) {
				return amphibianService.makePreview(uuid, repositoryFile);
			} else {
				throw e;
			}
		}
	}

	/**
	 * Delete the preview.
	 *
	 * @param uuid the repositoryFile UUID
	 * @throws IOException Signals that an I/O exception has occurred.
	 * @throws AmphibianException 
	 */
	@DeleteMapping(value = "/preview/{uuid:\\w{8}\\-\\w{4}\\-.{22}}")
	@ApiOperation(value = "Delete the preview of parsed data")
	public boolean deletePreview(@PathVariable(name = "uuid", required = true) UUID uuid) throws NoSuchRepositoryFileException, IOException, AmphibianException {

		try {
			var preview = amphibianService.loadPreview(uuid);
			if (preview != null) {
				repositoryService.getFile(uuid); // Permission check
				amphibianService.deletePreview(preview);
			}
			return true;
		} catch (HttpClientErrorException e) {
			if (e.getStatusCode() == HttpStatus.NOT_FOUND) {
				return false;
			} else {
				throw e;
			}
		}
	}

	@GetMapping(path = { "/preview/{uuid:\\w{8}\\-\\w{4}\\-.{22}}/{sheet:\\d+}/{startRow:\\d+}" })
	@ApiOperation(value = "Get the overview of the parsed dataset", notes = "Use the same reference UUID as provided when ingesting a dataset")
	public List<Object> getData(@ApiParam(value = "Your reference UUID", required = true) @PathVariable UUID uuid, // uuid
			@ApiParam(value = "Sheet index", required = true) @PathVariable int sheet, // sheet index
			@ApiParam(value = "Index of the first row", required = true) @PathVariable long startRow, // start row
			@ApiParam(value = "Number of rows to return", required = false) @RequestParam(name = "count", required = false, defaultValue = "50") Optional<Integer> count,
			@ApiParam(value = "Specify fields to return", required = true) @RequestParam(required = false) List<String> fields // selected columns
			) throws NoSuchRepositoryFileException, AmphibianException {

		repositoryService.getFile(uuid);

		return amphibianService.getPreviewData(uuid, sheet, startRow, count.orElse(50), fields == null ? List.of() : fields, null);
	}

	@PostMapping(path = { "/preview/{uuid:\\w{8}\\-\\w{4}\\-.{22}}/{sheet:\\d+}/{startRow:\\d+}" })
	@ApiOperation(value = "Get the overview data matching preview filters", notes = "Use the same reference UUID as provided when ingesting a dataset")
	public List<Object> getDataFiltered(@ApiParam(value = "Your reference UUID", required = true) @PathVariable UUID uuid, // uuid
			@ApiParam(value = "Sheet index", required = true) @PathVariable int sheet, // sheet index
			@ApiParam(value = "Index of the first row", required = true) @PathVariable long startRow, // start row
			@ApiParam(value = "Number of rows to return", required = false) @RequestParam(name = "count", required = false, defaultValue = "50") Optional<Integer> count,
			@ApiParam(value = "Specify fields to return", required = true) @RequestParam(required = false) List<String> fields, // selected columns
			@ApiParam(value = "Preview data filters", required = false) @RequestBody(required = false) Optional<PreviewDataFilter> previewFilter // filters
			) throws NoSuchRepositoryFileException, AmphibianException {

		repositoryService.getFile(uuid);

		return amphibianService.getPreviewData(uuid, sheet, startRow, count.orElse(50), fields == null ? List.of() : fields, previewFilter.orElse(null));
	}

	@PutMapping(path = { "/preview/{uuid:\\w{8}\\-\\w{4}\\-.{22}}/{sheet:\\d+}/meta" })
	@ApiOperation(value = "Add accessionRefs to Preview", notes = "Use the same reference UUID as provided when ingesting a dataset")
	public long addAccessionRefs(@ApiParam(value = "Your reference UUID", required = true) @PathVariable UUID uuid, // uuid
			@ApiParam(value = "Sheet index", required = true) @PathVariable int sheet, // sheet index
			@ApiParam(value = "Accession references", required = true) @RequestBody(required = true) @Valid List<PreviewAccessionRef> accessionRefs  // unlinked accession refs
			) throws NoSuchRepositoryFileException, AmphibianException {

		repositoryService.getFile(uuid);

		return amphibianService.addAccessionRefs(uuid, sheet, accessionRefs);
	}

	@PostMapping(path = { "/preview/{uuid:\\w{8}\\-\\w{4}\\-.{22}}/pivot" })
	@ApiOperation(value = "Add a sheet of wide data to the Preview", notes = "Use the same reference UUID as provided when ingesting a dataset")
	public Preview longToWide(@ApiParam(value = "Your reference UUID", required = true) @PathVariable UUID uuid, // uuid
			@RequestBody @Valid LongToWide longToWide
			) throws NoSuchRepositoryFileException, AmphibianException {

		repositoryService.getFile(uuid);

		return amphibianService.longToWide(uuid, longToWide);
	}

	@PostMapping(value = "/upload")
	@ApiOperation(value = "Upload any dataset file")
	public Preview uploadFile(@RequestPart(name = "file", required = true) final MultipartFile file) throws InvalidRepositoryFileDataException,
			AmphibianException, InvalidRepositoryPathException, IOException {

		return amphibianService.uploadFile(file);
	}

	@PutMapping(value = "/update/{uuid:\\w{8}\\-\\w{4}\\-.{22}}")
	@ApiOperation(value = "Update the content of the existing dataset file")
	public Preview updateFileContent(@PathVariable(required = true) final UUID uuid, @RequestPart(name = "file", required = true) final MultipartFile file)
			throws NoSuchRepositoryFileException, AmphibianException, IOException {

		return amphibianService.updateFileContent(uuid, file);
	}

	@DeleteMapping(value = "/delete/{uuid:\\w{8}\\-\\w{4}\\-.{22}}")
	@ApiOperation(value = "Delete the existing dataset file")
	public void deleteFile(@PathVariable(required = true) final UUID uuid)
			throws NoSuchRepositoryFileException, AmphibianException, IOException {

		amphibianService.deleteFile(uuid);
	}

	@GetMapping(value = "/list-files")
	@ApiOperation(value = "List of uploaded dataset files")
	public List<RepositoryFile> listFiles() throws InvalidRepositoryPathException {
		return amphibianService.myFiles();
	}

	@PostMapping(value = "/stats/{uuid:\\w{8}\\-\\w{4}\\-.{22}}/{sheet}/{startRow}")
	@ApiOperation(value = "Get the statistics of the parsed dataset", notes = "Use the same reference UUID as provided when ingesting a dataset")
	public List<?> getStatisticsData(@ApiParam(value = "Your reference UUID", required = true) @PathVariable UUID uuid, // uuid
			@ApiParam(value = "Sheet index", required = true) @PathVariable int sheet, // sheet index
			@ApiParam(value = "Index of the first row", required = true) @PathVariable long startRow, // start row
			@ApiParam(value = "Maximum number of distinct values to fetch", required = false) @RequestParam(required = true, defaultValue = "100") int maxDistinct, // start row
			@ApiParam(value = "Specify fields to analyze", required = true) @RequestParam(required = true) String[] fields)
			throws NoSuchRepositoryFileException, AmphibianException {

		repositoryService.getFile(uuid);
		LOG.debug("Getting {} stats for {}", maxDistinct, fields);
		return amphibianService.getStatisticsData(uuid, sheet, startRow, maxDistinct, ImmutableList.copyOf(fields));
	}


	@PostMapping(path = { "/preview/{uuid:\\w{8}\\-\\w{4}\\-.{22}}/{sheet:\\d+}/validate/{field:C\\d+}" })
	@ApiOperation(value = "Validate Preview data against Descriptor", notes = "Use the same reference UUID as provided when ingesting a dataset")
	public Set<?> validatePreviewField(
			@ApiParam(value = "Your reference UUID", required = true) @PathVariable UUID uuid, // uuid
			@ApiParam(value = "Sheet index", required = true) @PathVariable int sheet, // sheet index
			@ApiParam(value = "Index of the first row", required = true) @RequestParam long startRow, // start row
			@ApiParam(value = "Preview column to validate", required = true) @PathVariable String field, // Field to validate
			@ApiParam(value = "Array separator", required = true) @RequestParam String arraySeparator, // Separator
			@ApiParam(value = "Descriptor UUID", required = true) @RequestParam UUID descriptor, // descriptor
			@ApiParam(value = "Value mapping") @RequestBody(required = false) List<ValueMapping> mapping // value mapping
			) throws NoSuchRepositoryFileException, AmphibianException {

		repositoryService.getFile(uuid);
		var d = descriptorService.loadDescriptor(descriptor);
		return amphibianService.ingestDryRun(uuid, sheet, startRow, field, arraySeparator, mapping, d);
	}

	@PostMapping(path = { "/{datasetUuid:\\w{8}\\-\\w{4}\\-.{22}}/add-observations" })
	@ApiOperation(value = "Add observations from Preview to Datatable", notes = "")
	public Set<?> addPreviewObservationsToDatatable(
			@ApiParam(value = "Dataset UUID", required = true) @PathVariable UUID datasetUuid, // Dataset UUID
			@ApiParam(value = "Descriptor UUID", required = true) @RequestParam UUID descriptor, // descriptor
			@ApiParam(value = "Preview UUID", required = true) @RequestParam("preview") UUID previewUuid, // Preview UUID
			@ApiParam(value = "Sheet index", required = true) @RequestParam int sheet, // sheet index
			@ApiParam(value = "Index of the first row", required = true) @RequestParam long startRow, // start row
			@ApiParam(value = "Preview column to validate", required = true) @RequestParam String field, // Field to validate
			@ApiParam(value = "Array separator", required = true) @RequestParam String arraySeparator, // Separator
			@ApiParam(value = "Value mapping") @RequestBody(required = false) List<ValueMapping> mapping // value mapping
			) throws NoSuchRepositoryFileException, AmphibianException {

		var dataset = datasetService.getDataset(datasetUuid);
		var preview = amphibianService.loadPreview(previewUuid);
		var d = descriptorService.loadDescriptor(descriptor);

		return amphibianService.ingestFromPreview(dataset, d, preview, sheet, startRow, field, arraySeparator, mapping);
	}

	@DeleteMapping(path = { "/{datasetUuid:\\w{8}\\-\\w{4}\\-.{22}}" })
	@ApiOperation(value = "Remove observations of descriptor from Datatable", notes = "")
	public DatasetTable removeObservationsFromDatatable(
			@ApiParam(value = "Dataset UUID", required = true) @PathVariable UUID datasetUuid, // Dataset UUID
			@ApiParam(value = "Descriptor UUID", required = true) @RequestParam UUID descriptor // descriptor
			) throws NoSuchRepositoryFileException, AmphibianException {

		var dataset = datasetService.getDataset(datasetUuid);
		var d = descriptorService.loadDescriptor(descriptor);

		return amphibianService.removeObservations(dataset, d);
	}

	@GetMapping(path = { "/{datasetUuid:\\w{8}\\-\\w{4}\\-.{22}}" })
	@ApiOperation(value = "Get Datatable info", notes = "Fetched from Amphibian for the selected Genesys Dataset")
	public DatasetTable getDatatableInfo(
			@ApiParam(value = "Dataset UUID", required = true) @PathVariable UUID datasetUuid // Dataset UUID
			) {

		var dataset = datasetService.getDataset(datasetUuid);
		return amphibianService.getAmphibianDataset(dataset);
	}

	@PostMapping(path = { "/datatable/list" })
	@ApiOperation(value = "Get list of Datatable info", notes = "Fetched from Amphibian for the selected Genesys Datasets")
	public List<DatasetTable> getDatatableInfoList(
		@ApiParam(value = "Dataset UUIDs", required = true) @RequestBody List<UUID> datasetUuids // Dataset UUIDs
	) {
		return amphibianService.getAmphibianDatasetsList(datasetUuids);
	}

	@PostMapping(path = { "/{datasetUuid:\\w{8}\\-\\w{4}\\-.{22}}/data" })
	@ApiOperation(value = "Read observations from Datatable", notes = "")
	public DatasetDataResponse filterDatasetData(
			@ApiParam(value = "Dataset UUID", required = true) @PathVariable UUID datasetUuid, // Dataset UUID
			final Pagination page,
			@ApiParam(value = "Data filters and fields") @RequestBody(required = true) DatsetDataRequest dataRequest
			) {

		var dataset = datasetService.getDataset(datasetUuid);
		var pagination = page.toPageRequest(MAX_PAGE_SIZE, DEFAULT_PAGE_SIZE);
		var observations = amphibianService.getObservations(dataset, pagination, dataRequest.fields, dataRequest.filters);
		return new DatasetDataResponse(observations, dataRequest.filters);
	}

	public static final class DatsetDataRequest {
		public List<UUID> fields;
		public TraitFilters filters;
	}

	public static final class DatasetDataResponse {
		@JsonUnwrapped
		@JsonIgnoreProperties({ "pageable" })
		public Page<?> content;
		public TraitFilters filters;

		public DatasetDataResponse() {
		}

		public DatasetDataResponse(Page<?> content, TraitFilters filters) {
			this.content = content;
			this.filters = filters;
		}
	}

	@PostMapping(path = { "/{datasetUuid:\\w{8}\\-\\w{4}\\-.{22}}/histogram" })
	@ApiOperation(value = "getObservationsHistogram", notes = "Get histogram of numerical observations from dataset")
	public List<ObservationHistogram> getObservationHistograms(
		@ApiParam(value = "Table key") @PathVariable(name = "datasetUuid") UUID datasetUuid, // tableKey
		@ApiParam(value = "Specify fields to return") @RequestParam(name = "fields") List<UUID> fields, // selected fields
		@ApiParam(value = "Number of bins") @RequestParam(name = "bins", required = false, defaultValue = "4") Integer binsNumber, // number of bins
		@ApiParam(value = "Data filters", required = true) @RequestBody(required = false) TraitFilters filter // data  filters
	) {
		var dataset = datasetService.getDataset(datasetUuid);
		if (dataset == null) {
			throw new NotFoundElement("No such dataset");
		}
		return amphibianService.getDatasetsHistograms(List.of(dataset), fields, filter, binsNumber);
	}

	@PostMapping(path = { "/{datasetUuid:\\w{8}\\-\\w{4}\\-.{22}}/heatmap" })
	@ApiOperation(value = "getObservationsHeatMap", notes = "Calculate a heat map of observations for two selected categorical descriptors in Dataset")
	public HeatMap getHeatMapData(
		@ApiParam(value = "Table key") @PathVariable(name = "datasetUuid") UUID datasetUuid, // tableKey
		@ApiParam(value = "X category field uuid") @RequestParam(name = "xCategory") UUID xCategoryField, // x category field uuid
		@ApiParam(value = "Y category field uuid") @RequestParam(name = "yCategory") UUID yCategoryField, // y category field uuid
		@ApiParam(value = "Data filters", required = true) @RequestBody(required = false) TraitFilters filter // data  filters
	) throws NoAccessionsForFilterException {
		var dataset = datasetService.getDataset(datasetUuid);
		if (dataset == null) {
			throw new NotFoundElement("No such dataset");
		}
		var xCategoryDescriptor = descriptorService.getDescriptor(xCategoryField);
		var yCategoryDescriptor = descriptorService.getDescriptor(yCategoryField);
		if (xCategoryDescriptor == null || yCategoryDescriptor == null) {
			throw new NotFoundElement("No such categorical descriptor");
		}
		if (!xCategoryDescriptor.getDataType().equals(Descriptor.DataType.CODED) || !yCategoryDescriptor.getDataType().equals(Descriptor.DataType.CODED)) {
			throw new InvalidApiUsageException("Categorical Descriptor must have CODED data type");
		}
		return amphibianService.getDatasetsHeatMap(List.of(dataset), xCategoryDescriptor.getUuid(), yCategoryDescriptor.getUuid(), filter);
	}

	@PostMapping(path = { "/{datasetUuid:\\w{8}\\-\\w{4}\\-.{22}}/chart" })
	@ApiOperation(value = "getObservationsCharts", notes = "Get charts of the number of accessions for the distinct values of the selected field(s)t")
	public List<ObservationChart> getObservationCharts(
		@ApiParam(value = "Table key") @PathVariable(name = "datasetUuid") UUID datasetUuid, // tableKey
		@ApiParam(value = "Specify fields to return") @RequestParam(name = "fields") List<UUID> fields, // selected fields
		@ApiParam(value = "Data filters", required = true) @RequestBody(required = false) TraitFilters filter // data  filters
	) {
		var dataset = datasetService.getDataset(datasetUuid);
		if (dataset == null) {
			throw new NotFoundElement("No such dataset");
		}
		return amphibianService.getDatasetsCharts(List.of(dataset), fields, filter);
	}


	@DownloadEndpoint
	@PostMapping(path = "/{datasetUuid:\\w{8}\\-\\w{4}\\-.{22}}/download")
	public void downloadObservations(
		@ApiParam(value = "Table key") @PathVariable(name = "datasetUuid") UUID datasetUuid, // tableKey
		@ApiParam(value = "Data filters", required = false) @RequestBody(required = false) TraitFilters filter, // data  filters
		final HttpServletResponse response
	) throws IOException {
		var dataset = datasetService.loadDataset(datasetUuid);
		if (dataset == null) {
			throw new NotFoundElement("No such dataset");
		}

		try {
			response.setHeader(HttpHeaders.CACHE_CONTROL, "max-age=86400, s-maxage=86400, public, no-transform");
			response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
			response.addHeader("Content-Disposition", String.format("attachment; filename=\"%s.%s\"", dataset.getTitle(), "xlsx"));
			amphibianService.downloadXlsxDatasetObservations(dataset, filter, response.getOutputStream());
			response.flushBuffer();
		} catch (NoAccessionsForFilterException e) {
			response.reset();
			throw new NotFoundElement("No matching accessions found.");
		}
	}

	@DownloadEndpoint
	@RequestMapping(value = "/{datasetUuid:\\w{8}\\-\\w{4}\\-.{22}}/download", method = RequestMethod.POST, params = { "form" })
	public void downloadObservationsByRequestParams(
		@ApiParam(value = "Table key") @PathVariable(name = "datasetUuid") UUID datasetUuid, // tableKey
		@ApiParam(value = "Data filters", required = false) @RequestParam(value="filter", required = false) String jsonFilter, // data  filters
		final HttpServletResponse response
	) throws IOException {
		var dataset = datasetService.loadDataset(datasetUuid);
		if (dataset == null) {
			throw new NotFoundElement("No such dataset");
		}
		TraitFilters filter = null;
		if (StringUtils.isNotBlank(jsonFilter)) {
			filter = objectMapper.readValue(jsonFilter, TraitFilters.class);
		}

		try {
			response.setHeader(HttpHeaders.CACHE_CONTROL, "max-age=86400, s-maxage=86400, public, no-transform");
			response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
			response.addHeader("Content-Disposition", String.format("attachment; filename=\"%s.%s\"", dataset.getTitle(), "xlsx"));
			amphibianService.downloadXlsxDatasetObservations(dataset, filter, response.getOutputStream());
			response.flushBuffer();
		} catch (NoAccessionsForFilterException e) {
			response.reset();
			throw new NotFoundElement("No matching accessions found.");
		}
	}
}