From 5290d478d2b0421729250b9105955cb6bc45a2e4 Mon Sep 17 00:00:00 2001 From: David Robinson <14000840+khelwood@users.noreply.github.com> Date: Fri, 19 Apr 2024 09:23:36 +0100 Subject: [PATCH] x1191 Offer release files as xlsx as alternative to tsv --- .../sccp/stan/ReleaseFileController.java | 17 +- .../uk/ac/sanger/sccp/utils/BasicUtils.java | 10 ++ .../sccp/utils/tsv/TableFileWriter.java | 15 ++ .../sccp/utils/tsv/TsvFileConverter.java | 13 +- .../ac/sanger/sccp/utils/tsv/TsvWriter.java | 6 +- .../ac/sanger/sccp/utils/tsv/XlsxWriter.java | 96 +++++++++++ .../integrationtest/TestReleaseMutation.java | 2 +- .../sanger/sccp/utils/tsv/TestXlsxWriter.java | 151 ++++++++++++++++++ 8 files changed, 301 insertions(+), 9 deletions(-) create mode 100644 src/main/java/uk/ac/sanger/sccp/utils/tsv/TableFileWriter.java create mode 100644 src/main/java/uk/ac/sanger/sccp/utils/tsv/XlsxWriter.java create mode 100644 src/test/java/uk/ac/sanger/sccp/utils/tsv/TestXlsxWriter.java diff --git a/src/main/java/uk/ac/sanger/sccp/stan/ReleaseFileController.java b/src/main/java/uk/ac/sanger/sccp/stan/ReleaseFileController.java index 737a5e41..b7516076 100644 --- a/src/main/java/uk/ac/sanger/sccp/stan/ReleaseFileController.java +++ b/src/main/java/uk/ac/sanger/sccp/stan/ReleaseFileController.java @@ -11,6 +11,7 @@ import java.util.*; import static uk.ac.sanger.sccp.utils.BasicUtils.nullOrEmpty; +import static uk.ac.sanger.sccp.utils.BasicUtils.repr; /** * Controller for delivering release files (tsv). @@ -28,10 +29,22 @@ public ReleaseFileController(ReleaseFileService releaseFileService) { @RequestMapping(value="/release", method = RequestMethod.GET, produces = "text/tsv") @ResponseBody public TsvFile getReleaseFile(@RequestParam(name="id") List ids, - @RequestParam(name="groups", required = false) List groupNames) { + @RequestParam(name="groups", required=false) List groupNames, + @RequestParam(name="type", required=false) String fileType) { + final String filename = filenameForType(fileType); ReleaseFileContent rfc = releaseFileService.getReleaseFileContent(ids, parseOptions(groupNames)); List> columns = releaseFileService.computeColumns(rfc); - return new TsvFile<>("releases.tsv", rfc.getEntries(), columns); + return new TsvFile<>(filename, rfc.getEntries(), columns); + } + + protected String filenameForType(String fileType) { + if (nullOrEmpty(fileType) || fileType.equalsIgnoreCase("tsv")) { + return "releases.tsv"; + } + if (fileType.equalsIgnoreCase("xlsx")) { + return "releases.xlsx"; + } + throw new IllegalArgumentException("Unsupported file type: " + repr(fileType)); } /** diff --git a/src/main/java/uk/ac/sanger/sccp/utils/BasicUtils.java b/src/main/java/uk/ac/sanger/sccp/utils/BasicUtils.java index 382f7ff8..2b28c5fd 100644 --- a/src/main/java/uk/ac/sanger/sccp/utils/BasicUtils.java +++ b/src/main/java/uk/ac/sanger/sccp/utils/BasicUtils.java @@ -401,6 +401,16 @@ public static boolean startsWithIgnoreCase(String string, String sub) { return StringUtils.startsWithIgnoreCase(string, sub); } + /** + * Does the given string end with the given substring, ignoring its case? + * @param string the containing string + * @param sub the substring + * @return true if {@code string} ends with {@code sub}, ignoring case; false otherwise + */ + public static boolean endsWithIgnoreCase(String string, String sub) { + return StringUtils.endsWithIgnoreCase(string, sub); + } + /** * Escape the sql-LIKE symbols in a string * (percent, which is any sequence of characters, underscore, which is any single character, diff --git a/src/main/java/uk/ac/sanger/sccp/utils/tsv/TableFileWriter.java b/src/main/java/uk/ac/sanger/sccp/utils/tsv/TableFileWriter.java new file mode 100644 index 00000000..5b5dc22d --- /dev/null +++ b/src/main/java/uk/ac/sanger/sccp/utils/tsv/TableFileWriter.java @@ -0,0 +1,15 @@ +package uk.ac.sanger.sccp.utils.tsv; + +import java.io.Closeable; +import java.io.IOException; + +public interface TableFileWriter extends Closeable { + /** + * Writes the table data to this writer's output stream + * @param data data to write + * @param type representing columns in the table + * @param type representing values in the table + * @exception IOException if a problem happened during writing + */ + void write(TsvData data) throws IOException; +} diff --git a/src/main/java/uk/ac/sanger/sccp/utils/tsv/TsvFileConverter.java b/src/main/java/uk/ac/sanger/sccp/utils/tsv/TsvFileConverter.java index 01326669..ffd67b75 100644 --- a/src/main/java/uk/ac/sanger/sccp/utils/tsv/TsvFileConverter.java +++ b/src/main/java/uk/ac/sanger/sccp/utils/tsv/TsvFileConverter.java @@ -3,17 +3,20 @@ import org.jetbrains.annotations.NotNull; import org.springframework.http.*; import org.springframework.http.converter.*; +import uk.ac.sanger.sccp.utils.BasicUtils; import java.io.IOException; +import java.io.OutputStream; /** * @author dr6 */ public class TsvFileConverter extends AbstractHttpMessageConverter> { - public static final MediaType MEDIA_TYPE = new MediaType("text", "tsv"); + public static final MediaType TSV_MEDIA_TYPE = new MediaType("text", "tsv"), + XLSX_MEDIA_TYPE = new MediaType("application", "vnd.openxmlformats-officedocument.spreadsheetml.sheet"); public TsvFileConverter() { - super(MEDIA_TYPE); + super(TSV_MEDIA_TYPE, XLSX_MEDIA_TYPE); } @Override @@ -30,9 +33,11 @@ protected TsvFile readInternal(@NotNull Class> cls, @Not @Override protected void writeInternal(TsvFile rel, HttpOutputMessage output) throws IOException, HttpMessageNotWritableException { - output.getHeaders().setContentType(MEDIA_TYPE); + boolean useTsv = BasicUtils.endsWithIgnoreCase(rel.getFilename(), "tsv"); + output.getHeaders().setContentType(useTsv ? TSV_MEDIA_TYPE : XLSX_MEDIA_TYPE); output.getHeaders().set("Content-Disposition", "attachment; filename=\"" + rel.getFilename() + "\""); - try (TsvWriter writer = new TsvWriter(output.getBody())) { + OutputStream out = output.getBody(); + try (TableFileWriter writer = useTsv ? new TsvWriter(out) : new XlsxWriter(out)) { writer.write(rel); } } diff --git a/src/main/java/uk/ac/sanger/sccp/utils/tsv/TsvWriter.java b/src/main/java/uk/ac/sanger/sccp/utils/tsv/TsvWriter.java index c229d284..4b54f8d8 100644 --- a/src/main/java/uk/ac/sanger/sccp/utils/tsv/TsvWriter.java +++ b/src/main/java/uk/ac/sanger/sccp/utils/tsv/TsvWriter.java @@ -1,6 +1,7 @@ package uk.ac.sanger.sccp.utils.tsv; -import java.io.*; +import java.io.IOException; +import java.io.OutputStream; import java.util.Iterator; import java.util.List; @@ -12,7 +13,7 @@ * (by default {@code "} is escaped to {@code ""}). * @author dr6 */ -public class TsvWriter implements Closeable { +public class TsvWriter implements TableFileWriter { private final char separator; private final char quote; private final char quoteEscape; @@ -32,6 +33,7 @@ public TsvWriter(OutputStream out, char separator, char quote, char quoteEscape, this.newline = newline; } + @Override public void write(TsvData data) throws IOException { final List columns = data.getColumns(); writeLn(columns.stream().map(Object::toString).iterator()); diff --git a/src/main/java/uk/ac/sanger/sccp/utils/tsv/XlsxWriter.java b/src/main/java/uk/ac/sanger/sccp/utils/tsv/XlsxWriter.java new file mode 100644 index 00000000..04f06d01 --- /dev/null +++ b/src/main/java/uk/ac/sanger/sccp/utils/tsv/XlsxWriter.java @@ -0,0 +1,96 @@ +package uk.ac.sanger.sccp.utils.tsv; + +import org.apache.poi.ss.usermodel.*; + +import java.io.IOException; +import java.io.OutputStream; +import java.util.Iterator; +import java.util.List; +import java.util.stream.Stream; + +/** + * Utility to write data as an xlsx file to an OutputStream. + * @author dr6 + */ +public class XlsxWriter implements TableFileWriter { + private final OutputStream out; + + public XlsxWriter(OutputStream out) { + this.out = out; + } + + @Override + public void write(TsvData data) throws IOException { + final List columns = data.getColumns(); + final int numRows = data.getNumRows(); + try (Workbook wb = createWorkbook()) { + Sheet sheet = wb.createSheet(); + createRow(sheet, 0, columns.stream().map(Object::toString), createHeadingsStyle(wb)); + for (int fileRow = 1; fileRow <= numRows; fileRow++) { + final int dataRow = fileRow - 1; + createRow(sheet, fileRow, columns.stream() + .map(column -> valueToString(data.getValue(dataRow, column))), + null); + } + wb.write(out); + } + } + + /** Creates a new Xssf workbook from the POI factory */ + public Workbook createWorkbook() throws IOException { + return WorkbookFactory.create(true); + } + + /** Creates a style suitable for headings in the given workbook */ + protected CellStyle createHeadingsStyle(Workbook wb) { + CellStyle style = wb.createCellStyle(); + Font headingsFont = wb.createFont(); + headingsFont.setBold(true); + style.setFont(headingsFont); + return style; + } + + /** + * Adds a row to the given sheet + * @param sheet the sheet to modify + * @param rowIndex the index of the row to add + * @param values the values to put in the row + * @param style the style to use for the row and its cells (may be null) + * @return the new row + */ + protected Row createRow(Sheet sheet, int rowIndex, Stream values, CellStyle style) { + Row row = sheet.createRow(rowIndex); + if (style!=null) { + row.setRowStyle(style); + } + Iterator iter = values.iterator(); + int columnIndex = 0; + while (iter.hasNext()) { + String value = iter.next(); + Cell cell = row.createCell(columnIndex, CellType.STRING); + if (style!=null) { + cell.setCellStyle(style); + } + if (value != null) { + cell.setCellValue(value); + } + ++columnIndex; + } + return row; + } + + /** + * Null-ignoring toString method + * @param value object to convert + * @return null if the object is null, otherwise the result of calling {@code toString()} on it + */ + protected static String valueToString(Object value) { + return (value==null ? null : value.toString()); + } + + /** Closes the underlying output stream */ + @Override + public void close() throws IOException { + out.close(); + } +} diff --git a/src/test/java/uk/ac/sanger/sccp/stan/integrationtest/TestReleaseMutation.java b/src/test/java/uk/ac/sanger/sccp/stan/integrationtest/TestReleaseMutation.java index dbe61827..2196b77f 100644 --- a/src/test/java/uk/ac/sanger/sccp/stan/integrationtest/TestReleaseMutation.java +++ b/src/test/java/uk/ac/sanger/sccp/stan/integrationtest/TestReleaseMutation.java @@ -206,7 +206,7 @@ private void recordStain(Labware lw, StainType st, String bondBarcode, Integer r stainTypeRepo.saveOperationStainTypes(op.getId(), List.of(st)); } Slot slot = lw.getFirstSlot(); - Sample sample = slot.getSamples().get(0); + Sample sample = slot.getSamples().getFirst(); actionRepo.save(new Action(null, op.getId(), slot, slot, sample, sample)); lwNoteRepo.save(new LabwareNote(null, lw.getId(), op.getId(), "Bond barcode", bondBarcode)); if (rnaPlex!=null) { diff --git a/src/test/java/uk/ac/sanger/sccp/utils/tsv/TestXlsxWriter.java b/src/test/java/uk/ac/sanger/sccp/utils/tsv/TestXlsxWriter.java new file mode 100644 index 00000000..c6dbf2eb --- /dev/null +++ b/src/test/java/uk/ac/sanger/sccp/utils/tsv/TestXlsxWriter.java @@ -0,0 +1,151 @@ +package uk.ac.sanger.sccp.utils.tsv; + +import org.apache.poi.ss.usermodel.*; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.ArgumentCaptor; + +import java.io.IOException; +import java.io.OutputStream; +import java.util.*; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import static uk.ac.sanger.sccp.stan.Matchers.streamCaptor; + +/** + * Test {@link XlsxWriter} + */ +public class TestXlsxWriter { + enum TestColumn { + Alpha, Beta, Gamma + } + + private OutputStream out; + private XlsxWriter writer; + + @BeforeEach + void setup() { + out = mock(OutputStream.class); + writer = spy(new XlsxWriter(out)); + } + + @SuppressWarnings("unchecked") + TsvData mockData() { + TsvData data = mock(TsvData.class); + doReturn(Arrays.asList(TestColumn.values())).when(data).getColumns(); + when(data.getNumRows()).thenReturn(2); + when(data.getValue(anyInt(), any())).then(invocation -> { + int rowIndex = invocation.getArgument(0); + TestColumn column = invocation.getArgument(1); + if (column==TestColumn.Gamma) { + return null; + } + return column.name() + rowIndex; + }); + return data; + } + + @Test + void testWrite() throws IOException { + TsvData data = mockData(); + Workbook wb = mock(Workbook.class); + doReturn(wb).when(writer).createWorkbook(); + Sheet sheet = mock(Sheet.class); + doReturn(sheet).when(wb).createSheet(); + CellStyle style = mock(CellStyle.class); + doReturn(style).when(writer).createHeadingsStyle(wb); + doReturn(null).when(writer).createRow(any(), anyInt(), any(), any()); + + writer.write(data); + + verify(writer).createWorkbook(); + verify(wb).createSheet(); + ArgumentCaptor> headingsCaptor = streamCaptor(); + verify(writer).createRow(same(sheet), eq(0), headingsCaptor.capture(), same(style)); + assertThat(headingsCaptor.getValue()).containsExactly("Alpha", "Beta", "Gamma"); + + for (int i = 1; i <= 2; ++i) { + ArgumentCaptor> rowCaptor = streamCaptor(); + verify(writer).createRow(same(sheet), eq(i), rowCaptor.capture(), isNull()); + if (i==1) { + assertThat(rowCaptor.getValue()).containsExactly("Alpha0", "Beta0", null); + } else { + assertThat(rowCaptor.getValue()).containsExactly("Alpha1", "Beta1", null); + } + } + + verify(wb).write(out); + } + + @Test + void testCreateWorkbook() throws IOException { + assertNotNull(writer.createWorkbook()); + } + + @Test + void testCreateHeadingsStyle() { + Workbook wb = mock(Workbook.class); + CellStyle style = mock(CellStyle.class); + Font font = mock(Font.class); + when(wb.createCellStyle()).thenReturn(style); + when(wb.createFont()).thenReturn(font); + + assertSame(style, writer.createHeadingsStyle(wb)); + verify(wb).createCellStyle(); + verify(wb).createFont(); + verify(font).setBold(true); + verify(style).setFont(font); + } + + @ParameterizedTest + @ValueSource(booleans={false,true}) + void testCreateRow(boolean hasStyle) { + Sheet sheet = mock(Sheet.class); + final int rowIndex = 5; + Stream values = Stream.of("Alpha", "Beta", null); + CellStyle style = (hasStyle ? mock(CellStyle.class) : null); + Row row = mock(Row.class); + when(sheet.createRow(anyInt())).thenReturn(row); + final List cells = new ArrayList<>(2); + when(row.createCell(anyInt(), any())).then(invocation -> { + Cell cell = mock(Cell.class); + cells.add(cell); + return cell; + }); + + assertSame(row, writer.createRow(sheet, rowIndex, values, style)); + verify(sheet).createRow(rowIndex); + if (style==null) { + verify(row, never()).setRowStyle(any()); + } else { + verify(row).setRowStyle(style); + } + assertThat(cells).hasSize(3); + verify(row).createCell(0, CellType.STRING); + verify(row).createCell(1, CellType.STRING); + verify(row).createCell(2, CellType.STRING); + verifyNoMoreInteractions(row); + cells.forEach(style==null ? (cell -> verify(cell, never()).setCellStyle(any())) : (cell -> verify(cell).setCellStyle(style))); + verify(cells.get(0)).setCellValue("Alpha"); + verify(cells.get(1)).setCellValue("Beta"); + verify(cells.get(2), never()).setCellValue(any(String.class)); + } + + @Test + void testValueToString() { + assertEquals("1", XlsxWriter.valueToString(1)); + assertEquals("Alpha", XlsxWriter.valueToString(TestColumn.Alpha)); + assertNull(XlsxWriter.valueToString(null)); + } + + @Test + void testClose() throws IOException { + writer.close(); + verify(out).close(); + } +}