core-reporting
Qué problema resuelve
Generar un PDF y después subirlo a S3 son dos pasos que siempre van juntos y que cada proyecto vuelve a cablear: generar bytes, elegir un bucket, armar una key, crear la fila de metadata, verificar permisos.
core-reporting los junta en una sola llamada. Definís el layout del reporte como una implementación de
ReportTemplate (código Java versionado, no un archivo de markup ni una fila de base), y
ReportService.generateAndStore(...) lo renderiza a PDF y lo sube vía core-storage con
las mismas verificaciones de permiso que cualquier otra subida.
La decisión de fondo: la estructura de un reporte (tablas, layout de página) se parece más a código que a algo que edite alguien no-desarrollador, así que se versiona con el código.
Cómo se agrega
dependencies {
implementation("dev.echotechs.core:core-reporting:0.1.0-SNAPSHOT")
}
Arrastra core-storage (y por lo tanto core-auth, core-authz, core-persistence) y OpenPDF 3.0.5.
No tiene tablas propias, pero si vas a usar ReportService necesitás el changelog de core-storage,
porque los archivos se registran en core.file_metadata:
<include file="db/changelog/core-storage/changelog-master.xml"/>
:::warning OpenPDF 3.x renombró su paquete
Es org.openpdf.text.*, no el legacy com.lowagie.text.*. La forma clásica de la API
(Document/PdfWriter/Paragraph) no cambió, sólo el paquete.
:::
No hay propiedades de configuración propias. El módulo hereda las de core-storage.
API pública
ReportTemplate
Un layout de reporte, registrado como bean y buscado por reportType().
public interface ReportTemplate {
String reportType();
/** Agregá los Elements que necesite el reporte — el document ya viene abierto. */
void render(Document document, Map<String, Object> data) throws DocumentException;
}
ReportGenerator
public class ReportGenerator {
/** @throws IllegalArgumentException si no hay ReportTemplate registrado para reportType. */
public byte[] generate(String reportType, Map<String, Object> data);
}
Devuelve los bytes del PDF. No toca storage — usalo directo si querés el PDF sin subirlo (por ejemplo para devolverlo en la respuesta HTTP).
Se registra siempre que core-reporting esté en el classpath, incluso con cero ReportTemplate y sin
core-storage configurado.
ReportService
public class ReportService {
/**
* resourceType/resourceId identifican el recurso al que pertenece el archivo generado — misma
* verificación can_edit que cualquier otra subida, porque pasa por FileStorageService.uploadDirect.
*/
public FileDescriptor generateAndStore(String reportType, Map<String, Object> data,
String resourceType, UUID resourceId, String fileName);
}
El content type queda fijo en application/pdf. El FileDescriptor devuelto ya viene en estado
CONFIRMED (uploadDirect no tiene paso de confirmación).
ReportService sólo se registra si existe un bean FileStorageService, o sea si configuraste
echotechs.storage.s3.bucket. Sin eso, ReportGenerator sigue disponible por su cuenta.
ReportGenerationException
public class ReportGenerationException extends RuntimeException {}
Envuelve las DocumentException de OpenPDF. No extiende ApiException, así que
GlobalExceptionHandler la trata por el fallback → 500 INTERNAL_ERROR.
Ejemplo de uso
Definir una plantilla
De TestInvoiceReportTemplate:
@Component
public class InvoiceReportTemplate implements ReportTemplate {
@Override
public String reportType() {
return "invoice";
}
@Override
public void render(Document document, Map<String, Object> data) throws DocumentException {
document.add(new Paragraph("Factura para " + data.get("clientName")));
document.add(new Paragraph("Total: " + data.get("total")));
}
}
Cualquier org.openpdf.text.Element sirve — Paragraph, PdfPTable, Image, etc. El Document ya está
abierto; no lo abras ni lo cierres, de eso se encarga ReportGenerator.
Generar y subir
@Service
public class InvoiceService {
private final ReportService reportService;
@Transactional
@RequiresPermission(relation = "can_edit", objectType = "invoice", objectId = "#invoiceId")
public FileDescriptor generateInvoicePdf(UUID invoiceId) {
Invoice invoice = repository.findById(invoiceId).orElseThrow();
return reportService.generateAndStore(
"invoice",
Map.of("clientName", invoice.getClientName(), "total", invoice.getTotal()),
"invoice", invoiceId, "factura-" + invoice.getNumber() + ".pdf");
}
}
Sólo los bytes, sin subir
@GetMapping(value = "/{id}/pdf", produces = MediaType.APPLICATION_PDF_VALUE)
@RequiresPermission(relation = "can_view", objectType = "invoice", objectId = "#id")
public byte[] downloadPdf(@PathVariable UUID id) {
Invoice invoice = repository.findById(id).orElseThrow();
return reportGenerator.generate("invoice",
Map.of("clientName", invoice.getClientName(), "total", invoice.getTotal()));
}
Qué garantizan los tests
ReportGeneratorTest verifica que salen bytes de PDF de verdad:
@Test
void generatesRealPdfBytesFromARegisteredTemplate() {
byte[] pdf = reportGenerator.generate("test-invoice", Map.of("clientName", "Ada Lovelace", "total", "100"));
assertThat(pdf).isNotEmpty();
assertThat(new String(pdf, 0, 5, StandardCharsets.US_ASCII)).isEqualTo("%PDF-");
}
Y ReportServiceIntegrationTest confirma que el archivo realmente aterriza en un MinIO real, leyéndolo de
vuelta del bucket en vez de confiar en lo que dice la fila de metadata:
@Test
void generateAndStoreProducesARealPdfActuallyPresentInTheBucket() throws Exception {
tupleWriter.grant(FgaId.of("user", userId), "admin", FgaId.of("organization", orgId));
tupleWriter.grant(FgaId.of("organization", orgId), "owner_organization", FgaId.of("resource", resourceId));
FileDescriptor descriptor = reportService.generateAndStore("test-invoice",
Map.of("clientName", "Ada Lovelace", "total", "100"),
"resource", resourceId, "invoice.pdf");
assertThat(descriptor.status()).isEqualTo(FileStatus.CONFIRMED);
assertThat(descriptor.contentType()).isEqualTo("application/pdf");
assertThat(descriptor.sizeBytes()).isPositive();
byte[] actualObjectBytes = downloadFromMinio(descriptor);
assertThat(new String(actualObjectBytes, 0, 5, StandardCharsets.US_ASCII)).isEqualTo("%PDF-");
assertThat(actualObjectBytes.length).isEqualTo(descriptor.sizeBytes().intValue());
}
Errores comunes
IllegalArgumentException: No ReportTemplate registered for report type 'x'.
El reportType() de la plantilla tiene que coincidir exacto con el string que le pasás a generate. Y la
plantilla tiene que estar registrada como bean.
La app no arranca, o falta el bean ReportService.
ReportService está condicionado a que exista FileStorageService, que a su vez requiere
echotechs.storage.s3.bucket y echotechs.authz.openfga.api-url. Si sólo necesitás generar PDFs,
inyectá ReportGenerator en vez de ReportService.
AccessDeniedException al generar un reporte.
generateAndStore pasa por uploadDirect, que exige can_edit sobre el recurso destino. Un job nocturno
sin principal autenticado va a fallar acá: FileStorageService tira AccessDeniedException cuando
TenantContext.getCurrentPrincipal() es null.
IllegalStateException desde TenantContext.getCurrentOrg() en un job programado.
Mismo problema que arriba, un paso más adelante: uploadDirect necesita la organización para armar el
object key, y un service account no es organization-scoped.
Acentos o caracteres especiales salen mal en el PDF.
Las fuentes por default de OpenPDF son las base-14 de PDF, con cobertura limitada. Para texto en español con
acentos garantizados, registrá una fuente con BaseFont.IDENTITY_H y encoding embebido dentro de tu
render.
ReportGenerationException envolviendo una DocumentException.
Casi siempre por manipular el Document de una forma que OpenPDF rechaza — por ejemplo cerrarlo dentro de
render, o agregar elementos después de cerrarlo.
Notas de implementación
Diferencias entre platform-core-spec.md y lo que hace el código:
No hay "plantillas + datos" en el sentido de un archivo de plantilla. El spec 1.7 dice "generación de
PDF... a partir de plantillas + datos", lo que sugiere un archivo de layout. La implementación es
código Java: ReportTemplate es una interfaz que se implementa, no un archivo .html/.jrxml que se
parsea. El Javadoc lo justifica explícitamente.
La subida "automática" exige permisos y contexto de tenant. El spec dice "sube el resultado
automáticamente vía core-storage", que suena a un paso de infraestructura transparente. En el código pasa
por FileStorageService.uploadDirect, con la verificación can_edit completa y la necesidad de un
TenantContext poblado. No se puede llamar desde un contexto sin principal autenticado.
No hay endpoint HTTP. A diferencia de core-storage o core-config, este módulo no registra ningún
controller. Exponer la generación de reportes es responsabilidad del proyecto de dominio.