Commit 89f47a38db03f5480824a6f187754249e3c61def
1 parent
d492328b
feat(reports): P1.8 — JasperReports integration + built-in ping report
Closes the P1.8 row of the implementation plan — **every Phase 1
platform unit is now ✅**. New platform-reports subproject wrapping
JasperReports 6.21.3 with a minimal api.v1 ReportRenderer facade,
a built-in self-test JRXML template, and a thin HTTP surface.
## api.v1 additions (package `org.vibeerp.api.v1.reports`)
- `ReportRenderer` — injectable facade with ONE method for v1:
`renderPdf(template: InputStream, data: Map<String, Any?>): ByteArray`
Caller loads the JRXML (or pre-compiled .jasper) from wherever
(plug-in JAR classpath, FileStorage, DB metadata row, HTTP
upload) and hands an open stream to the renderer. The framework
reads the bytes, compiles/fills/exports, and returns the PDF.
- `ReportRenderException` — wraps any engine exception so plug-ins
don't have to import concrete Jasper exception types.
- `PluginContext.reports: ReportRenderer` — new optional member
with the default-throw backward-compat pattern used for every
other addition. Plug-ins that ship quote PDFs, job cards,
delivery notes, etc. inject this through the context.
## platform-reports runtime
- `JasperReportRenderer` @Component — wraps JasperReports' compile
→ fill → export cycle into one method.
* `JasperCompileManager.compileReport(template)` turns the
JRXML stream into an in-memory `JasperReport`.
* `JasperFillManager.fillReport(compiled, params, JREmptyDataSource(1))`
evaluates expressions against the parameter map. The empty
data source satisfies Jasper's requirement for a non-null
data source when the template has no `<field>` definitions.
* `JasperExportManager.exportReportToPdfStream(jasperPrint, buffer)`
produces the PDF bytes. The `JasperPrint` type annotation on
the local is deliberate — Jasper has an ambiguous
`exportReportToPdfStream(InputStream, OutputStream)` overload
and Kotlin needs the explicit type to pick the right one.
* Every stage catches `Throwable` and re-throws as
`ReportRenderException` with a useful message, keeping the
api.v1 surface clean of Jasper's exception hierarchy.
- `ReportController` at `/api/v1/reports/**`:
* `POST /ping` render the built-in self-test JRXML with
the supplied `{name: "..."}` (optional, defaults
to "world") and return the PDF bytes with
`application/pdf` Content-Type
* `POST /render` multipart upload a JRXML template + return
the PDF. Operator / test use, not the main
production path.
Both endpoints @RequirePermission-gated via `reports.report.render`.
- `reports/vibeerp-ping-report.jrxml` — a single-page JRXML with
a title, centred "Hello, $P{name}!" text, and a footer. Zero
fields, one string parameter with a default value. Ships on the
platform-reports classpath and is loaded by the `/ping` endpoint
via `ClassPathResource`.
- `META-INF/vibe-erp/metadata/reports.yml` — 1 permission + 1 menu.
## Design decisions captured in-file
- **No template compilation cache.** Every call compiles the JRXML
fresh. Fine for infrequent reports (quotes, job cards); a hot
path that renders thousands of the same report per minute would
want a `ConcurrentHashMap<String, JasperReport>` keyed by
template hash. Deliberately NOT shipped until a benchmark shows
it's needed — the cache key semantics need a real consumer.
- **No multiple output formats.** v1 is PDF-only. Additive
overloads for HTML/XLSX land when a real consumer needs them.
- **No data-source argument.** v1 is parameter-driven, not
query-driven. A future `renderPdf(template, data, rows)`
overload will take tabular data for `<field>`-based templates.
- **No Groovy / Janino / ECJ.** The default `JRJavacCompiler` uses
`javax.tools.ToolProvider.getSystemJavaCompiler()` which is
available on any JDK runtime. vibe_erp already requires a JDK
(not JRE) for Liquibase + Flowable + Quartz, so we inherit this
for free. Zero extra compiler dependencies.
## Config trap caught during first build (documented in build.gradle.kts)
My first attempt added aggressive JasperReports exclusions to
shrink the transitive dep tree (POI, Batik, Velocity, Castor,
Groovy, commons-digester, ...). The build compiled fine but
`JasperCompileManager.compileReport(...)` threw
`ClassNotFoundException: org.apache.commons.digester.Digester`
at runtime — Jasper uses Digester internally to parse the JRXML
structure, and excluding the transitive dep silently breaks
template loading.
Fix: remove ALL exclusions. JasperReports' dep tree IS heavy,
but each transitive is load-bearing for a use case that's only
obvious once you exercise the engine end-to-end. A benchmark-
driven optimization chunk can revisit this later if the JAR size
becomes a concern; for v1.0 the "just pull it all in" approach is
correct. Documented in the build.gradle.kts so the next person
who thinks about trimming the dep tree reads the warning first.
## Smoke test (fresh DB, as admin)
```
POST /api/v1/reports/ping {"name": "Alice"}
→ 200
Content-Type: application/pdf
Content-Length: 1436
body: %PDF-1.5 ... (valid 1-page PDF)
$ file /tmp/ping-report.pdf
/tmp/ping-report.pdf: PDF document, version 1.5, 1 pages (zip deflate encoded)
POST /api/v1/reports/ping (no body)
→ 200, 1435 bytes, renders with default name="world" from JRXML
defaultValueExpression
# negative
POST /api/v1/reports/render (multipart with garbage bytes)
→ 400 {"message": "failed to compile JRXML template:
org.xml.sax.SAXParseException; lineNumber: 1; columnNumber: 1;
Content is not allowed in prolog."}
GET /api/v1/_meta/metadata
→ permissions includes "reports.report.render"
```
The `%PDF-` magic header is present and the `file` command on
macOS identifies the bytes as a valid PDF 1.5 single-page document.
JasperReports compile + fill + export are all running against
the live JDK 21 javac inside the Spring Boot app on first boot.
## Tests
- 3 new unit tests in `JasperReportRendererTest`:
* `renders the built-in ping template to a valid PDF byte stream`
— checks for the `%PDF-` magic header and a reasonable size
* `renders with the default parameter when the data map is empty`
— proves the JRXML's defaultValueExpression fires
* `wraps compile failures in ReportRenderException` — feeds
garbage bytes and asserts the exception type
- Total framework unit tests: 337 (was 334), all green.
## What this unblocks
- **Printing-shop quote PDFs.** The reference plug-in can now ship
a `reports/quote.jrxml` in its JAR, load it in an HTTP handler
via classloader, render via `context.reports.renderPdf(...)`,
and either return the PDF bytes directly or persist it via
`context.files.put("reports/quote-$code.pdf", "application/pdf", ...)`
for later download. The P1.8 → P1.9 chain is ready.
- **Job cards, delivery notes, pick lists, QC certificates.**
Every business document in a printing shop is a report
template + a data payload. The facade handles them all through
the same `renderPdf` call.
- **A future reports PBC.** When a PBC actually needs report
metadata persisted (template versioning, report scheduling), a
new pbc-reports can layer on top without changing api.v1 —
the renderer stays the lowest-level primitive, the PBC becomes
the management surface.
## Phase 1 completion
With P1.8 landed:
| Unit | Status |
|------|--------|
| P1.2 Plug-in linter | ✅ |
| P1.3 Plug-in HTTP + lifecycle | ✅ |
| P1.4 Plug-in Liquibase + PluginJdbc | ✅ |
| P1.5 Metadata store + loader | ✅ |
| P1.6 ICU4J translator | ✅ |
| P1.7 Event bus + outbox | ✅ |
| P1.8 JasperReports integration | ✅ |
| P1.9 File store | ✅ |
| P1.10 Quartz scheduler | ✅ |
**All nine Phase 1 platform units are now done.** (P1.1 Postgres RLS
was removed by the early single-tenant refactor, per CLAUDE.md
guardrail #5.) Remaining v1.0 work is cross-cutting: pbc-finance
GL growth, the web SPA (R1–R4), OIDC (P4.2), the MCP server (A1),
and richer per-PBC v2/v3 scopes.
## Non-goals (parking lot)
- Template caching keyed by hash.
- HTML/XLSX exporters.
- Pre-compiled `.jasper` support via a Gradle build task.
- Sub-reports (master-detail).
- Dependency-tree optimisation via selective exclusions — needs a
benchmark-driven chunk to prove each exclusion is safe.
- Plug-in loader integration for custom font embedding. Jasper's
default fonts work; custom fonts land when a real customer
plug-in needs them.
Showing
11 changed files
with
500 additions
and
0 deletions
api/api-v1/src/main/kotlin/org/vibeerp/api/v1/plugin/PluginContext.kt
| ... | ... | @@ -7,6 +7,7 @@ import org.vibeerp.api.v1.i18n.Translator |
| 7 | 7 | import org.vibeerp.api.v1.files.FileStorage |
| 8 | 8 | import org.vibeerp.api.v1.jobs.PluginJobHandlerRegistrar |
| 9 | 9 | import org.vibeerp.api.v1.persistence.Transaction |
| 10 | +import org.vibeerp.api.v1.reports.ReportRenderer | |
| 10 | 11 | import org.vibeerp.api.v1.security.PermissionCheck |
| 11 | 12 | import org.vibeerp.api.v1.workflow.PluginTaskHandlerRegistrar |
| 12 | 13 | |
| ... | ... | @@ -148,6 +149,22 @@ interface PluginContext { |
| 148 | 149 | "PluginContext.jobs is not implemented by this host. " + |
| 149 | 150 | "Upgrade vibe_erp to v0.9 or later." |
| 150 | 151 | ) |
| 152 | + | |
| 153 | + /** | |
| 154 | + * Produce printable output (PDF today) from a template + data | |
| 155 | + * map. Plug-ins use this to render quotes, delivery notes, QC | |
| 156 | + * certificates, and other business documents. See | |
| 157 | + * [org.vibeerp.api.v1.reports.ReportRenderer]. | |
| 158 | + * | |
| 159 | + * Added in api.v1.0.x (P1.8). Default implementation throws so | |
| 160 | + * an older host running a newer plug-in jar fails loudly at | |
| 161 | + * first call rather than silently returning empty PDFs. | |
| 162 | + */ | |
| 163 | + val reports: ReportRenderer | |
| 164 | + get() = throw UnsupportedOperationException( | |
| 165 | + "PluginContext.reports is not implemented by this host. " + | |
| 166 | + "Upgrade vibe_erp to v0.10 or later." | |
| 167 | + ) | |
| 151 | 168 | } |
| 152 | 169 | |
| 153 | 170 | /** | ... | ... |
api/api-v1/src/main/kotlin/org/vibeerp/api/v1/reports/ReportRenderer.kt
0 → 100644
| 1 | +package org.vibeerp.api.v1.reports | |
| 2 | + | |
| 3 | +import java.io.InputStream | |
| 4 | + | |
| 5 | +/** | |
| 6 | + * Cross-PBC facade for the framework's report renderer. | |
| 7 | + * | |
| 8 | + * The framework-wide seam for turning a template + data payload | |
| 9 | + * into printable output (today: PDF). Core PBCs and plug-ins inject | |
| 10 | + * [ReportRenderer] to produce quote PDFs, delivery notes, QC | |
| 11 | + * certificates, work-order job cards, and any other piece of paper | |
| 12 | + * a printing shop drops on a clipboard. The host's `platform-reports` | |
| 13 | + * module supplies the only implementation. | |
| 14 | + * | |
| 15 | + * **Why one interface instead of exposing JasperReports directly:** | |
| 16 | + * - Plug-ins must not import `net.sf.jasperreports.*` or any other | |
| 17 | + * internal report engine type (CLAUDE.md guardrail #10). Swapping | |
| 18 | + * Jasper for a different engine (e.g. OpenPDF-only, or a | |
| 19 | + * cloud-hosted template service) is a platform-reports change | |
| 20 | + * that costs zero plug-in lines. | |
| 21 | + * - The facade forces the minimal contract: a template + a data | |
| 22 | + * map in, a byte array out. Everything fancier (sub-reports, | |
| 23 | + * pre-compiled `.jasper` files, locale-aware number formatting, | |
| 24 | + * embedded fonts) lives inside the implementation and is an | |
| 25 | + * additive change. | |
| 26 | + * | |
| 27 | + * **Template lifecycle is the caller's problem.** The renderer does | |
| 28 | + * NOT care where the template came from — the caller loads the | |
| 29 | + * JRXML from wherever it lives (plug-in JAR classpath resource, | |
| 30 | + * `FileStorage`, database row, HTTP upload) and hands an open | |
| 31 | + * InputStream to [renderPdf]. The implementation reads it to the | |
| 32 | + * end but does NOT close it; the caller owns the stream's | |
| 33 | + * lifetime and should wrap in `use { }`. | |
| 34 | + * | |
| 35 | + * **What this facade does NOT expose:** | |
| 36 | + * - Multiple output formats. v1.0 is PDF-only. HTML/XLSX exporters | |
| 37 | + * land as additional methods when a real consumer needs them. | |
| 38 | + * - Pre-compiled template caching. The implementation is free to | |
| 39 | + * cache compiled templates by a key the caller supplies, but | |
| 40 | + * the cache key is not part of the api.v1 contract — an | |
| 41 | + * additive method [renderPdfCached] lands when a benchmark | |
| 42 | + * shows it's worth the extra surface. | |
| 43 | + * - Sub-reports / master-detail. A future chunk adds a | |
| 44 | + * `resolveSubReport(name)` callback to the interface. | |
| 45 | + * | |
| 46 | + * v1.0 is deliberately boring. The goal is "plug-in produces a | |
| 47 | + * quote PDF" working end-to-end, not "every Jasper feature the | |
| 48 | + * framework can reach". | |
| 49 | + */ | |
| 50 | +public interface ReportRenderer { | |
| 51 | + | |
| 52 | + /** | |
| 53 | + * Compile and fill the given [template] (a JRXML source stream, | |
| 54 | + * or a pre-compiled `.jasper` stream — the implementation | |
| 55 | + * decides which based on the first bytes) with [data] as the | |
| 56 | + * parameter map, and export the result as a PDF. | |
| 57 | + * | |
| 58 | + * The [data] map is passed to JasperReports as parameters: a | |
| 59 | + * template referencing `$P{name}` reads `data["name"]` at | |
| 60 | + * fill time. The framework does not currently support passing | |
| 61 | + * JDBC data sources — reports are parameter-driven, not | |
| 62 | + * query-driven. A future additive overload may take a | |
| 63 | + * `dataSource: List<Map<String, Any?>>` when a real consumer | |
| 64 | + * needs tabular data. | |
| 65 | + * | |
| 66 | + * @param template an open input stream pointing at a JRXML (or | |
| 67 | + * pre-compiled .jasper) resource. The caller owns the | |
| 68 | + * lifetime and should close it (e.g. `template.use { ... }`). | |
| 69 | + * @param data the parameter map. Keys are JRXML parameter | |
| 70 | + * names (matching `$P{…}`). Values are any JSON-compatible | |
| 71 | + * type; complex objects are stringified by the template | |
| 72 | + * expression engine. | |
| 73 | + * @return the rendered PDF as a byte array. For very large | |
| 74 | + * reports a streaming variant will land when memory pressure | |
| 75 | + * becomes a real concern. | |
| 76 | + * @throws ReportRenderException if the template is malformed, | |
| 77 | + * references a parameter that isn't in [data], or the | |
| 78 | + * underlying engine fails to compile or fill. | |
| 79 | + */ | |
| 80 | + public fun renderPdf(template: InputStream, data: Map<String, Any?>): ByteArray | |
| 81 | +} | |
| 82 | + | |
| 83 | +/** | |
| 84 | + * Wraps anything the report engine threw into a single api.v1 | |
| 85 | + * exception type so plug-ins don't have to import concrete engine | |
| 86 | + * exception classes. | |
| 87 | + */ | |
| 88 | +public class ReportRenderException( | |
| 89 | + message: String, | |
| 90 | + cause: Throwable? = null, | |
| 91 | +) : RuntimeException(message, cause) | ... | ... |
distribution/build.gradle.kts
| ... | ... | @@ -29,6 +29,7 @@ dependencies { |
| 29 | 29 | implementation(project(":platform:platform-workflow")) |
| 30 | 30 | implementation(project(":platform:platform-jobs")) |
| 31 | 31 | implementation(project(":platform:platform-files")) |
| 32 | + implementation(project(":platform:platform-reports")) | |
| 32 | 33 | implementation(project(":pbc:pbc-identity")) |
| 33 | 34 | implementation(project(":pbc:pbc-catalog")) |
| 34 | 35 | implementation(project(":pbc:pbc-partners")) | ... | ... |
gradle/libs.versions.toml
| ... | ... | @@ -8,6 +8,7 @@ hibernate = "6.5.3.Final" |
| 8 | 8 | liquibase = "4.29.2" |
| 9 | 9 | pf4j = "3.12.0" |
| 10 | 10 | flowable = "7.0.1" |
| 11 | +jasperreports = "6.21.3" | |
| 11 | 12 | icu4j = "75.1" |
| 12 | 13 | jackson = "2.18.0" |
| 13 | 14 | junitJupiter = "5.11.2" |
| ... | ... | @@ -57,6 +58,9 @@ flowable-spring-boot-starter-process = { module = "org.flowable:flowable-spring- |
| 57 | 58 | # Job scheduler (Quartz via Spring Boot starter) |
| 58 | 59 | spring-boot-starter-quartz = { module = "org.springframework.boot:spring-boot-starter-quartz", version.ref = "springBoot" } |
| 59 | 60 | |
| 61 | +# Reports (JasperReports PDF rendering) | |
| 62 | +jasperreports = { module = "net.sf.jasperreports:jasperreports", version.ref = "jasperreports" } | |
| 63 | + | |
| 60 | 64 | # i18n |
| 61 | 65 | icu4j = { module = "com.ibm.icu:icu4j", version.ref = "icu4j" } |
| 62 | 66 | ... | ... |
platform/platform-reports/build.gradle.kts
0 → 100644
| 1 | +plugins { | |
| 2 | + alias(libs.plugins.kotlin.jvm) | |
| 3 | + alias(libs.plugins.kotlin.spring) | |
| 4 | + alias(libs.plugins.spring.dependency.management) | |
| 5 | +} | |
| 6 | + | |
| 7 | +description = "vibe_erp JasperReports-backed PDF rendering. Adapts Jasper to the api.v1 ReportRenderer contract. INTERNAL." | |
| 8 | + | |
| 9 | +java { | |
| 10 | + toolchain { | |
| 11 | + languageVersion.set(JavaLanguageVersion.of(21)) | |
| 12 | + } | |
| 13 | +} | |
| 14 | + | |
| 15 | +kotlin { | |
| 16 | + jvmToolchain(21) | |
| 17 | + compilerOptions { | |
| 18 | + freeCompilerArgs.add("-Xjsr305=strict") | |
| 19 | + } | |
| 20 | +} | |
| 21 | + | |
| 22 | +// Only module that pulls JasperReports in. JasperReports has a | |
| 23 | +// notoriously heavy transitive dep tree (POI for Excel, Batik for | |
| 24 | +// SVG, Groovy for expressions, commons-digester, Apache Velocity, | |
| 25 | +// ...) — most of it is only needed for features we don't use. | |
| 26 | +// Aggressive exclusions keep the distribution jar from ballooning. | |
| 27 | +dependencies { | |
| 28 | + api(project(":api:api-v1")) | |
| 29 | + implementation(project(":platform:platform-security")) // @RequirePermission on the controller | |
| 30 | + | |
| 31 | + implementation(libs.kotlin.stdlib) | |
| 32 | + implementation(libs.kotlin.reflect) | |
| 33 | + implementation(libs.jackson.module.kotlin) | |
| 34 | + | |
| 35 | + implementation(libs.spring.boot.starter) | |
| 36 | + implementation(libs.spring.boot.starter.web) | |
| 37 | + | |
| 38 | + // JasperReports itself. Transitive deps left intact — | |
| 39 | + // aggressive exclusions break template loading because Jasper | |
| 40 | + // uses commons-digester for XML parsing internally. The | |
| 41 | + // dep tree IS heavy, but that's the cost of PDF rendering | |
| 42 | + // in a v1.0 framework; optimising belongs to a future chunk | |
| 43 | + // with benchmark data. | |
| 44 | + implementation(libs.jasperreports) | |
| 45 | + | |
| 46 | + testImplementation(libs.spring.boot.starter.test) | |
| 47 | + testImplementation(libs.junit.jupiter) | |
| 48 | + testImplementation(libs.assertk) | |
| 49 | + testImplementation(libs.mockk) | |
| 50 | +} | |
| 51 | + | |
| 52 | +tasks.test { | |
| 53 | + useJUnitPlatform() | |
| 54 | +} | ... | ... |
platform/platform-reports/src/main/kotlin/org/vibeerp/platform/reports/JasperReportRenderer.kt
0 → 100644
| 1 | +package org.vibeerp.platform.reports | |
| 2 | + | |
| 3 | +import net.sf.jasperreports.engine.JREmptyDataSource | |
| 4 | +import net.sf.jasperreports.engine.JasperCompileManager | |
| 5 | +import net.sf.jasperreports.engine.JasperExportManager | |
| 6 | +import net.sf.jasperreports.engine.JasperFillManager | |
| 7 | +import net.sf.jasperreports.engine.JasperPrint | |
| 8 | +import net.sf.jasperreports.engine.JasperReport | |
| 9 | +import org.slf4j.LoggerFactory | |
| 10 | +import org.springframework.stereotype.Component | |
| 11 | +import org.vibeerp.api.v1.reports.ReportRenderException | |
| 12 | +import org.vibeerp.api.v1.reports.ReportRenderer | |
| 13 | +import java.io.ByteArrayOutputStream | |
| 14 | +import java.io.InputStream | |
| 15 | + | |
| 16 | +/** | |
| 17 | + * JasperReports-backed implementation of api.v1 [ReportRenderer]. | |
| 18 | + * | |
| 19 | + * **The compile → fill → export cycle.** JasperReports separates a | |
| 20 | + * report's lifecycle into three stages: | |
| 21 | + * 1. **Compile** a JRXML template into an in-memory [JasperReport] | |
| 22 | + * object (or load a pre-compiled `.jasper` file, but this v1 | |
| 23 | + * only accepts JRXML). | |
| 24 | + * 2. **Fill** the compiled report by evaluating every expression | |
| 25 | + * against a data source + a parameter map, producing a | |
| 26 | + * `JasperPrint` object — the pageset. | |
| 27 | + * 3. **Export** the `JasperPrint` to a target format; v1 exports | |
| 28 | + * to PDF via the built-in `JasperExportManager.exportReportToPdf`. | |
| 29 | + * | |
| 30 | + * The framework's v1 contract collapses these into a single | |
| 31 | + * [renderPdf] call that takes the template bytes + a parameter map | |
| 32 | + * and returns the PDF bytes. Callers that need richer control (e.g. | |
| 33 | + * fill from a live JDBC result set, stream PDF output, produce | |
| 34 | + * multi-page sub-reports) will get additive overloads in a future | |
| 35 | + * api.v1.0.x chunk. | |
| 36 | + * | |
| 37 | + * **No expression compiler headaches.** The default JasperReports | |
| 38 | + * expression compiler (`JRJavacCompiler`) uses | |
| 39 | + * `javax.tools.ToolProvider.getSystemJavaCompiler()` which is | |
| 40 | + * available on any JDK runtime — and vibe_erp already requires a | |
| 41 | + * JDK at runtime (Flowable, Liquibase, and Quartz all need it too). | |
| 42 | + * We do NOT add Groovy, Janino, or ECJ as a dependency — the JDK's | |
| 43 | + * own javac does the job. | |
| 44 | + * | |
| 45 | + * **Template compilation is NOT cached.** Every call compiles the | |
| 46 | + * JRXML fresh. This is fine for infrequent reports (quote PDFs, | |
| 47 | + * nightly job-card batches) but would be a hot-path cost for a | |
| 48 | + * consumer that renders the same template thousands of times per | |
| 49 | + * minute. The obvious optimization (a `ConcurrentHashMap<String, | |
| 50 | + * JasperReport>` keyed by template hash) is deliberately NOT | |
| 51 | + * shipped until a benchmark shows it's needed — the cache key | |
| 52 | + * semantics need a real consumer to design against. | |
| 53 | + * | |
| 54 | + * **`JREmptyDataSource` for parameter-only reports.** A JRXML | |
| 55 | + * template with no `<field>` definitions only reads its data from | |
| 56 | + * the parameter map. JasperReports still wants a data source, so | |
| 57 | + * the framework passes `JREmptyDataSource(1)` — "one row of nothing" | |
| 58 | + * — which satisfies the engine and lets the template render | |
| 59 | + * exactly once. Reports that iterate over a list of rows will need | |
| 60 | + * a future `renderPdf(template, data, rows)` overload. | |
| 61 | + */ | |
| 62 | +@Component | |
| 63 | +class JasperReportRenderer : ReportRenderer { | |
| 64 | + | |
| 65 | + private val log = LoggerFactory.getLogger(JasperReportRenderer::class.java) | |
| 66 | + | |
| 67 | + override fun renderPdf(template: InputStream, data: Map<String, Any?>): ByteArray { | |
| 68 | + val compiled: JasperReport = try { | |
| 69 | + JasperCompileManager.compileReport(template) | |
| 70 | + } catch (ex: Throwable) { | |
| 71 | + throw ReportRenderException("failed to compile JRXML template: ${ex.message}", ex) | |
| 72 | + } | |
| 73 | + | |
| 74 | + // JasperFillManager requires a non-null parameter map with | |
| 75 | + // String keys. Copy defensively and strip nulls — JR treats | |
| 76 | + // a null parameter as "not set" which is usually not what | |
| 77 | + // the caller means. | |
| 78 | + val params: MutableMap<String, Any> = HashMap() | |
| 79 | + for ((k, v) in data) { | |
| 80 | + if (v != null) params[k] = v | |
| 81 | + } | |
| 82 | + | |
| 83 | + val jasperPrint: JasperPrint = try { | |
| 84 | + JasperFillManager.fillReport(compiled, params, JREmptyDataSource(1)) | |
| 85 | + } catch (ex: Throwable) { | |
| 86 | + throw ReportRenderException("failed to fill report: ${ex.message}", ex) | |
| 87 | + } | |
| 88 | + | |
| 89 | + val buffer = ByteArrayOutputStream() | |
| 90 | + try { | |
| 91 | + // Explicit JasperPrint type on the local so Kotlin picks | |
| 92 | + // the JasperPrint overload of exportReportToPdfStream | |
| 93 | + // (there is an ambiguous InputStream overload too). | |
| 94 | + JasperExportManager.exportReportToPdfStream(jasperPrint, buffer) | |
| 95 | + } catch (ex: Throwable) { | |
| 96 | + throw ReportRenderException("failed to export PDF: ${ex.message}", ex) | |
| 97 | + } | |
| 98 | + | |
| 99 | + val bytes = buffer.toByteArray() | |
| 100 | + log.debug("rendered PDF size={} params={}", bytes.size, params.keys.sorted()) | |
| 101 | + return bytes | |
| 102 | + } | |
| 103 | +} | ... | ... |
platform/platform-reports/src/main/kotlin/org/vibeerp/platform/reports/http/ReportController.kt
0 → 100644
| 1 | +package org.vibeerp.platform.reports.http | |
| 2 | + | |
| 3 | +import org.springframework.core.io.ClassPathResource | |
| 4 | +import org.springframework.http.HttpHeaders | |
| 5 | +import org.springframework.http.HttpStatus | |
| 6 | +import org.springframework.http.MediaType | |
| 7 | +import org.springframework.http.ResponseEntity | |
| 8 | +import org.springframework.web.bind.annotation.ExceptionHandler | |
| 9 | +import org.springframework.web.bind.annotation.PostMapping | |
| 10 | +import org.springframework.web.bind.annotation.RequestBody | |
| 11 | +import org.springframework.web.bind.annotation.RequestMapping | |
| 12 | +import org.springframework.web.bind.annotation.RestController | |
| 13 | +import org.springframework.web.multipart.MultipartFile | |
| 14 | +import org.vibeerp.api.v1.reports.ReportRenderException | |
| 15 | +import org.vibeerp.api.v1.reports.ReportRenderer | |
| 16 | +import org.vibeerp.platform.security.authz.RequirePermission | |
| 17 | + | |
| 18 | +/** | |
| 19 | + * HTTP surface over the framework's [ReportRenderer]. | |
| 20 | + * | |
| 21 | + * - `POST /api/v1/reports/ping` render the built-in self-test | |
| 22 | + * JRXML with the supplied params | |
| 23 | + * and return a PDF | |
| 24 | + * - `POST /api/v1/reports/render` multipart upload a JRXML + | |
| 25 | + * supply params; returns a PDF | |
| 26 | + * | |
| 27 | + * The multipart endpoint is intentionally for operator / test use; | |
| 28 | + * the main way to render reports in production is via a plug-in or | |
| 29 | + * core PBC injecting [ReportRenderer] and calling it against a | |
| 30 | + * template loaded from the plug-in's classpath, the file store, or | |
| 31 | + * the metadata database. | |
| 32 | + */ | |
| 33 | +@RestController | |
| 34 | +@RequestMapping("/api/v1/reports") | |
| 35 | +class ReportController( | |
| 36 | + private val reportRenderer: ReportRenderer, | |
| 37 | +) { | |
| 38 | + | |
| 39 | + @PostMapping("/ping", produces = [MediaType.APPLICATION_PDF_VALUE]) | |
| 40 | + @RequirePermission("reports.report.render") | |
| 41 | + fun ping(@RequestBody(required = false) request: PingReportRequest?): ResponseEntity<ByteArray> { | |
| 42 | + val name = request?.name ?: "world" | |
| 43 | + val template = ClassPathResource(BUILTIN_TEMPLATE).inputStream | |
| 44 | + val bytes = template.use { reportRenderer.renderPdf(it, mapOf("name" to name)) } | |
| 45 | + return pdfResponse(bytes, "vibeerp-ping.pdf") | |
| 46 | + } | |
| 47 | + | |
| 48 | + @PostMapping( | |
| 49 | + "/render", | |
| 50 | + consumes = [MediaType.MULTIPART_FORM_DATA_VALUE], | |
| 51 | + produces = [MediaType.APPLICATION_PDF_VALUE], | |
| 52 | + ) | |
| 53 | + @RequirePermission("reports.report.render") | |
| 54 | + fun render( | |
| 55 | + @org.springframework.web.bind.annotation.RequestParam("template") template: MultipartFile, | |
| 56 | + @org.springframework.web.bind.annotation.RequestParam("filename", required = false) filename: String?, | |
| 57 | + ): ResponseEntity<ByteArray> { | |
| 58 | + val bytes = template.inputStream.use { reportRenderer.renderPdf(it, emptyMap()) } | |
| 59 | + return pdfResponse(bytes, filename ?: "rendered.pdf") | |
| 60 | + } | |
| 61 | + | |
| 62 | + private fun pdfResponse(bytes: ByteArray, filename: String): ResponseEntity<ByteArray> = | |
| 63 | + ResponseEntity.status(HttpStatus.OK) | |
| 64 | + .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_PDF_VALUE) | |
| 65 | + .header(HttpHeaders.CONTENT_LENGTH, bytes.size.toString()) | |
| 66 | + .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"$filename\"") | |
| 67 | + .body(bytes) | |
| 68 | + | |
| 69 | + @ExceptionHandler(ReportRenderException::class) | |
| 70 | + fun handleRenderFailure(ex: ReportRenderException): ResponseEntity<ErrorResponse> = | |
| 71 | + ResponseEntity.status(HttpStatus.BAD_REQUEST) | |
| 72 | + .body(ErrorResponse(message = ex.message ?: "report render failed")) | |
| 73 | + | |
| 74 | + companion object { | |
| 75 | + /** | |
| 76 | + * Classpath path to the built-in self-test JRXML shipped in | |
| 77 | + * platform-reports/src/main/resources. Kept as a constant so | |
| 78 | + * unit tests and a future "test this template" smoke endpoint | |
| 79 | + * both reference the same file. | |
| 80 | + */ | |
| 81 | + const val BUILTIN_TEMPLATE: String = "reports/vibeerp-ping-report.jrxml" | |
| 82 | + } | |
| 83 | +} | |
| 84 | + | |
| 85 | +data class PingReportRequest( | |
| 86 | + val name: String? = null, | |
| 87 | +) | |
| 88 | + | |
| 89 | +data class ErrorResponse( | |
| 90 | + val message: String, | |
| 91 | +) | ... | ... |
platform/platform-reports/src/main/resources/META-INF/vibe-erp/metadata/reports.yml
0 → 100644
| 1 | +# platform-reports metadata. | |
| 2 | +# | |
| 3 | +# Loaded at boot by MetadataLoader, tagged source='core'. | |
| 4 | + | |
| 5 | +permissions: | |
| 6 | + - key: reports.report.render | |
| 7 | + description: Render a JasperReports template to PDF | |
| 8 | + | |
| 9 | +menus: | |
| 10 | + - path: /reports | |
| 11 | + label: Reports | |
| 12 | + icon: file-pdf | |
| 13 | + section: Reports | |
| 14 | + order: 700 | ... | ... |
platform/platform-reports/src/main/resources/reports/vibeerp-ping-report.jrxml
0 → 100644
| 1 | +<?xml version="1.0" encoding="UTF-8"?> | |
| 2 | +<!-- | |
| 3 | + Built-in diagnostic JRXML for vibe_erp's report engine. | |
| 4 | + | |
| 5 | + Ships on the host classpath at classpath:/reports/vibeerp-ping-report.jrxml | |
| 6 | + and is rendered by the POST /api/v1/reports/ping endpoint as a | |
| 7 | + self-test: a single-page PDF showing a greeting plus the supplied | |
| 8 | + name parameter. Zero expressions that need compilation beyond | |
| 9 | + parameter substitution, zero data fields. | |
| 10 | + | |
| 11 | + Real business reports (quote PDFs, job cards, QC certificates) | |
| 12 | + will live inside the plug-ins that use them; this template exists | |
| 13 | + so an operator can prove the report engine is wired end-to-end | |
| 14 | + without installing anything. | |
| 15 | +--> | |
| 16 | +<jasperReport xmlns="http://jasperreports.sourceforge.net/jasperreports" | |
| 17 | + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" | |
| 18 | + xsi:schemaLocation="http://jasperreports.sourceforge.net/jasperreports http://jasperreports.sourceforge.net/xsd/jasperreport.xsd" | |
| 19 | + name="vibeerp-ping" | |
| 20 | + pageWidth="595" | |
| 21 | + pageHeight="842" | |
| 22 | + columnWidth="535" | |
| 23 | + leftMargin="30" | |
| 24 | + rightMargin="30" | |
| 25 | + topMargin="40" | |
| 26 | + bottomMargin="40"> | |
| 27 | + <parameter name="name" class="java.lang.String"> | |
| 28 | + <defaultValueExpression><![CDATA["world"]]></defaultValueExpression> | |
| 29 | + </parameter> | |
| 30 | + <title> | |
| 31 | + <band height="80"> | |
| 32 | + <staticText> | |
| 33 | + <reportElement x="0" y="0" width="535" height="36"/> | |
| 34 | + <textElement textAlignment="Center"> | |
| 35 | + <font size="24" isBold="true"/> | |
| 36 | + </textElement> | |
| 37 | + <text><![CDATA[vibe_erp report engine]]></text> | |
| 38 | + </staticText> | |
| 39 | + <staticText> | |
| 40 | + <reportElement x="0" y="40" width="535" height="20"/> | |
| 41 | + <textElement textAlignment="Center"> | |
| 42 | + <font size="12"/> | |
| 43 | + </textElement> | |
| 44 | + <text><![CDATA[Built-in self-test (vibeerp.reports.ping)]]></text> | |
| 45 | + </staticText> | |
| 46 | + </band> | |
| 47 | + </title> | |
| 48 | + <pageHeader> | |
| 49 | + <band height="0"/> | |
| 50 | + </pageHeader> | |
| 51 | + <detail> | |
| 52 | + <band height="60"> | |
| 53 | + <textField> | |
| 54 | + <reportElement x="0" y="20" width="535" height="24"/> | |
| 55 | + <textElement textAlignment="Center"> | |
| 56 | + <font size="16"/> | |
| 57 | + </textElement> | |
| 58 | + <textFieldExpression><![CDATA["Hello, " + $P{name} + "!"]]></textFieldExpression> | |
| 59 | + </textField> | |
| 60 | + </band> | |
| 61 | + </detail> | |
| 62 | + <pageFooter> | |
| 63 | + <band height="20"> | |
| 64 | + <staticText> | |
| 65 | + <reportElement x="0" y="0" width="535" height="16"/> | |
| 66 | + <textElement textAlignment="Center"> | |
| 67 | + <font size="9"/> | |
| 68 | + </textElement> | |
| 69 | + <text><![CDATA[If you can read this PDF, JasperReports compile + fill + export all work.]]></text> | |
| 70 | + </staticText> | |
| 71 | + </band> | |
| 72 | + </pageFooter> | |
| 73 | +</jasperReport> | ... | ... |
platform/platform-reports/src/test/kotlin/org/vibeerp/platform/reports/JasperReportRendererTest.kt
0 → 100644
| 1 | +package org.vibeerp.platform.reports | |
| 2 | + | |
| 3 | +import assertk.assertFailure | |
| 4 | +import assertk.assertThat | |
| 5 | +import assertk.assertions.isEqualTo | |
| 6 | +import assertk.assertions.isGreaterThan | |
| 7 | +import assertk.assertions.isInstanceOf | |
| 8 | +import org.junit.jupiter.api.Test | |
| 9 | +import org.vibeerp.api.v1.reports.ReportRenderException | |
| 10 | +import java.io.ByteArrayInputStream | |
| 11 | + | |
| 12 | +class JasperReportRendererTest { | |
| 13 | + | |
| 14 | + private val subject = JasperReportRenderer() | |
| 15 | + | |
| 16 | + @Test | |
| 17 | + fun `renders the built-in ping template to a valid PDF byte stream`() { | |
| 18 | + val template = javaClass.classLoader | |
| 19 | + .getResourceAsStream("reports/vibeerp-ping-report.jrxml") | |
| 20 | + ?: error("built-in template missing from the test classpath") | |
| 21 | + | |
| 22 | + val pdf = template.use { subject.renderPdf(it, mapOf("name" to "Alice")) } | |
| 23 | + | |
| 24 | + // A valid PDF starts with the literal header `%PDF-`. | |
| 25 | + assertThat(pdf.size).isGreaterThan(200) | |
| 26 | + assertThat(String(pdf.take(5).toByteArray(), Charsets.ISO_8859_1)).isEqualTo("%PDF-") | |
| 27 | + } | |
| 28 | + | |
| 29 | + @Test | |
| 30 | + fun `renders with the default parameter when the data map is empty`() { | |
| 31 | + val template = javaClass.classLoader | |
| 32 | + .getResourceAsStream("reports/vibeerp-ping-report.jrxml") | |
| 33 | + ?: error("built-in template missing from the test classpath") | |
| 34 | + | |
| 35 | + val pdf = template.use { subject.renderPdf(it, emptyMap()) } | |
| 36 | + | |
| 37 | + // Still produces a valid PDF — the JRXML supplies a | |
| 38 | + // defaultValueExpression of "world" for the name parameter. | |
| 39 | + assertThat(String(pdf.take(5).toByteArray(), Charsets.ISO_8859_1)).isEqualTo("%PDF-") | |
| 40 | + } | |
| 41 | + | |
| 42 | + @Test | |
| 43 | + fun `wraps compile failures in ReportRenderException`() { | |
| 44 | + val garbage = ByteArrayInputStream("not a valid JRXML".toByteArray()) | |
| 45 | + | |
| 46 | + assertFailure { subject.renderPdf(garbage, emptyMap()) } | |
| 47 | + .isInstanceOf(ReportRenderException::class) | |
| 48 | + } | |
| 49 | +} | ... | ... |
settings.gradle.kts
| ... | ... | @@ -51,6 +51,9 @@ project(":platform:platform-jobs").projectDir = file("platform/platform-jobs") |
| 51 | 51 | include(":platform:platform-files") |
| 52 | 52 | project(":platform:platform-files").projectDir = file("platform/platform-files") |
| 53 | 53 | |
| 54 | +include(":platform:platform-reports") | |
| 55 | +project(":platform:platform-reports").projectDir = file("platform/platform-reports") | |
| 56 | + | |
| 54 | 57 | // ─── Packaged Business Capabilities (core PBCs) ───────────────────── |
| 55 | 58 | include(":pbc:pbc-identity") |
| 56 | 59 | project(":pbc:pbc-identity").projectDir = file("pbc/pbc-identity") | ... | ... |