Generación dinámica de estructuras de carpetas jerárquicas y empaquetado ZIP en Spring Boot

Se requiere implementar una funcionalidad que genere localmente una estructura de directorios anidada —basada en una representación arbórea previamente construida—, descargue archivos adjuntos asociados a cada nodo y finalmente comprima todo el contenido en un archivo ZIP listo para descarga.

La solución se basa en tres etapas clave: (1) obtención recursiva de la estructura jerárquica desde una API o servicio de árbol; (2) creación física de carpetas y descarga de recursos vinculados a cada nodo hoja; y (3) compresión del directorio resultante usando java.util.zip, sin dependencias externas.

Controlador REST para activación del flujo

@ApiOperation("Exporta resultados organizados como árbol en paquete ZIP")
@PostMapping("/export/structured-zip")
public ResponseEntity<ResponseEntity.BodyBuilder> generateAndDownloadZip(
    @RequestParam String projectId,
    @RequestParam String departId) {

    List<TreeNode> treeData = treeService.fetchFullTree(projectId, departId);
    String zipPath = zipGenerator.generateZipFromTree(treeData, "resultados-proyecto");

    return ResponseEntity.ok()
        .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + zipPath.split("/")[zipPath.split("/").length - 1] + "\"")
        .body(EntityResponse.of(zipPath));
}

Construcción recursiva de la jerarquía de carpetas y descarga de adjuntos

El método buildDirectoryStructure recorre cada nodo, crea su carpeta correspondiente y, si es un nodo terminal (leaf), busca y descarga los archivos relacionados mediante llamadas HTTP sincrónicas:

private void buildDirectoryStructure(List<TreeNode> nodes, Path parentDir) throws IOException {
    for (TreeNode node : nodes) {
        Path nodeDir = Files.createDirectories(parentDir.resolve(node.getLabel()));
        
        if (node.isLeaf()) {
            downloadAttachmentsForNode(node.getId(), nodeDir);
        } else {
            buildDirectoryStructure(node.getChildren(), nodeDir);
        }
    }
}

private void downloadAttachmentsForNode(String nodeId, Path targetDir) throws IOException {
    List<AttachmentMetadata> attachments = attachmentService.findByNodeId(nodeId);
    
    RestTemplate client = new RestTemplate();
    attachments.forEach(attachment -> {
        try {
            byte[] content = client.getForObject(attachment.getDownloadUrl(), byte[].class);
            String safeName = sanitizeFilename(attachment.getOriginalName());
            Files.write(targetDir.resolve(safeName), content);
        } catch (Exception e) {
            log.warn("Fallo al descargar adjunto {}: {}", attachment.getId(), e.getMessage());
        }
    });
}

Compresión ZIP con soporte para estructura de directorios

La clase ZipArchiver utiliza ZipOutputStream para empaqueatr de forma recursiva, preservando la ruta relativa de cada entrada:

public Path createZipArchive(Path sourceDir, String archiveName) throws IOException {
    Path zipFile = Files.createTempFile(archiveName, ".zip");
    
    try (ZipOutputStream zos = new ZipOutputStream(Files.newOutputStream(zipFile))) {
        compressRecursively(sourceDir, zos, "");
    }
    
    return zipFile;
}

private void compressRecursively(Path file, ZipOutputStream zos, String base) throws IOException {
    String entryName = base.isEmpty() ? file.getFileName().toString() : base + "/" + file.getFileName();
    
    if (Files.isRegularFile(file)) {
        zos.putNextEntry(new ZipEntry(entryName));
        Files.copy(file, zos);
        zos.closeEntry();
    } else if (Files.isDirectory(file)) {
        zos.putNextEntry(new ZipEntry(entryName + "/"));
        zos.closeEntry();
        
        try (Stream<Path> children = Files.list(file)) {
            children.forEach(child -> {
                try {
                    compressRecursively(child, zos, entryName);
                } catch (IOException e) {
                    throw new UncheckedIOException(e);
                }
            });
        }
    }
}

Limpieza post-compresión opcional

Para evitar acumulación de archivos temporales, se incluye un limpiador seguro que elimina solo el directorio generado (no recursivo sobre rutas arbitrarias):

private void cleanupTemporaryDirectory(Path dir) {
    try {
        if (Files.exists(dir) && Files.isDirectory(dir)) {
            Files.walk(dir)
                .sorted(Comparator.reverseOrder())
                .forEach(path -> {
                    try { Files.delete(path); } 
                    catch (IOException ignored) {}
                });
        }
    } catch (IOException ignored) {}
}

Este enfoque garantiza reproducibilidad, control explícito sobre permisos de escritura y compatibilidad nativa con sistemas operativos heterogéneos, evitando librerías de terceros como Apache Commons Compress o TrueZIP.

Etiquetas: spring-boot java-zip rest-api file-system recursive-directory

Publicado el 10-25 22:02