The reference printing-shop plug-in now actually does something:
its main class registers two HTTP endpoints during start(context),
and a real curl to /api/v1/plugins/printing-shop/ping returns the
JSON the plug-in's lambda produced. End-to-end smoke test 10/10
green. This is the chunk that turns vibe_erp from "an ERP app
that has a plug-in folder" into "an ERP framework whose plug-ins
can serve traffic".
What landed:
* api.v1 — additive (binary-compatible per the api.v1 stability rule):
- org.vibeerp.api.v1.plugin.HttpMethod (enum)
- org.vibeerp.api.v1.plugin.PluginRequest (path params, query, body)
- org.vibeerp.api.v1.plugin.PluginResponse (status + body)
- org.vibeerp.api.v1.plugin.PluginEndpointHandler (fun interface)
- org.vibeerp.api.v1.plugin.PluginEndpointRegistrar (per-plugin
scoped, register(method, path, handler))
- PluginContext.endpoints getter with default impl that throws
UnsupportedOperationException so the addition is binary-compatible
with plug-ins compiled against earlier api.v1 builds.
* platform-plugins — three new files:
- PluginEndpointRegistry: process-wide registration storage. Uses
Spring's AntPathMatcher so {var} extracts path variables.
Synchronized mutation. Exact-match fast path before pattern loop.
Rejects duplicate (method, path) per plug-in. unregisterAll(plugin)
on shutdown.
- ScopedPluginEndpointRegistrar: per-plugin wrapper that tags every
register() call with the right plugin id. Plug-ins cannot register
under another plug-in's namespace.
- PluginEndpointDispatcher: single Spring @RestController at
/api/v1/plugins/{pluginId}/** that catches GET/POST/PUT/PATCH/DELETE,
asks the registry for a match, builds a PluginRequest, calls the
handler, serializes the response. 404 on no match, 500 on handler
throw (logged with stack trace).
- DefaultPluginContext: implements PluginContext with a real
SLF4J-backed logger (every line tagged with the plug-in id) and
the scoped endpoint registrar. The other six services
(eventBus, transaction, translator, localeProvider,
permissionCheck, entityRegistry) throw UnsupportedOperationException
with messages pointing at the implementation plan unit that will
land each one. Loud failure beats silent no-op.
* VibeErpPluginManager — after PF4J's startPlugins() now walks every
loaded plug-in, casts the wrapper instance to api.v1.plugin.Plugin,
and calls start(context) with a freshly-built DefaultPluginContext.
Tracks the started set so destroy() can call stop() and
unregisterAll() in reverse order. Catches plug-in start failures
loudly without bringing the framework down.
* Reference plug-in (PrintingShopPlugin):
- Now extends BOTH org.pf4j.Plugin (so PF4J's loader can instantiate
it via the Plugin-Class manifest entry) AND
org.vibeerp.api.v1.plugin.Plugin (so the host's vibe_erp lifecycle
hook can call start(context)). Uses Kotlin import aliases to
disambiguate the two `Plugin` simple names.
- In start(context), registers two endpoints:
GET /ping — returns {plugin, version, ok, message}
GET /echo/{name} — extracts path variable, echoes it back
- The /echo handler proves path-variable extraction works end-to-end.
* Build infrastructure:
- reference-customer/plugin-printing-shop now has an `installToDev`
Gradle task that builds the JAR and stages it into <repo>/plugins-dev/.
The task wipes any previous staged copies first so renaming the JAR
on a version bump doesn't leave PF4J trying to load two versions.
- distribution's `bootRun` task now (a) depends on `installToDev`
so the staging happens automatically and (b) sets workingDir to
the repo root so application-dev.yaml's relative
`vibeerp.plugins.directory: ./plugins-dev` resolves to the right
place. Without (b) bootRun's CWD was distribution/ and PF4J found
"No plugins" — which is exactly the bug that surfaced in the first
smoke run.
- .gitignore now excludes /plugins-dev/ and /files-dev/.
Tests: 12 new unit tests for PluginEndpointRegistry covering literal
paths, single/multi path variables, duplicate registration rejection,
literal-vs-pattern precedence, cross-plug-in isolation, method
matching, and unregisterAll. Total now 61 unit tests across the
framework, all green.
End-to-end smoke test against fresh Postgres + the plug-in JAR
loaded by PF4J at boot (10/10 passing):
GET /api/v1/plugins/printing-shop/ping (no auth) → 401
POST /api/v1/auth/login → access token
GET /api/v1/plugins/printing-shop/ping (Bearer) → 200
{plugin, version, ok, message}
GET /api/v1/plugins/printing-shop/echo/hello → 200, echoed=hello
GET /api/v1/plugins/printing-shop/echo/world → 200, echoed=world
GET /api/v1/plugins/printing-shop/nonexistent → 404 (no handler)
GET /api/v1/plugins/missing-plugin/ping → 404 (no plugin)
POST /api/v1/plugins/printing-shop/ping → 404 (wrong method)
GET /api/v1/catalog/uoms (Bearer) → 200, 15 UoMs
GET /api/v1/identity/users (Bearer) → 200, 1 user
PF4J resolved the JAR, started the plug-in, the host called
vibe_erp's start(context), the plug-in registered two endpoints, and
the dispatcher routed real HTTP traffic to the plug-in's lambdas.
The boot log shows the full chain.
What is explicitly NOT in this chunk and remains for later:
• plug-in linter (P1.2) — bytecode scan for forbidden imports
• plug-in Liquibase application (P1.4) — plug-in-owned schemas
• per-plug-in Spring child context — currently we just instantiate
the plug-in via PF4J's classloader; there is no Spring context
for the plug-in's own beans
• PluginContext.eventBus / transaction / translator / etc. — they
still throw UnsupportedOperationException with TODO messages
• Path-template precedence between multiple competing patterns
(only literal-beats-pattern is implemented, not most-specific-pattern)
• Permission checks at the dispatcher (Spring Security still
catches plug-in endpoints with the global "anyRequest authenticated"
rule, which is the right v0.5 behavior)
• Hot reload of plug-ins (cold restart only)
Bug encountered and fixed during the smoke test:
• application-dev.yaml has `vibeerp.plugins.directory: ./plugins-dev`,
a relative path. Gradle's `bootRun` task by default uses the
subproject's directory as the working directory, so the relative
path resolved to <repo>/distribution/plugins-dev/ instead of
<repo>/plugins-dev/. PF4J reported "No plugins" because that
directory was empty. Fixed by setting bootRun.workingDir =
rootProject.layout.projectDirectory.asFile.
• One KDoc comment in PluginEndpointDispatcher contained the literal
string `/api/v1/plugins/{pluginId}/**` inside backticks. The
Kotlin lexer doesn't treat backticks as comment-suppressing, so
`/**` opened a nested KDoc comment that was never closed and the
file failed to compile. Same root cause as the AuthController bug
earlier in the session. Rewrote the line to avoid the literal
`/**` sequence.