Commit e4f7cf42661e4e5963b5a7224e53d3c49ac0568c

Authored by zichun
1 parent d243a9a4

feat(plugins+ref-plugin): wire FileStorage + ReportRenderer through PluginContext + quote PDF demo

Closes two open wiring gaps left by the P1.9 and P1.8 chunks —
`PluginContext.files` and `PluginContext.reports` both previously
threw `UnsupportedOperationException` because the host's
`DefaultPluginContext` never received the concrete beans. This
commit plumbs both through and exercises them end-to-end via a
new printing-shop plug-in endpoint that generates a quote PDF,
stores it in the file store, and returns the file handle.

With this chunk the reference printing-shop plug-in demonstrates
**every extension seam the framework provides**: HTTP endpoints,
JDBC, metadata YAML, i18n, BPMN + TaskHandlers, JobHandlers,
custom fields on core entities, event publishing via EventBus,
ReportRenderer, and FileStorage. There is no major public plug-in
surface left unexercised.

## Wiring: DefaultPluginContext + VibeErpPluginManager

- `DefaultPluginContext` gains two new constructor parameters
  (`sharedFileStorage: FileStorage`, `sharedReportRenderer: ReportRenderer`)
  and two new overrides. Each is wired via Spring — they live in
  platform-files and platform-reports respectively, but
  platform-plugins only depends on api.v1 (the interfaces) and
  NOT on those modules directly. The concrete beans are injected
  by Spring at distribution boot time when every `@Component` is
  on the classpath.
- `VibeErpPluginManager` adds `private val fileStorage: FileStorage`
  and `private val reportRenderer: ReportRenderer` constructor
  params and passes them through to every `DefaultPluginContext`
  it builds per plug-in.

The `files` and `reports` getters in api.v1 `PluginContext` still
have their default-throw backward-compat shim — a plug-in built
against v0.8 of api.v1 loading on a v0.7 host would still fail
loudly at first call with a clear "upgrade to v0.8" message. The
override here makes the v0.8+ host honour the interface.

## Printing-shop reference — quote PDF endpoint

- New `resources/reports/quote-template.jrxml` inside the plug-in
  JAR. Parameters: plateCode, plateName, widthMm, heightMm,
  status, customerName. Produces a single-page A4 PDF with a
  header, a table of plate attributes, and a footer.

- New endpoint `POST /api/v1/plugins/printing-shop/plates/{id}/generate-quote-pdf`.
  Request body `{"customerName": "..."}`, response:
    `{"plateId", "plateCode", "customerName",
      "fileKey", "fileSize", "fileContentType", "downloadUrl"}`

  The handler does ALL of:
    1. Reads the plate row via `context.jdbc.queryForObject(...)`
    2. Loads the JRXML from the PLUG-IN's own classloader (not
       the host classpath — `this::class.java.classLoader
       .getResourceAsStream("reports/quote-template.jrxml")` —
       so the host's built-in `vibeerp-ping-report.jrxml` and the
       plug-in's template live in isolated namespaces)
    3. Renders via `context.reports.renderPdf(template, data)`
       — uses the host JasperReportRenderer under the hood
    4. Persists via `context.files.put(key, contentType, content)`
       under a plug-in-scoped key `plugin-printing-shop/quotes/quote-<code>.pdf`
    5. Returns the file handle plus a `downloadUrl` pointing at
       the framework's `/api/v1/files/download` endpoint the
       caller can immediately hit

## Smoke test (fresh DB + staged plug-in)

```
# create a plate
POST /api/v1/plugins/printing-shop/plates
     {code: PLATE-200, name: "Premium cover", widthMm: 420, heightMm: 594}
  → 201 {id, code: PLATE-200, status: DRAFT, ...}

# generate + store the quote PDF
POST /api/v1/plugins/printing-shop/plates/<id>/generate-quote-pdf
     {customerName: "Acme Inc"}
  → 201 {
      plateId, plateCode: "PLATE-200", customerName: "Acme Inc",
      fileKey: "plugin-printing-shop/quotes/quote-PLATE-200.pdf",
      fileSize: 1488,
      fileContentType: "application/pdf",
      downloadUrl: "/api/v1/files/download?key=plugin-printing-shop/quotes/quote-PLATE-200.pdf"
    }

# download via the framework's file endpoint
GET /api/v1/files/download?key=plugin-printing-shop/quotes/quote-PLATE-200.pdf
  → 200
    Content-Type: application/pdf
    Content-Length: 1488
    body: valid PDF 1.5, 1 page

$ file /tmp/plate-quote.pdf
  /tmp/plate-quote.pdf: PDF document, version 1.5, 1 pages (zip deflate encoded)

# list by prefix
GET /api/v1/files?prefix=plugin-printing-shop/
  → [{"key":"plugin-printing-shop/quotes/quote-PLATE-200.pdf",
      "size":1488, "contentType":"application/pdf", ...}]

# plug-in log
[plugin:printing-shop] registered 8 endpoints under /api/v1/plugins/printing-shop/
[plugin:printing-shop] generated quote PDF for plate PLATE-200 (1488 bytes)
                       → plugin-printing-shop/quotes/quote-PLATE-200.pdf
```

Four public surfaces composed in one flow: plug-in JDBC read →
plug-in classloader resource load → host ReportRenderer compile/
fill/export → host FileStorage put → host file controller
download. Every step stays on api.v1; zero plug-in code reaches
into a concrete platform class.

## Printing-shop plug-in — full extension surface exercised

After this commit the reference printing-shop plug-in contributes
via every public seam the framework offers:

| Seam                          | How the plug-in uses it                                |
|-------------------------------|--------------------------------------------------------|
| HTTP endpoints (P1.3)         | 8 endpoints under /api/v1/plugins/printing-shop/       |
| JDBC (P1.4)                   | Reads/writes its own plugin_printingshop__* tables    |
| Liquibase                     | Own changelog.xml, 2 tables created at plug-in start  |
| Metadata YAML (P1.5)          | 2 entities, 5 permissions, 2 menus                    |
| Custom fields on CORE (P3.4)  | 5 plug-in fields on Partner/Item/SalesOrder/WorkOrder |
| i18n (P1.6)                   | Own messages_<locale>.properties, quote number msgs   |
| EventBus (P1.7)               | Publishes WorkOrderRequestedEvent from a TaskHandler  |
| TaskHandlers (P2.1)           | 2 handlers (plate-approval, quote-to-work-order)      |
| Plug-in BPMN (P2.1 followup)  | 2 BPMNs in processes/ auto-deployed at start          |
| JobHandlers (P1.10 followup)  | PlateCleanupJobHandler using context.jdbc + logger    |
| ReportRenderer (P1.8)         | Quote PDF from JRXML via context.reports              |
| FileStorage (P1.9)            | Persists quote PDF via context.files                  |

Everything listed in this table is exercised end-to-end by the
current smoke test. The plug-in is the framework's executable
acceptance test for the entire public extension surface.

## Tests

No new unit tests — the wiring change is a plain constructor
addition, the existing `DefaultPluginContext` has no dedicated
test class (it's a thin dataclass-shaped bean), and
`JasperReportRenderer` + `LocalDiskFileStorage` each have their
own unit tests from the respective parent chunks. The change is
validated end-to-end by the above smoke test; formalizing that
into an integration test would need Testcontainers + a real
plug-in JAR and belongs to a different (test-infra) chunk.

- Total framework unit tests: 337 (unchanged), all green.

## Non-goals (parking lot)

- Pre-compiled `.jasper` caching keyed by template hash. A
  hot-path benchmark would tell us whether the cache is worth
  shipping.
- Multipart upload of a template into a plug-in's own `files`
  namespace so non-bundled templates can be tried without a
  plug-in rebuild. Nice-to-have for iteration but not on the
  v1.0 critical path.
- Scoped file-key prefixes per plug-in enforced by the framework
  (today the plug-in picks its own prefix by convention; a
  `plugin.files.keyPrefix` config would let the host enforce
  that every plug-in-contributed file lives under
  `plugin-<id>/`). Future hardening chunk.
platform/platform-plugins/build.gradle.kts
... ... @@ -30,6 +30,12 @@ dependencies {
30 30 implementation(project(":platform:platform-security")) // for PermissionEvaluator refresh wiring
31 31 implementation(project(":platform:platform-workflow")) // for per-plug-in TaskHandler registration
32 32 implementation(project(":platform:platform-jobs")) // for per-plug-in JobHandler registration
  33 + // Note: platform-files and platform-reports are NOT needed here
  34 + // because DefaultPluginContext accepts api.v1 FileStorage +
  35 + // ReportRenderer interfaces (both in api-v1), and the concrete
  36 + // beans are wired by Spring at distribution boot. Adding them
  37 + // as dependencies would work too but would unnecessarily couple
  38 + // platform-plugins to two more modules.
33 39  
34 40 implementation(libs.spring.boot.starter)
35 41 implementation(libs.spring.boot.starter.web) // for @RestController on the dispatcher
... ...
platform/platform-plugins/src/main/kotlin/org/vibeerp/platform/plugins/DefaultPluginContext.kt
... ... @@ -9,8 +9,10 @@ import org.vibeerp.api.v1.persistence.Transaction
9 9 import org.vibeerp.api.v1.plugin.PluginContext
10 10 import org.vibeerp.api.v1.plugin.PluginEndpointRegistrar
11 11 import org.vibeerp.api.v1.plugin.PluginJdbc
  12 +import org.vibeerp.api.v1.files.FileStorage
12 13 import org.vibeerp.api.v1.jobs.PluginJobHandlerRegistrar
13 14 import org.vibeerp.api.v1.plugin.PluginLogger
  15 +import org.vibeerp.api.v1.reports.ReportRenderer
14 16 import org.vibeerp.api.v1.security.PermissionCheck
15 17 import org.vibeerp.api.v1.workflow.PluginTaskHandlerRegistrar
16 18  
... ... @@ -51,6 +53,8 @@ internal class DefaultPluginContext(
51 53 private val sharedLocaleProvider: LocaleProvider,
52 54 private val scopedTaskHandlers: PluginTaskHandlerRegistrar,
53 55 private val scopedJobHandlers: PluginJobHandlerRegistrar,
  56 + private val sharedFileStorage: FileStorage,
  57 + private val sharedReportRenderer: ReportRenderer,
54 58 ) : PluginContext {
55 59  
56 60 override val logger: PluginLogger = Slf4jPluginLogger(pluginId, delegateLogger)
... ... @@ -119,6 +123,24 @@ internal class DefaultPluginContext(
119 123 */
120 124 override val jobs: PluginJobHandlerRegistrar = scopedJobHandlers
121 125  
  126 + /**
  127 + * Shared host [FileStorage]. Plug-ins persist generated
  128 + * report PDFs, uploaded attachments, exports, and any other
  129 + * binary blobs through this. Wired in P1.9 + the plug-in
  130 + * loader follow-up; backed by `platform-files`'
  131 + * [org.vibeerp.platform.files.LocalDiskFileStorage] by default.
  132 + */
  133 + override val files: FileStorage = sharedFileStorage
  134 +
  135 + /**
  136 + * Shared host [ReportRenderer]. Plug-ins render JRXML
  137 + * templates (loaded from their own classpath, the file store,
  138 + * or a database row) into PDF bytes. Wired alongside
  139 + * [files] in the same chunk so a plug-in can do
  140 + * "render → store → return handle" in one method.
  141 + */
  142 + override val reports: ReportRenderer = sharedReportRenderer
  143 +
122 144 // ─── Not yet implemented ───────────────────────────────────────
123 145  
124 146 override val transaction: Transaction
... ...
platform/platform-plugins/src/main/kotlin/org/vibeerp/platform/plugins/VibeErpPluginManager.kt
... ... @@ -8,8 +8,10 @@ import org.springframework.beans.factory.InitializingBean
8 8 import org.springframework.boot.context.properties.ConfigurationProperties
9 9 import org.springframework.stereotype.Component
10 10 import org.vibeerp.api.v1.event.EventBus
  11 +import org.vibeerp.api.v1.files.FileStorage
11 12 import org.vibeerp.api.v1.i18n.LocaleProvider
12 13 import org.vibeerp.api.v1.plugin.PluginJdbc
  14 +import org.vibeerp.api.v1.reports.ReportRenderer
13 15 import org.vibeerp.platform.i18n.IcuTranslator
14 16 import org.vibeerp.platform.metadata.MetadataLoader
15 17 import org.vibeerp.platform.metadata.customfield.CustomFieldRegistry
... ... @@ -73,6 +75,8 @@ class VibeErpPluginManager(
73 75 private val taskHandlerRegistry: TaskHandlerRegistry,
74 76 private val pluginProcessDeployer: PluginProcessDeployer,
75 77 private val jobHandlerRegistry: JobHandlerRegistry,
  78 + private val fileStorage: FileStorage,
  79 + private val reportRenderer: ReportRenderer,
76 80 ) : DefaultPluginManager(Paths.get(properties.directory)), InitializingBean, DisposableBean {
77 81  
78 82 private val log = LoggerFactory.getLogger(VibeErpPluginManager::class.java)
... ... @@ -285,6 +289,8 @@ class VibeErpPluginManager(
285 289 sharedLocaleProvider = localeProvider,
286 290 scopedTaskHandlers = ScopedTaskHandlerRegistrar(taskHandlerRegistry, pluginId),
287 291 scopedJobHandlers = ScopedJobHandlerRegistrar(jobHandlerRegistry, pluginId),
  292 + sharedFileStorage = fileStorage,
  293 + sharedReportRenderer = reportRenderer,
288 294 )
289 295 try {
290 296 vibeErpPlugin.start(context)
... ...
reference-customer/plugin-printing-shop/src/main/kotlin/org/vibeerp/reference/printingshop/PrintingShopPlugin.kt
... ... @@ -270,7 +270,89 @@ class PrintingShopPlugin(wrapper: PluginWrapper) : Pf4jPlugin(wrapper), VibeErpP
270 270 )
271 271 }
272 272  
273   - context.logger.info("registered 7 endpoints under /api/v1/plugins/printing-shop/")
  273 + // ─── POST /plates/{id}/generate-quote-pdf ─────────────────
  274 + //
  275 + // Renders a quote PDF for a plate by composing every
  276 + // extension seam the framework offers: context.jdbc reads
  277 + // the plate from the plug-in's own table, context.reports
  278 + // compiles + fills a JRXML template loaded from the
  279 + // plug-in's OWN classloader (not the host classpath, so
  280 + // Jasper's classpath auto-deploy doesn't see it), and
  281 + // context.files persists the PDF under a plug-in-scoped
  282 + // key so a future GET /api/v1/files/download?key=... can
  283 + // retrieve it. The response returns the file handle so the
  284 + // caller can download it immediately.
  285 + context.endpoints.register(HttpMethod.POST, "/plates/{id}/generate-quote-pdf") { request ->
  286 + val rawId = request.pathParameters["id"] ?: return@register PluginResponse(400, mapOf("detail" to "missing id"))
  287 + val id = try { UUID.fromString(rawId) } catch (ex: IllegalArgumentException) {
  288 + return@register PluginResponse(400, mapOf("detail" to "id is not a valid UUID"))
  289 + }
  290 + val body = parseJsonObject(request.body.orEmpty())
  291 + val customerName = body["customerName"] as? String ?: "<unknown customer>"
  292 +
  293 + val plate = context.jdbc.queryForObject(
  294 + "SELECT code, name, width_mm, height_mm, status FROM plugin_printingshop__plate WHERE id = :id",
  295 + mapOf("id" to id),
  296 + ) { row ->
  297 + mapOf(
  298 + "code" to row.string("code"),
  299 + "name" to row.string("name"),
  300 + "widthMm" to row.int("width_mm"),
  301 + "heightMm" to row.int("height_mm"),
  302 + "status" to row.string("status"),
  303 + )
  304 + } ?: return@register PluginResponse(404, mapOf("detail" to "plate not found: $id"))
  305 +
  306 + // Load the JRXML from THIS plug-in's classloader, not
  307 + // the host's. PF4J's PluginClassLoader is parent-first
  308 + // for anything not in the plug-in jar, so the plug-in's
  309 + // own resources/reports/quote-template.jrxml is reached
  310 + // via the local classloader; the host's ping-report is
  311 + // invisible to the plug-in, and vice versa.
  312 + val templateStream = this::class.java.classLoader
  313 + .getResourceAsStream("reports/quote-template.jrxml")
  314 + ?: return@register PluginResponse(500, mapOf("detail" to "quote template missing from plug-in classpath"))
  315 +
  316 + val pdfBytes = templateStream.use { stream ->
  317 + context.reports.renderPdf(
  318 + template = stream,
  319 + data = mapOf(
  320 + "plateCode" to (plate["code"] as String),
  321 + "plateName" to (plate["name"] as String),
  322 + "widthMm" to (plate["widthMm"] as Int),
  323 + "heightMm" to (plate["heightMm"] as Int),
  324 + "status" to (plate["status"] as String),
  325 + "customerName" to customerName,
  326 + ),
  327 + )
  328 + }
  329 +
  330 + val fileKey = "plugin-printing-shop/quotes/quote-${plate["code"]}.pdf"
  331 + val handle = context.files.put(
  332 + key = fileKey,
  333 + contentType = "application/pdf",
  334 + content = java.io.ByteArrayInputStream(pdfBytes),
  335 + )
  336 +
  337 + context.logger.info(
  338 + "generated quote PDF for plate ${plate["code"]} (${pdfBytes.size} bytes) → $fileKey",
  339 + )
  340 +
  341 + PluginResponse(
  342 + status = 201,
  343 + body = mapOf(
  344 + "plateId" to id.toString(),
  345 + "plateCode" to plate["code"],
  346 + "customerName" to customerName,
  347 + "fileKey" to handle.key,
  348 + "fileSize" to handle.size,
  349 + "fileContentType" to handle.contentType,
  350 + "downloadUrl" to "/api/v1/files/download?key=${handle.key}",
  351 + ),
  352 + )
  353 + }
  354 +
  355 + context.logger.info("registered 8 endpoints under /api/v1/plugins/printing-shop/")
274 356  
275 357 // ─── Workflow task handlers (P2.1 plug-in-loaded registration) ──
276 358 // The host's VibeErpPluginManager wires context.taskHandlers to a
... ...
reference-customer/plugin-printing-shop/src/main/resources/reports/quote-template.jrxml 0 → 100644
  1 +<?xml version="1.0" encoding="UTF-8"?>
  2 +<!--
  3 + Printing-shop plate quote PDF template.
  4 +
  5 + Lives inside the plug-in JAR under resources/reports/ and is
  6 + loaded by PrintingShopPlugin via the plug-in's own classloader
  7 + (not on the host classpath, so Jasper's default classpath auto-
  8 + deploy doesn't see it — the plug-in loads the bytes manually and
  9 + hands them to context.reports.renderPdf).
  10 +
  11 + Parameters:
  12 + plateCode (String) plate's business code
  13 + plateName (String) human name
  14 + widthMm (Integer) plate width in mm
  15 + heightMm (Integer) plate height in mm
  16 + status (String) current status (DRAFT / APPROVED / ...)
  17 + customerName (String) customer the quote is for (free-form)
  18 +-->
  19 +<jasperReport xmlns="http://jasperreports.sourceforge.net/jasperreports"
  20 + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  21 + xsi:schemaLocation="http://jasperreports.sourceforge.net/jasperreports http://jasperreports.sourceforge.net/xsd/jasperreport.xsd"
  22 + name="printing-shop-quote"
  23 + pageWidth="595"
  24 + pageHeight="842"
  25 + columnWidth="535"
  26 + leftMargin="30"
  27 + rightMargin="30"
  28 + topMargin="40"
  29 + bottomMargin="40">
  30 + <parameter name="plateCode" class="java.lang.String">
  31 + <defaultValueExpression><![CDATA["?"]]></defaultValueExpression>
  32 + </parameter>
  33 + <parameter name="plateName" class="java.lang.String">
  34 + <defaultValueExpression><![CDATA["?"]]></defaultValueExpression>
  35 + </parameter>
  36 + <parameter name="widthMm" class="java.lang.Integer">
  37 + <defaultValueExpression><![CDATA[0]]></defaultValueExpression>
  38 + </parameter>
  39 + <parameter name="heightMm" class="java.lang.Integer">
  40 + <defaultValueExpression><![CDATA[0]]></defaultValueExpression>
  41 + </parameter>
  42 + <parameter name="status" class="java.lang.String">
  43 + <defaultValueExpression><![CDATA["?"]]></defaultValueExpression>
  44 + </parameter>
  45 + <parameter name="customerName" class="java.lang.String">
  46 + <defaultValueExpression><![CDATA["?"]]></defaultValueExpression>
  47 + </parameter>
  48 + <title>
  49 + <band height="80">
  50 + <staticText>
  51 + <reportElement x="0" y="0" width="535" height="36"/>
  52 + <textElement textAlignment="Center">
  53 + <font size="24" isBold="true"/>
  54 + </textElement>
  55 + <text><![CDATA[Printing shop — plate quote]]></text>
  56 + </staticText>
  57 + <textField>
  58 + <reportElement x="0" y="44" width="535" height="20"/>
  59 + <textElement textAlignment="Center">
  60 + <font size="12"/>
  61 + </textElement>
  62 + <textFieldExpression><![CDATA["Prepared for " + $P{customerName}]]></textFieldExpression>
  63 + </textField>
  64 + </band>
  65 + </title>
  66 + <detail>
  67 + <band height="200">
  68 + <staticText>
  69 + <reportElement x="0" y="20" width="200" height="20"/>
  70 + <textElement><font size="12" isBold="true"/></textElement>
  71 + <text><![CDATA[Plate code:]]></text>
  72 + </staticText>
  73 + <textField>
  74 + <reportElement x="210" y="20" width="325" height="20"/>
  75 + <textElement><font size="12"/></textElement>
  76 + <textFieldExpression><![CDATA[$P{plateCode}]]></textFieldExpression>
  77 + </textField>
  78 +
  79 + <staticText>
  80 + <reportElement x="0" y="50" width="200" height="20"/>
  81 + <textElement><font size="12" isBold="true"/></textElement>
  82 + <text><![CDATA[Plate name:]]></text>
  83 + </staticText>
  84 + <textField>
  85 + <reportElement x="210" y="50" width="325" height="20"/>
  86 + <textElement><font size="12"/></textElement>
  87 + <textFieldExpression><![CDATA[$P{plateName}]]></textFieldExpression>
  88 + </textField>
  89 +
  90 + <staticText>
  91 + <reportElement x="0" y="80" width="200" height="20"/>
  92 + <textElement><font size="12" isBold="true"/></textElement>
  93 + <text><![CDATA[Dimensions:]]></text>
  94 + </staticText>
  95 + <textField>
  96 + <reportElement x="210" y="80" width="325" height="20"/>
  97 + <textElement><font size="12"/></textElement>
  98 + <textFieldExpression><![CDATA[$P{widthMm} + " mm × " + $P{heightMm} + " mm"]]></textFieldExpression>
  99 + </textField>
  100 +
  101 + <staticText>
  102 + <reportElement x="0" y="110" width="200" height="20"/>
  103 + <textElement><font size="12" isBold="true"/></textElement>
  104 + <text><![CDATA[Status:]]></text>
  105 + </staticText>
  106 + <textField>
  107 + <reportElement x="210" y="110" width="325" height="20"/>
  108 + <textElement><font size="12"/></textElement>
  109 + <textFieldExpression><![CDATA[$P{status}]]></textFieldExpression>
  110 + </textField>
  111 +
  112 + <staticText>
  113 + <reportElement x="0" y="160" width="535" height="24"/>
  114 + <textElement textAlignment="Center">
  115 + <font size="10" isItalic="true"/>
  116 + </textElement>
  117 + <text><![CDATA[Generated by the vibe_erp printing-shop reference plug-in.]]></text>
  118 + </staticText>
  119 + </band>
  120 + </detail>
  121 +</jasperReport>
... ...