Original article: How to Generate an Excel Report in a Spring Boot REST API with Apache POI and Kotlin

En este artículo, me gustaría mostrarte cómo generar reportes Excel en los formatos .xls y .xlsx (también conocidos como Open XML) en una API REST de Spring Boot con Apache POI y Kotlin.

Después del término de esta guía, tendrás un entendimiento fundamental sobre cómo crear de forma personalizada, formatos de celdas, estilos y fuentes. Al final, te mostraré cómo crear endpoints para que así puedas descargar los archivos generados fácilmente.

Para visualizar mejor lo que aprenderemos, revisa la vista previa del resultado final.

result_file_preview

Paso 1: Añadir las dependencias necesarias.

Como primer paso, vamos a crear un proyecto Spring Boot (Recomiendo altamente, utilizar la página Spring Initializr) e importar las siguientes dependencias.

implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.apache.poi:poi:4.1.2")
implementation("org.apache.poi:poi-ooxml:4.1.2")

Déjame explicar el propósito de cada librería.

  • El Spring Boot Starter Web es necesario para crear la API REST en nuestra aplicación.
  • Apache POI es una librería compleja de Java para trabajar con archivos Excel. Si quisiéramos trabajar solo con el formato .xls, entonces la dependencia poi sería suficiente. En nuestro caso, queremos añadir soporte para el formato .xlsx, por lo que el componente poi-ooxml, también es necesario.

Paso 2: Crear los modelos

Como siguiente paso, vamos a crear una clase tipo enum llamada CustomCellStyle con 4 constantes:

enum class CustomCellStyle {
    GREY_CENTERED_BOLD_ARIAL_WITH_BORDER,
    RIGHT_ALIGNED,
    RED_BOLD_ARIAL_WITH_BORDER,
    RIGHT_ALIGNED_DATE_FORMAT
}

Si bien el propósito de esta clase enum pueda verse un tanto enigmática al momento, se volverá clara en las siguientes secciones.

Paso 3: Preparar los estilos de celdas

La librería Apache POI viene con la interfaz CellStyle, la cual podemos utilizar para definir estilos y formatos personalizados entre filas, columnas y celdas.

Vamos a crear un componente StylesGenerator, el cual será responsable de preparar un mapa que contenga nuestros estilos personalizados.

@Component
class StylesGenerator {

    fun prepareStyles(wb: Workbook): Map<CustomCellStyle, CellStyle> {
        val boldArial = createBoldArialFont(wb)
        val redBoldArial = createRedBoldArialFont(wb)

        val rightAlignedStyle = createRightAlignedStyle(wb)
        val greyCenteredBoldArialWithBorderStyle =
            createGreyCenteredBoldArialWithBorderStyle(wb, boldArial)
        val redBoldArialWithBorderStyle =
            createRedBoldArialWithBorderStyle(wb, redBoldArial)
        val rightAlignedDateFormatStyle =
            createRightAlignedDateFormatStyle(wb)

        return mapOf(
            CustomCellStyle.RIGHT_ALIGNED to rightAlignedStyle,
            CustomCellStyle.GREY_CENTERED_BOLD_ARIAL_WITH_BORDER to greyCenteredBoldArialWithBorderStyle,
            CustomCellStyle.RED_BOLD_ARIAL_WITH_BORDER to redBoldArialWithBorderStyle,
            CustomCellStyle.RIGHT_ALIGNED_DATE_FORMAT to rightAlignedDateFormatStyle
        )
    }
}

Como puedes ver, con este acercamiento, creamos cada estilo una vez y lo ponemos dentro de un mapa para que así lo podamos referenciar después.

Hay bastantes técnicas de diseño que podríamos utilizar aquí, pero creo que utilizando un mapa y constantes enum es una de las mejores maneras para mantener el código más limpio y más fácil de modificar.

Dicho esto, añadamos algunas funciones faltantes dentro de la clase generadora. Comencemos primero con las fuentes personalizadas:

private fun createBoldArialFont(wb: Workbook): Font {
    val font = wb.createFont()
    font.fontName = "Arial"
    font.bold = true
    return font
}

La función createBoldArialFont crea una nueva instancia en negrita de la fuente Arial, la cual la usaremos más tarde.

Similarmente, vamos a implementar una función createRedBoldArialFont y establecer el color de la fuente a rojo:

private fun createRedBoldArialFont(wb: Workbook): Font {
    val font = wb.createFont()
    font.fontName = "Arial"
    font.bold = true
    font.color = IndexedColors.RED.index
    return font
}

Después de eso, podemos añadir otras funciones responsables de crear instancias CellStyle individuales.

private fun createRightAlignedStyle(wb: Workbook): CellStyle {
    val style: CellStyle = wb.createCellStyle()
    style.alignment = HorizontalAlignment.RIGHT
    return style
}

private fun createBorderedStyle(wb: Workbook): CellStyle {
    val thin = BorderStyle.THIN
    val black = IndexedColors.BLACK.getIndex()
    val style = wb.createCellStyle()
    style.borderRight = thin
    style.rightBorderColor = black
    style.borderBottom = thin
    style.bottomBorderColor = black
    style.borderLeft = thin
    style.leftBorderColor = black
    style.borderTop = thin
    style.topBorderColor = black
    return style
}

private fun createGreyCenteredBoldArialWithBorderStyle(wb: Workbook, boldArial: Font): CellStyle {
    val style = createBorderedStyle(wb)
    style.alignment = HorizontalAlignment.CENTER
    style.setFont(boldArial)
    style.fillForegroundColor = IndexedColors.GREY_25_PERCENT.getIndex();
    style.fillPattern = FillPatternType.SOLID_FOREGROUND;
    return style
}

private fun createRedBoldArialWithBorderStyle(wb: Workbook, redBoldArial: Font): CellStyle {
    val style = createBorderedStyle(wb)
    style.setFont(redBoldArial)
    return style
}

private fun createRightAlignedDateFormatStyle(wb: Workbook): CellStyle {
    val style = wb.createCellStyle()
    style.alignment = HorizontalAlignment.RIGHT
    style.dataFormat = 14
    return style
}

Por favor hay que tener en mente que los ejemplos de arriba son solo una parte pequeña de las posibilidades de CellStyle (estilo de celda). Si te gustaría ver una lista completa, por favor refiérate a la documentación aquí (artículo en inglés).

Paso 4: Crear la clase ReportService

Como siguiente paso vamos a implementar la clase ReportService responsable de crear los archivos .xlsx y .xls y retornarlos como instancias ByteArray:

@Service
class ReportService(
    private val stylesGenerator: StylesGenerator
) {
    fun generateXlsxReport(): ByteArray {
        val wb = XSSFWorkbook()

        return generateReport(wb)
    }

    fun generateXlsReport(): ByteArray {
        val wb = HSSFWorkbook()

        return generateReport(wb)
    }
 }   

Como puedes ver, la única diferencia entre la generación de estos dos formatos es el tipo de implementación del Workbook (libro de trabajo) que hemos utilizado. Para el formato .xlsx vamos a utilizar la clase XSSFWorkbook, y para el .xls utilizaremos HSSFWorkbook.

Vamos a añadir el resto del código a ReportService:

private fun generateReport(wb: Workbook): ByteArray {
    val styles = stylesGenerator.prepareStyles(wb)
    val sheet: Sheet = wb.createSheet("Example sheet name")

    setColumnsWidth(sheet)

    createHeaderRow(sheet, styles)
    createStringsRow(sheet, styles)
    createDoublesRow(sheet, styles)
    createDatesRow(sheet, styles)

    val out = ByteArrayOutputStream()
    wb.write(out)

    out.close()
    wb.close()

    return out.toByteArray()
}

private fun setColumnsWidth(sheet: Sheet) {
    sheet.setColumnWidth(0, 256 * 20)

    for (columnIndex in 1 until 5) {
        sheet.setColumnWidth(columnIndex, 256 * 15)
    }
}

private fun createHeaderRow(sheet: Sheet, styles: Map<CustomCellStyle, CellStyle>) {
    val row = sheet.createRow(0)

    for (columnNumber in 1 until 5) {
        val cell = row.createCell(columnNumber)

        cell.setCellValue("Column $columnNumber")
        cell.cellStyle = styles[CustomCellStyle.GREY_CENTERED_BOLD_ARIAL_WITH_BORDER]
    }
}

private fun createRowLabelCell(row: Row, styles: Map<CustomCellStyle, CellStyle>, label: String) {
    val rowLabel = row.createCell(0)
    rowLabel.setCellValue(label)
    rowLabel.cellStyle = styles[CustomCellStyle.RED_BOLD_ARIAL_WITH_BORDER]
}

private fun createStringsRow(sheet: Sheet, styles: Map<CustomCellStyle, CellStyle>) {
    val row = sheet.createRow(1)
    createRowLabelCell(row, styles, "Strings row")

    for (columnNumber in 1 until 5) {
        val cell = row.createCell(columnNumber)

        cell.setCellValue("String $columnNumber")
        cell.cellStyle = styles[CustomCellStyle.RIGHT_ALIGNED]
    }
}

private fun createDoublesRow(sheet: Sheet, styles: Map<CustomCellStyle, CellStyle>) {
    val row = sheet.createRow(2)
    createRowLabelCell(row, styles, "Doubles row")

    for (columnNumber in 1 until 5) {
        val cell = row.createCell(columnNumber)

        cell.setCellValue(BigDecimal("${columnNumber}.99").toDouble())
        cell.cellStyle = styles[CustomCellStyle.RIGHT_ALIGNED]
    }
}

private fun createDatesRow(sheet: Sheet, styles: Map<CustomCellStyle, CellStyle>) {
    val row = sheet.createRow(3)
    createRowLabelCell(row, styles, "Dates row")

    for (columnNumber in 1 until 5) {
        val cell = row.createCell(columnNumber)

        cell.setCellValue((LocalDate.now()))
        cell.cellStyle = styles[CustomCellStyle.RIGHT_ALIGNED_DATE_FORMAT]
    }
}

Como puedes ver, la primera cosa que la función generateReport hace, es que estiliza la inicialización. Pasamos la instancia Workbook al StylesGenerator (generador de estilos) y en retorno, obtenemos un mapa, el cual utilizaremos más tarde para obtener CellStyles (estilos de celda) apropiados.

Después de esto, crea una nueva hoja dentro de nuestro libro de trabajo y le entrega un nombre para este (el libro).

Después, invoca las funciones responsables de configurar el ancho de las columnas y operar nuestra hoja fila por fila.

Finalmente, escribe nuestro libro de trabajo a un ByteArrayOutputStream.

Tomemos un minuto y analicemos que hace exactamente cada función:

private fun setColumnsWidth(sheet: Sheet) {
    sheet.setColumnWidth(0, 256 * 20)

    for (columnIndex in 1 until 5) {
        sheet.setColumnWidth(columnIndex, 256 * 15)
    }
}

Como el nombre sugiere, setColumnsWidth (establecerAnchoColumnas) es responsable de configurar los anchos de las columnas en nuestra hoja. El primer parámetro pasado a  setColumnsWidth indica el columnIndex (indiceColumna), y el segundo establece el ancho (en unidades de 1/256 de ancho de carácter).

private fun createRowLabelCell(row: Row, styles: Map<CustomCellStyle, CellStyle>, label: String) {
    val rowLabel = row.createCell(0)
    rowLabel.setCellValue(label)
    rowLabel.cellStyle = styles[CustomCellStyle.RED_BOLD_ARIAL_WITH_BORDER]
}

La función createRowLabelCell (crearEtiquetaFilaCelda) es responsable de añadir una celda en la primera columna de la fila entregada, paralelamente configura su valor a la etiqueta especificada y configura su estilo. He decidido añadir esta función para reducir un poco la redundacia del código.

Todas las funciones de abajo son bastante similares. Su propósito es crear una nueva fila, invocar la función createRowLabelCell (a excepción de createHeaderRow) y añadir cinco columnas con información a nuestra hoja.

private fun createHeaderRow(sheet: Sheet, styles: Map<CustomCellStyle, CellStyle>) {
    val row = sheet.createRow(0)

    for (columnNumber in 1 until 5) {
        val cell = row.createCell(columnNumber)

        cell.setCellValue("Column $columnNumber")
        cell.cellStyle = styles[CustomCellStyle.GREY_CENTERED_BOLD_ARIAL_WITH_BORDER]
    }
}
private fun createStringsRow(sheet: Sheet, styles: Map<CustomCellStyle, CellStyle>) {
    val row = sheet.createRow(1)
    createRowLabelCell(row, styles, "Strings row")

    for (columnNumber in 1 until 5) {
        val cell = row.createCell(columnNumber)

        cell.setCellValue("String $columnNumber")
        cell.cellStyle = styles[CustomCellStyle.RIGHT_ALIGNED]
    }
}
private fun createDoublesRow(sheet: Sheet, styles: Map<CustomCellStyle, CellStyle>) {
    val row = sheet.createRow(2)
    createRowLabelCell(row, styles, "Doubles row")

    for (columnNumber in 1 until 5) {
        val cell = row.createCell(columnNumber)

        cell.setCellValue(BigDecimal("${columnNumber}.99").toDouble())
        cell.cellStyle = styles[CustomCellStyle.RIGHT_ALIGNED]
    }
}
private fun createDatesRow(sheet: Sheet, styles: Map<CustomCellStyle, CellStyle>) {
    val row = sheet.createRow(3)
    createRowLabelCell(row, styles, "Dates row")

    for (columnNumber in 1 until 5) {
        val cell = row.createCell(columnNumber)

        cell.setCellValue((LocalDate.now()))
        cell.cellStyle = styles[CustomCellStyle.RIGHT_ALIGNED_DATE_FORMAT]
    }
}

Paso 5: Implementar el ReportController REST

Como último paso, vamos a implementar una clase llamada ReportController. Será responsable de manejar las peticiones POST entrantes a nuestros dos endpoints REST:

  • /api/report/xlsx - crear un reporte en formato .xlsx
  • /api/report/xls - lo mismo de arriba, pero en formato .xls
@RestController
@RequestMapping("/api/report")
class ReportController(
    private val reportService: ReportService
) {

    @PostMapping("/xlsx")
    fun generateXlsxReport(): ResponseEntity<ByteArray> {
        val report = reportService.generateXlsxReport()

        return createResponseEntity(report, "report.xlsx")
    }

    @PostMapping("/xls")
    fun generateXlsReport(): ResponseEntity<ByteArray> {
        val report = reportService.generateXlsReport()

        return createResponseEntity(report, "report.xls")
    }

    private fun createResponseEntity(
        report: ByteArray,
        fileName: String
    ): ResponseEntity<ByteArray> =
        ResponseEntity.ok()
            .contentType(MediaType.APPLICATION_OCTET_STREAM)
            .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"$fileName\"")
            .body(report)

}

La parte más interesante del código de arriba es la función createResponseEntity (crearEntidadRespuesta), la cual establece el ByteArray (Arreglo de Bytes) entregado con su reporte generado como un cuerpo de respuesta.

Adicionalmente, establecemos el encabezado Content-Type de la respuesta como application/octet-stream, y la disposición de contenido (Content-Disposition) como el archivo adjunto attachment; filename = <FILENAME>

Paso 6: Testear tódo con Postman

Finalmente, podemos correr y probar nuetra aplicación Spring Boot, por ejemplo con el commando gradlew:

./gradlew bootRun

Por defecto, la aplicación Spring Boot estará corriendo en el puerto 8080, así que vamos a abrir Postman (o cualquier otra herramienta), especificar la petición POST a localhost:8080/api/report/xls  y presionar el botón Send and Download (enviar y descargar):

Si tódo estuvo bien , tendríamos que ver una ventana que nos permita guardar el archivo .xls.

Similarmente, vamos a probar el segundo endpoint:

POST_xlsx

Esta vez, la extensión del archivo tendría que ser .xlsx.

Resumen

¡Eso es todo por este artículo! Hemos cubierto el proceso de generar reportes Excel en una API REST de Spring Boot con Apache POI y Kotlin.

Si lo disfrutaste y te gustaría aprender otros temas a través de artículo similares, porfavor visita my blog Codersee (blog en inglés).

Y la última cosa: para el código fuente, o un proyecto completamente funcional, porfavor consulte este repositorio GitHub (en inglés).