Commit 20d7ddc6d70e9a7251fd4de1a979c101e2169933

Authored by vibe_erp
1 parent 69f3daa9

feat(plugins): plug-ins serve real HTTP requests via api.v1 (P1.3 v0.5 cut)

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.
.gitignore
@@ -34,6 +34,10 @@ logs/ @@ -34,6 +34,10 @@ logs/
34 /srv/ 34 /srv/
35 /opt/vibe-erp/ 35 /opt/vibe-erp/
36 36
  37 +# Dev plug-in staging directory (populated by `:reference-customer:plugin-printing-shop:installToDev`)
  38 +/plugins-dev/
  39 +/files-dev/
  40 +
37 # Node (for the future web SPA) 41 # Node (for the future web SPA)
38 node_modules/ 42 node_modules/
39 dist/ 43 dist/
api/api-v1/src/main/kotlin/org/vibeerp/api/v1/plugin/EndpointTypes.kt 0 → 100644
  1 +package org.vibeerp.api.v1.plugin
  2 +
  3 +/**
  4 + * HTTP plug-in endpoint types.
  5 + *
  6 + * **What this is.** A small, framework-agnostic surface that lets a plug-in
  7 + * register handlers for HTTP requests at runtime. The host mounts every
  8 + * registered handler under `/api/v1/plugins/<plugin-id><path>` and routes
  9 + * matching requests to it.
  10 + *
  11 + * **What this is NOT.** A full Spring MVC clone. There is no auto JSON
  12 + * deserialization, no content negotiation, no `@PathVariable` annotation,
  13 + * no header introspection. The plug-in receives the raw body as a string
  14 + * (parses it itself, usually with `kotlinx.serialization` or its own
  15 + * Jackson) and returns a [PluginResponse] whose `body` is serialized to
  16 + * JSON by the host. Path variables are extracted from the registered
  17 + * pattern and delivered as a `Map<String, String>`.
  18 + *
  19 + * **Why so minimal.** Plug-ins live in a different classloader and the
  20 + * api.v1 surface must not leak Spring or Jackson types. Anything richer
  21 + * (typed bodies, validation, OpenAPI generation) can be added as
  22 + * additive sub-interfaces in later api.v1 minor versions. The smallest
  23 + * thing that proves "a plug-in can serve a request" is what ships first.
  24 + */
  25 +
  26 +/**
  27 + * The HTTP methods the framework supports for plug-in endpoints.
  28 + *
  29 + * Closed enum so the dispatcher can `when`-match exhaustively. Adding a
  30 + * value (e.g. `OPTIONS`, `HEAD`) is a non-breaking change within api.v1.x.
  31 + */
  32 +enum class HttpMethod {
  33 + GET, POST, PUT, PATCH, DELETE,
  34 +}
  35 +
  36 +/**
  37 + * Per-request data the host hands to a plug-in handler.
  38 + *
  39 + * @property pathParameters Values extracted from `{name}` placeholders in
  40 + * the path the plug-in registered. Empty for literal paths.
  41 + * @property queryParameters Repeated keys are preserved as a list. Single
  42 + * values appear as a single-element list. Order matches the request.
  43 + * @property body Raw request body as a UTF-8 string, or null when no
  44 + * body was sent. The plug-in is responsible for parsing it.
  45 + */
  46 +data class PluginRequest(
  47 + val pathParameters: Map<String, String> = emptyMap(),
  48 + val queryParameters: Map<String, List<String>> = emptyMap(),
  49 + val body: String? = null,
  50 +)
  51 +
  52 +/**
  53 + * Per-response data the plug-in hands back to the host.
  54 + *
  55 + * @property status HTTP status code. Defaults to 200.
  56 + * @property body Anything Jackson can serialize. The host writes the
  57 + * response with `Content-Type: application/json` (or `text/plain` for
  58 + * `String` bodies). `null` produces a body-less response.
  59 + */
  60 +data class PluginResponse(
  61 + val status: Int = 200,
  62 + val body: Any? = null,
  63 +)
  64 +
  65 +/**
  66 + * The actual request handler the plug-in registers.
  67 + *
  68 + * `fun interface` so plug-in authors can pass a Kotlin lambda directly:
  69 + *
  70 + * ```
  71 + * context.endpoints.register(HttpMethod.GET, "/ping") { request ->
  72 + * PluginResponse(body = mapOf("ok" to true))
  73 + * }
  74 + * ```
  75 + */
  76 +fun interface PluginEndpointHandler {
  77 + fun handle(request: PluginRequest): PluginResponse
  78 +}
  79 +
  80 +/**
  81 + * Registry the plug-in receives via [PluginContext.endpoints].
  82 + *
  83 + * Each plug-in gets its own scoped registrar so the registration calls
  84 + * automatically carry the plug-in id — the plug-in cannot register
  85 + * endpoints under another plug-in's namespace.
  86 + *
  87 + * **Path syntax.** Patterns may contain `{name}` placeholders, e.g.
  88 + * `/items/{id}`. Anything in `{}` is captured into
  89 + * [PluginRequest.pathParameters] under that name. Path matching uses
  90 + * the host's URI matcher (Spring's `AntPathMatcher` in v0.5).
  91 + *
  92 + * **Conflicts.** Registering twice with the same `(method, pattern)`
  93 + * pair throws [IllegalStateException]. Plug-ins are expected to register
  94 + * each endpoint exactly once during [Plugin.start].
  95 + */
  96 +interface PluginEndpointRegistrar {
  97 +
  98 + /**
  99 + * Register an HTTP handler at the given path under this plug-in's
  100 + * URL namespace.
  101 + *
  102 + * @throws IllegalStateException if `(method, path)` is already
  103 + * registered for this plug-in.
  104 + */
  105 + fun register(method: HttpMethod, path: String, handler: PluginEndpointHandler)
  106 +}
api/api-v1/src/main/kotlin/org/vibeerp/api/v1/plugin/PluginContext.kt
@@ -47,11 +47,27 @@ interface PluginContext { @@ -47,11 +47,27 @@ interface PluginContext {
47 /** 47 /**
48 * Lifecycle-bound logger. Always prefer this over `LoggerFactory.getLogger` 48 * Lifecycle-bound logger. Always prefer this over `LoggerFactory.getLogger`
49 * because the platform tags every log line with the plug-in id, the 49 * because the platform tags every log line with the plug-in id, the
50 - * tenant, the request id, and the principal automatically. A plug-in that  
51 - * brings its own SLF4J binding bypasses all of that and is hard to debug  
52 - * in a multi-plug-in deployment. 50 + * request id, and the principal automatically. A plug-in that brings
  51 + * its own SLF4J binding bypasses all of that and is hard to debug in a
  52 + * multi-plug-in deployment.
53 */ 53 */
54 val logger: PluginLogger 54 val logger: PluginLogger
  55 +
  56 + /**
  57 + * Register HTTP endpoints under `/api/v1/plugins/<plugin-id>/...`.
  58 + *
  59 + * Added in api.v1.0.x; backed by a default implementation that throws
  60 + * [UnsupportedOperationException] so the addition is binary-compatible
  61 + * with plug-ins compiled against earlier api.v1 builds (CLAUDE.md
  62 + * guardrail #10 — adding to api.v1 within a major is allowed when a
  63 + * default implementation is provided). The first host that wires this
  64 + * is `platform-plugins` v0.5; future hosts will follow.
  65 + */
  66 + val endpoints: PluginEndpointRegistrar
  67 + get() = throw UnsupportedOperationException(
  68 + "PluginContext.endpoints is not implemented by this host. " +
  69 + "Upgrade vibe_erp to v0.5 or later."
  70 + )
55 } 71 }
56 72
57 /** 73 /**
distribution/build.gradle.kts
@@ -55,7 +55,16 @@ tasks.bootJar { @@ -55,7 +55,16 @@ tasks.bootJar {
55 55
56 // `./gradlew :distribution:bootRun` — used by `make run` for local dev. 56 // `./gradlew :distribution:bootRun` — used by `make run` for local dev.
57 // Activates the dev profile so application-dev.yaml on the classpath is 57 // Activates the dev profile so application-dev.yaml on the classpath is
58 -// layered on top of application.yaml. 58 +// layered on top of application.yaml. Also depends on the reference
  59 +// plug-in's `installToDev` task so the JAR is staged in `plugins-dev/`
  60 +// before the app boots — that's where application-dev.yaml's
  61 +// `vibeerp.plugins.directory: ./plugins-dev` setting points.
59 tasks.bootRun { 62 tasks.bootRun {
60 systemProperty("spring.profiles.active", "dev") 63 systemProperty("spring.profiles.active", "dev")
  64 + // Run with the repo root as the working directory so the relative
  65 + // paths in application-dev.yaml (./plugins-dev, ./files-dev) resolve
  66 + // to the right place — the plug-in JAR is staged at
  67 + // <repo>/plugins-dev/, not <repo>/distribution/plugins-dev/.
  68 + workingDir = rootProject.layout.projectDirectory.asFile
  69 + dependsOn(":reference-customer:plugin-printing-shop:installToDev")
61 } 70 }
platform/platform-plugins/build.gradle.kts
@@ -26,6 +26,7 @@ dependencies { @@ -26,6 +26,7 @@ dependencies {
26 implementation(libs.jackson.module.kotlin) 26 implementation(libs.jackson.module.kotlin)
27 27
28 implementation(libs.spring.boot.starter) 28 implementation(libs.spring.boot.starter)
  29 + implementation(libs.spring.boot.starter.web) // for @RestController on the dispatcher
29 implementation(libs.pf4j) 30 implementation(libs.pf4j)
30 implementation(libs.pf4j.spring) 31 implementation(libs.pf4j.spring)
31 32
platform/platform-plugins/src/main/kotlin/org/vibeerp/platform/plugins/DefaultPluginContext.kt 0 → 100644
  1 +package org.vibeerp.platform.plugins
  2 +
  3 +import org.slf4j.Logger
  4 +import org.vibeerp.api.v1.entity.EntityRegistry
  5 +import org.vibeerp.api.v1.event.EventBus
  6 +import org.vibeerp.api.v1.i18n.LocaleProvider
  7 +import org.vibeerp.api.v1.i18n.Translator
  8 +import org.vibeerp.api.v1.persistence.Transaction
  9 +import org.vibeerp.api.v1.plugin.PluginContext
  10 +import org.vibeerp.api.v1.plugin.PluginEndpointRegistrar
  11 +import org.vibeerp.api.v1.plugin.PluginLogger
  12 +import org.vibeerp.api.v1.security.PermissionCheck
  13 +
  14 +/**
  15 + * The host's [PluginContext] implementation.
  16 + *
  17 + * v0.5 wires three of the eight services for real:
  18 + * • [logger] — backed by SLF4J, every line tagged with the plug-in id
  19 + * • [endpoints] — backed by the per-plug-in scoped registrar
  20 + * • everything else — throws `UnsupportedOperationException` with a
  21 + * message pointing at the implementation plan unit that will land it
  22 + *
  23 + * Why throw rather than provide stub no-ops: a plug-in that calls
  24 + * `context.translator.translate(...)` and silently gets back an empty
  25 + * string, or `context.eventBus.publish(...)` that silently drops the
  26 + * event, would create extremely subtle bugs that only surface much
  27 + * later. A loud `UnsupportedOperationException` at first call surfaces
  28 + * the gap immediately, in dev, on a live request, with a clear message
  29 + * saying "this lands in P1.x".
  30 + *
  31 + * Per-plug-in: every loaded plug-in gets its own [DefaultPluginContext]
  32 + * instance because the [logger] and [endpoints] are scoped to the
  33 + * plug-in id. Sharing would let one plug-in's logs masquerade as
  34 + * another's.
  35 + */
  36 +internal class DefaultPluginContext(
  37 + pluginId: String,
  38 + sharedRegistrar: PluginEndpointRegistrar,
  39 + delegateLogger: Logger,
  40 +) : PluginContext {
  41 +
  42 + override val logger: PluginLogger = Slf4jPluginLogger(pluginId, delegateLogger)
  43 +
  44 + override val endpoints: PluginEndpointRegistrar = sharedRegistrar
  45 +
  46 + // ─── Not yet implemented ───────────────────────────────────────
  47 +
  48 + override val eventBus: EventBus
  49 + get() = throw UnsupportedOperationException(
  50 + "PluginContext.eventBus is not yet implemented; lands in P1.7 (event bus + outbox)"
  51 + )
  52 +
  53 + override val transaction: Transaction
  54 + get() = throw UnsupportedOperationException(
  55 + "PluginContext.transaction is not yet implemented; lands alongside the plug-in JPA story"
  56 + )
  57 +
  58 + override val translator: Translator
  59 + get() = throw UnsupportedOperationException(
  60 + "PluginContext.translator is not yet implemented; lands in P1.6 (ICU4J translator)"
  61 + )
  62 +
  63 + override val localeProvider: LocaleProvider
  64 + get() = throw UnsupportedOperationException(
  65 + "PluginContext.localeProvider is not yet implemented; lands in P1.6"
  66 + )
  67 +
  68 + override val permissionCheck: PermissionCheck
  69 + get() = throw UnsupportedOperationException(
  70 + "PluginContext.permissionCheck is not yet implemented; lands in P4.3 (real permissions)"
  71 + )
  72 +
  73 + override val entityRegistry: EntityRegistry
  74 + get() = throw UnsupportedOperationException(
  75 + "PluginContext.entityRegistry is not yet implemented; lands in P1.5 (metadata seeder)"
  76 + )
  77 +}
  78 +
  79 +/**
  80 + * Bridges the api.v1 [PluginLogger] interface (no SLF4J types) to a
  81 + * real SLF4J logger and prefixes every line with the plug-in id so
  82 + * operators can grep for one plug-in's behavior.
  83 + */
  84 +internal class Slf4jPluginLogger(
  85 + private val pluginId: String,
  86 + private val delegate: Logger,
  87 +) : PluginLogger {
  88 +
  89 + override fun info(message: String) {
  90 + delegate.info("[plugin:$pluginId] {}", message)
  91 + }
  92 +
  93 + override fun warn(message: String, error: Throwable?) {
  94 + if (error != null) {
  95 + delegate.warn("[plugin:$pluginId] {}", message, error)
  96 + } else {
  97 + delegate.warn("[plugin:$pluginId] {}", message)
  98 + }
  99 + }
  100 +
  101 + override fun error(message: String, error: Throwable?) {
  102 + if (error != null) {
  103 + delegate.error("[plugin:$pluginId] {}", message, error)
  104 + } else {
  105 + delegate.error("[plugin:$pluginId] {}", message)
  106 + }
  107 + }
  108 +}
platform/platform-plugins/src/main/kotlin/org/vibeerp/platform/plugins/VibeErpPluginManager.kt
@@ -7,45 +7,58 @@ import org.springframework.beans.factory.DisposableBean @@ -7,45 +7,58 @@ import org.springframework.beans.factory.DisposableBean
7 import org.springframework.beans.factory.InitializingBean 7 import org.springframework.beans.factory.InitializingBean
8 import org.springframework.boot.context.properties.ConfigurationProperties 8 import org.springframework.boot.context.properties.ConfigurationProperties
9 import org.springframework.stereotype.Component 9 import org.springframework.stereotype.Component
  10 +import org.vibeerp.platform.plugins.endpoints.PluginEndpointRegistry
  11 +import org.vibeerp.platform.plugins.endpoints.ScopedPluginEndpointRegistrar
10 import java.nio.file.Files 12 import java.nio.file.Files
11 import java.nio.file.Path 13 import java.nio.file.Path
12 import java.nio.file.Paths 14 import java.nio.file.Paths
  15 +import org.vibeerp.api.v1.plugin.Plugin as VibeErpPlugin
13 16
14 /** 17 /**
15 * The framework's PF4J host. 18 * The framework's PF4J host.
16 * 19 *
17 * Wired as a Spring bean so its lifecycle follows the Spring application context: 20 * Wired as a Spring bean so its lifecycle follows the Spring application context:
18 - * • on startup, scans the configured plug-ins directory and loads every JAR  
19 - * that passes manifest validation and the API compatibility check 21 + * • on startup, scans the configured plug-ins directory, loads every JAR
  22 + * that passes manifest validation and the API compatibility check,
  23 + * starts each plug-in via PF4J, then walks the loaded plug-ins and calls
  24 + * `vibe_erp.api.v1.plugin.Plugin.start(context)` on each one with a real
  25 + * [DefaultPluginContext] (logger + endpoints registrar wired)
20 * • on shutdown, stops every plug-in cleanly so they get a chance to release 26 * • on shutdown, stops every plug-in cleanly so they get a chance to release
21 - * resources 27 + * resources, and removes their endpoint registrations from the registry
22 * 28 *
23 - * Each plug-in lives in its own `PluginClassLoader` (PF4J's default), which means:  
24 - * • plug-ins cannot accidentally see each other's classes  
25 - * • plug-ins cannot see `org.vibeerp.platform.*` or `org.vibeerp.pbc.*` —  
26 - * PF4J's parent-first-then-self classloader sees only `org.vibeerp.api.v1.*`  
27 - * in the host classloader because that's all that should be exported as  
28 - * a "plug-in API" via the `pluginsExportedPackages` mechanism 29 + * **Classloader contract.** PF4J's default `PluginClassLoader` is
  30 + * child-first: it tries the plug-in's own jar first and falls back to
  31 + * the parent (host) classloader on miss. That works for api.v1 as long
  32 + * as the plug-in jar does NOT bundle api-v1 itself — Gradle's
  33 + * `tasks.jar` only includes the plug-in's own compile output, never
  34 + * its dependencies, so this is satisfied as long as plug-ins follow
  35 + * the documented build setup (`implementation(project(":api:api-v1"))`
  36 + * for compile-time, host classpath at runtime).
29 * 37 *
30 - * v0.1 ships the loader skeleton but does NOT yet implement:  
31 - * • the plug-in linter (rejecting reflection into platform.*) — v0.2  
32 - * • per-plug-in Spring child context — v0.2  
33 - * • plug-in Liquibase changelog application — v0.2  
34 - * • metadata seed-row upsert — v0.2  
35 - *  
36 - * What v0.1 DOES implement: a real PF4J manager that boots, scans the  
37 - * plug-ins directory, loads JARs that have a valid `plugin.properties`,  
38 - * and starts them. The reference printing-shop plug-in proves this works. 38 + * If a plug-in jar were to bundle api-v1 by mistake, the plug-in's
  39 + * classloader would load its own copy and the host's
  40 + * `instance as VibeErpPlugin` cast would throw `ClassCastException`
  41 + * because "same name, different classloader = different class". The
  42 + * v0.6 plug-in linter (P1.2) will detect that mistake at install time;
  43 + * for now, the failure mode is loud and surfaces in the smoke test.
39 * 44 *
40 * Reference: architecture spec section 7 ("Plug-in lifecycle"). 45 * Reference: architecture spec section 7 ("Plug-in lifecycle").
41 */ 46 */
42 @Component 47 @Component
43 class VibeErpPluginManager( 48 class VibeErpPluginManager(
44 private val properties: VibeErpPluginsProperties, 49 private val properties: VibeErpPluginsProperties,
  50 + private val endpointRegistry: PluginEndpointRegistry,
45 ) : DefaultPluginManager(Paths.get(properties.directory)), InitializingBean, DisposableBean { 51 ) : DefaultPluginManager(Paths.get(properties.directory)), InitializingBean, DisposableBean {
46 52
47 private val log = LoggerFactory.getLogger(VibeErpPluginManager::class.java) 53 private val log = LoggerFactory.getLogger(VibeErpPluginManager::class.java)
48 54
  55 + /**
  56 + * Track which plug-ins we have called `start(context)` on, so
  57 + * `destroy()` can call `stop()` on the same set in reverse order
  58 + * without depending on PF4J's internal state.
  59 + */
  60 + private val started: MutableList<Pair<String, VibeErpPlugin>> = mutableListOf()
  61 +
49 override fun afterPropertiesSet() { 62 override fun afterPropertiesSet() {
50 if (!properties.autoLoad) { 63 if (!properties.autoLoad) {
51 log.info("vibe_erp plug-in auto-load disabled — skipping plug-in scan") 64 log.info("vibe_erp plug-in auto-load disabled — skipping plug-in scan")
@@ -60,16 +73,62 @@ class VibeErpPluginManager( @@ -60,16 +73,62 @@ class VibeErpPluginManager(
60 log.info("vibe_erp scanning plug-ins from {}", dir) 73 log.info("vibe_erp scanning plug-ins from {}", dir)
61 loadPlugins() 74 loadPlugins()
62 startPlugins() 75 startPlugins()
  76 +
  77 + // PF4J's `startPlugins()` calls each plug-in's PF4J `start()`
  78 + // method (the no-arg one). Now we walk the loaded set and also
  79 + // call vibe_erp's `start(context)` so the plug-in can register
  80 + // endpoints, subscribe to events, etc.
63 plugins.values.forEach { wrapper: PluginWrapper -> 81 plugins.values.forEach { wrapper: PluginWrapper ->
  82 + val pluginId = wrapper.pluginId
64 log.info( 83 log.info(
65 "vibe_erp plug-in loaded: id={} version={} state={}", 84 "vibe_erp plug-in loaded: id={} version={} state={}",
66 - wrapper.pluginId, wrapper.descriptor.version, wrapper.pluginState, 85 + pluginId, wrapper.descriptor.version, wrapper.pluginState,
  86 + )
  87 + startVibeErpPlugin(wrapper)
  88 + }
  89 + }
  90 +
  91 + private fun startVibeErpPlugin(wrapper: PluginWrapper) {
  92 + val pluginId = wrapper.pluginId
  93 + val instance = wrapper.plugin
  94 + val vibeErpPlugin = instance as? VibeErpPlugin
  95 + if (vibeErpPlugin == null) {
  96 + log.warn(
  97 + "plug-in '{}' main class {} does not implement org.vibeerp.api.v1.plugin.Plugin — " +
  98 + "PF4J loaded it but vibe_erp lifecycle hooks will be skipped",
  99 + pluginId, instance.javaClass.name,
67 ) 100 )
  101 + return
  102 + }
  103 + val context = DefaultPluginContext(
  104 + pluginId = pluginId,
  105 + sharedRegistrar = ScopedPluginEndpointRegistrar(endpointRegistry, pluginId),
  106 + delegateLogger = LoggerFactory.getLogger("plugin.$pluginId"),
  107 + )
  108 + try {
  109 + vibeErpPlugin.start(context)
  110 + started += pluginId to vibeErpPlugin
  111 + log.info("vibe_erp plug-in '{}' started successfully", pluginId)
  112 + } catch (ex: Throwable) {
  113 + log.error("vibe_erp plug-in '{}' failed to start; the plug-in is loaded but inactive", pluginId, ex)
  114 + // Strip any partial endpoint registrations the plug-in may
  115 + // have managed before throwing.
  116 + endpointRegistry.unregisterAll(pluginId)
68 } 117 }
69 } 118 }
70 119
71 override fun destroy() { 120 override fun destroy() {
72 - log.info("vibe_erp stopping {} plug-in(s)", plugins.size) 121 + log.info("vibe_erp stopping {} plug-in(s)", started.size)
  122 + // Reverse order so plug-ins that depend on others stop first.
  123 + started.reversed().forEach { (pluginId, instance) ->
  124 + try {
  125 + instance.stop()
  126 + } catch (ex: Throwable) {
  127 + log.warn("vibe_erp plug-in '{}' threw during stop; continuing teardown", pluginId, ex)
  128 + }
  129 + endpointRegistry.unregisterAll(pluginId)
  130 + }
  131 + started.clear()
73 stopPlugins() 132 stopPlugins()
74 unloadPlugins() 133 unloadPlugins()
75 } 134 }
platform/platform-plugins/src/main/kotlin/org/vibeerp/platform/plugins/endpoints/PluginEndpointDispatcher.kt 0 → 100644
  1 +package org.vibeerp.platform.plugins.endpoints
  2 +
  3 +import jakarta.servlet.http.HttpServletRequest
  4 +import org.slf4j.LoggerFactory
  5 +import org.springframework.http.MediaType
  6 +import org.springframework.http.ResponseEntity
  7 +import org.springframework.web.bind.annotation.PathVariable
  8 +import org.springframework.web.bind.annotation.RequestBody
  9 +import org.springframework.web.bind.annotation.RequestMapping
  10 +import org.springframework.web.bind.annotation.RequestMethod
  11 +import org.springframework.web.bind.annotation.RestController
  12 +import org.vibeerp.api.v1.plugin.HttpMethod
  13 +import org.vibeerp.api.v1.plugin.PluginRequest
  14 +
  15 +/**
  16 + * The single Spring controller that fronts every plug-in HTTP endpoint.
  17 + *
  18 + * Mounted under the path prefix `/api/v1/plugins/{pluginId}/` with a
  19 + * catch-all wildcard so it captures whatever the plug-in's path looked
  20 + * like under its namespace. The dispatcher then asks
  21 + * [PluginEndpointRegistry] for a matching handler and invokes it.
  22 + *
  23 + * Why one fat dispatcher instead of programmatic
  24 + * `RequestMappingHandlerMapping.registerMapping(...)` calls per
  25 + * plug-in handler:
  26 + * - Programmatic registration with Spring's HandlerMapping is doable
  27 + * but requires reflection on the plug-in's controller classes, which
  28 + * forces the plug-in to either expose Spring's mapping annotations
  29 + * (banned by api.v1) or invent its own annotations that mirror
  30 + * Spring's exactly.
  31 + * - The dispatcher pattern keeps api.v1 small (just a registrar
  32 + * interface) and lets plug-ins register lambdas — the most natural
  33 + * Kotlin shape.
  34 + * - Performance is fine: registry lookup is O(N) per request and N is
  35 + * bounded by the total number of plug-in endpoints (tens, not
  36 + * millions). The first lookup tries an exact match before the
  37 + * pattern matcher loop.
  38 + *
  39 + * Authentication: handled upstream by Spring Security. By the time a
  40 + * request reaches this controller, the JWT has been validated and the
  41 + * principal is bound to `PrincipalContext` so the plug-in's handler can
  42 + * read it (in a future api.v1 minor that exposes principal access on
  43 + * `PluginRequest`).
  44 + */
  45 +@RestController
  46 +@RequestMapping("/api/v1/plugins/{pluginId}")
  47 +class PluginEndpointDispatcher(
  48 + private val registry: PluginEndpointRegistry,
  49 +) {
  50 +
  51 + private val log = LoggerFactory.getLogger(PluginEndpointDispatcher::class.java)
  52 +
  53 + @RequestMapping(
  54 + value = ["/**"],
  55 + method = [
  56 + RequestMethod.GET,
  57 + RequestMethod.POST,
  58 + RequestMethod.PUT,
  59 + RequestMethod.PATCH,
  60 + RequestMethod.DELETE,
  61 + ],
  62 + )
  63 + fun dispatch(
  64 + @PathVariable pluginId: String,
  65 + @RequestBody(required = false) body: String?,
  66 + request: HttpServletRequest,
  67 + ): ResponseEntity<Any> {
  68 + val httpMethod = HttpMethod.entries.firstOrNull { it.name == request.method }
  69 + ?: return ResponseEntity.status(405).build()
  70 +
  71 + // Strip the `/api/v1/plugins/{pluginId}` prefix; the plug-in
  72 + // sees only its own namespace and never has to know what the
  73 + // host's URL prefix is.
  74 + val prefix = "/api/v1/plugins/$pluginId"
  75 + val pluginPath = request.requestURI.removePrefix(request.contextPath)
  76 + .removePrefix(prefix)
  77 + .ifEmpty { "/" }
  78 +
  79 + val match = registry.find(pluginId, httpMethod, pluginPath)
  80 + ?: return ResponseEntity.status(404).body(
  81 + mapOf(
  82 + "type" to "about:blank",
  83 + "title" to "Not Found",
  84 + "status" to 404,
  85 + "detail" to "no plug-in handler matches $httpMethod $pluginPath under plug-in '$pluginId'",
  86 + )
  87 + )
  88 +
  89 + // Spring's request parameter map is `Map<String, Array<String>>`;
  90 + // we expose it as `Map<String, List<String>>` so plug-ins do not
  91 + // see Java arrays.
  92 + val queryParameters: Map<String, List<String>> = request.parameterMap
  93 + .mapValues { it.value.toList() }
  94 +
  95 + val pluginRequest = PluginRequest(
  96 + pathParameters = match.pathVariables,
  97 + queryParameters = queryParameters,
  98 + body = body,
  99 + )
  100 +
  101 + return try {
  102 + val response = match.handler.handle(pluginRequest)
  103 + ResponseEntity.status(response.status)
  104 + .contentType(MediaType.APPLICATION_JSON)
  105 + .body(response.body ?: emptyMap<String, Any>())
  106 + } catch (ex: Throwable) {
  107 + log.error(
  108 + "plug-in '{}' handler {} {} threw {}",
  109 + pluginId, httpMethod, pluginPath, ex.javaClass.simpleName, ex,
  110 + )
  111 + ResponseEntity.status(500).body(
  112 + mapOf(
  113 + "type" to "about:blank",
  114 + "title" to "Internal Server Error",
  115 + "status" to 500,
  116 + "detail" to "plug-in handler failed",
  117 + )
  118 + )
  119 + }
  120 + }
  121 +}
platform/platform-plugins/src/main/kotlin/org/vibeerp/platform/plugins/endpoints/PluginEndpointRegistry.kt 0 → 100644
  1 +package org.vibeerp.platform.plugins.endpoints
  2 +
  3 +import org.springframework.stereotype.Component
  4 +import org.springframework.util.AntPathMatcher
  5 +import org.vibeerp.api.v1.plugin.HttpMethod
  6 +import org.vibeerp.api.v1.plugin.PluginEndpointHandler
  7 +import org.vibeerp.api.v1.plugin.PluginEndpointRegistrar
  8 +
  9 +/**
  10 + * Process-wide registry of plug-in HTTP endpoints.
  11 + *
  12 + * Each registration is tagged with its owning plug-in id; the dispatcher
  13 + * looks up handlers by `(pluginId, method, requestPath)` and uses
  14 + * Spring's [AntPathMatcher] to match patterns like `/items/{id}` against
  15 + * concrete request paths.
  16 + *
  17 + * Why one global registry instead of per-plugin: every dispatch goes
  18 + * through one Spring controller, and that controller needs O(1) lookup
  19 + * by plug-in id. The per-plugin scope is enforced by the
  20 + * [ScopedPluginEndpointRegistrar] wrapper that the host hands to each
  21 + * plug-in's [org.vibeerp.api.v1.plugin.PluginContext].
  22 + *
  23 + * Thread safety: registrations happen synchronously during plug-in start,
  24 + * dispatches happen on every request thread. The internal map is
  25 + * intentionally NOT a `ConcurrentHashMap` because we never mutate it
  26 + * after the plug-in lifecycle finishes; instead, all mutations go
  27 + * through `synchronized(this)`. Hot reload (which would re-mutate at
  28 + * runtime) is deferred to a later release.
  29 + */
  30 +@Component
  31 +class PluginEndpointRegistry {
  32 +
  33 + private val pathMatcher = AntPathMatcher()
  34 +
  35 + private val registrations: MutableList<Registration> = mutableListOf()
  36 +
  37 + /**
  38 + * Register a handler. Called only by [ScopedPluginEndpointRegistrar],
  39 + * which fills in the [pluginId] argument so plug-ins cannot register
  40 + * under another plug-in's namespace.
  41 + */
  42 + @Synchronized
  43 + internal fun register(
  44 + pluginId: String,
  45 + method: HttpMethod,
  46 + path: String,
  47 + handler: PluginEndpointHandler,
  48 + ) {
  49 + require(path.startsWith("/")) {
  50 + "plug-in '$pluginId' tried to register endpoint with path '$path' that does not start with '/'"
  51 + }
  52 + val duplicate = registrations.any {
  53 + it.pluginId == pluginId && it.method == method && it.path == path
  54 + }
  55 + check(!duplicate) {
  56 + "plug-in '$pluginId' already registered $method $path"
  57 + }
  58 + registrations += Registration(pluginId, method, path, handler)
  59 + }
  60 +
  61 + /**
  62 + * Find the handler matching `(pluginId, method, requestPath)`.
  63 + * Returns the handler plus any extracted path variables, or null if
  64 + * no registration matches.
  65 + *
  66 + * `requestPath` is the path **after** the `/api/v1/plugins/<pluginId>`
  67 + * prefix, with a leading slash, e.g. `/items/42`.
  68 + */
  69 + fun find(pluginId: String, method: HttpMethod, requestPath: String): Match? {
  70 + // First try exact match (faster, more specific):
  71 + registrations.firstOrNull {
  72 + it.pluginId == pluginId && it.method == method && it.path == requestPath
  73 + }?.let { return Match(it.handler, emptyMap()) }
  74 +
  75 + // Then try pattern match:
  76 + for (reg in registrations) {
  77 + if (reg.pluginId != pluginId || reg.method != method) continue
  78 + if (pathMatcher.match(reg.path, requestPath)) {
  79 + val pathVars = pathMatcher.extractUriTemplateVariables(reg.path, requestPath)
  80 + return Match(reg.handler, pathVars)
  81 + }
  82 + }
  83 + return null
  84 + }
  85 +
  86 + /**
  87 + * Drop every registration owned by [pluginId]. Called when the
  88 + * platform stops a plug-in (uninstall, shutdown, future hot reload).
  89 + */
  90 + @Synchronized
  91 + fun unregisterAll(pluginId: String) {
  92 + registrations.removeAll { it.pluginId == pluginId }
  93 + }
  94 +
  95 + /** Total number of registrations across all plug-ins. Used by tests. */
  96 + fun size(): Int = registrations.size
  97 +
  98 + private data class Registration(
  99 + val pluginId: String,
  100 + val method: HttpMethod,
  101 + val path: String,
  102 + val handler: PluginEndpointHandler,
  103 + )
  104 +
  105 + /** Result of a successful match. */
  106 + data class Match(
  107 + val handler: PluginEndpointHandler,
  108 + val pathVariables: Map<String, String>,
  109 + )
  110 +}
  111 +
  112 +/**
  113 + * The per-plug-in registrar exposed via
  114 + * [org.vibeerp.api.v1.plugin.PluginContext.endpoints].
  115 + *
  116 + * Holds a reference to the global [PluginEndpointRegistry] and a fixed
  117 + * `pluginId` so every `register(...)` call automatically tags the
  118 + * registration with the right owner. A plug-in's lambda cannot
  119 + * impersonate another plug-in.
  120 + */
  121 +internal class ScopedPluginEndpointRegistrar(
  122 + private val registry: PluginEndpointRegistry,
  123 + private val pluginId: String,
  124 +) : PluginEndpointRegistrar {
  125 +
  126 + override fun register(method: HttpMethod, path: String, handler: PluginEndpointHandler) {
  127 + registry.register(pluginId, method, path, handler)
  128 + }
  129 +}
platform/platform-plugins/src/test/kotlin/org/vibeerp/platform/plugins/endpoints/PluginEndpointRegistryTest.kt 0 → 100644
  1 +package org.vibeerp.platform.plugins.endpoints
  2 +
  3 +import assertk.assertFailure
  4 +import assertk.assertThat
  5 +import assertk.assertions.hasMessage
  6 +import assertk.assertions.isEqualTo
  7 +import assertk.assertions.isInstanceOf
  8 +import assertk.assertions.isNotNull
  9 +import assertk.assertions.isNull
  10 +import org.junit.jupiter.api.BeforeEach
  11 +import org.junit.jupiter.api.Test
  12 +import org.vibeerp.api.v1.plugin.HttpMethod
  13 +import org.vibeerp.api.v1.plugin.PluginEndpointHandler
  14 +import org.vibeerp.api.v1.plugin.PluginRequest
  15 +import org.vibeerp.api.v1.plugin.PluginResponse
  16 +
  17 +class PluginEndpointRegistryTest {
  18 +
  19 + private lateinit var registry: PluginEndpointRegistry
  20 + private val handler: PluginEndpointHandler = PluginEndpointHandler { req ->
  21 + PluginResponse(body = mapOf("path" to req.pathParameters))
  22 + }
  23 +
  24 + @BeforeEach
  25 + fun setUp() {
  26 + registry = PluginEndpointRegistry()
  27 + }
  28 +
  29 + // ─── basic registration ────────────────────────────────────────
  30 +
  31 + @Test
  32 + fun `register and find a literal path`() {
  33 + registry.register("printing-shop", HttpMethod.GET, "/ping", handler)
  34 +
  35 + val match = registry.find("printing-shop", HttpMethod.GET, "/ping")
  36 +
  37 + assertThat(match).isNotNull()
  38 + assertThat(match!!.pathVariables).isEqualTo(emptyMap())
  39 + }
  40 +
  41 + @Test
  42 + fun `find returns null when no registration matches`() {
  43 + registry.register("printing-shop", HttpMethod.GET, "/ping", handler)
  44 +
  45 + assertThat(registry.find("printing-shop", HttpMethod.GET, "/nope")).isNull()
  46 + assertThat(registry.find("other-plugin", HttpMethod.GET, "/ping")).isNull()
  47 + assertThat(registry.find("printing-shop", HttpMethod.POST, "/ping")).isNull()
  48 + }
  49 +
  50 + @Test
  51 + fun `register rejects paths that do not start with slash`() {
  52 + assertFailure { registry.register("p", HttpMethod.GET, "ping", handler) }
  53 + .isInstanceOf(IllegalArgumentException::class)
  54 + }
  55 +
  56 + @Test
  57 + fun `duplicate registration throws`() {
  58 + registry.register("printing-shop", HttpMethod.GET, "/ping", handler)
  59 +
  60 + assertFailure {
  61 + registry.register("printing-shop", HttpMethod.GET, "/ping", handler)
  62 + }
  63 + .isInstanceOf(IllegalStateException::class)
  64 + .hasMessage("plug-in 'printing-shop' already registered GET /ping")
  65 + }
  66 +
  67 + @Test
  68 + fun `same path under different plug-ins is fine`() {
  69 + registry.register("plugin-a", HttpMethod.GET, "/ping", handler)
  70 + registry.register("plugin-b", HttpMethod.GET, "/ping", handler)
  71 +
  72 + assertThat(registry.find("plugin-a", HttpMethod.GET, "/ping")).isNotNull()
  73 + assertThat(registry.find("plugin-b", HttpMethod.GET, "/ping")).isNotNull()
  74 + assertThat(registry.size()).isEqualTo(2)
  75 + }
  76 +
  77 + @Test
  78 + fun `same path with different methods is fine`() {
  79 + registry.register("printing-shop", HttpMethod.GET, "/items", handler)
  80 + registry.register("printing-shop", HttpMethod.POST, "/items", handler)
  81 +
  82 + assertThat(registry.find("printing-shop", HttpMethod.GET, "/items")).isNotNull()
  83 + assertThat(registry.find("printing-shop", HttpMethod.POST, "/items")).isNotNull()
  84 + }
  85 +
  86 + // ─── pattern matching ──────────────────────────────────────────
  87 +
  88 + @Test
  89 + fun `pattern with single path variable extracts the value`() {
  90 + registry.register("printing-shop", HttpMethod.GET, "/items/{id}", handler)
  91 +
  92 + val match = registry.find("printing-shop", HttpMethod.GET, "/items/42")
  93 +
  94 + assertThat(match).isNotNull()
  95 + assertThat(match!!.pathVariables).isEqualTo(mapOf("id" to "42"))
  96 + }
  97 +
  98 + @Test
  99 + fun `pattern with multiple path variables extracts all of them`() {
  100 + registry.register("printing-shop", HttpMethod.GET, "/items/{itemId}/lots/{lotId}", handler)
  101 +
  102 + val match = registry.find("printing-shop", HttpMethod.GET, "/items/SKU-1/lots/L42")
  103 +
  104 + assertThat(match).isNotNull()
  105 + assertThat(match!!.pathVariables).isEqualTo(
  106 + mapOf("itemId" to "SKU-1", "lotId" to "L42"),
  107 + )
  108 + }
  109 +
  110 + @Test
  111 + fun `literal match takes precedence over pattern match when both could apply`() {
  112 + registry.register("printing-shop", HttpMethod.GET, "/items/{id}", handler)
  113 + registry.register("printing-shop", HttpMethod.GET, "/items/special", handler)
  114 +
  115 + // The exact-match fast path returns the literal handler.
  116 + val match = registry.find("printing-shop", HttpMethod.GET, "/items/special")
  117 +
  118 + assertThat(match).isNotNull()
  119 + assertThat(match!!.pathVariables).isEqualTo(emptyMap())
  120 + }
  121 +
  122 + @Test
  123 + fun `pattern does not match path with extra segments`() {
  124 + registry.register("printing-shop", HttpMethod.GET, "/items/{id}", handler)
  125 +
  126 + // /items/42/extras has more segments than the pattern.
  127 + assertThat(registry.find("printing-shop", HttpMethod.GET, "/items/42/extras")).isNull()
  128 + }
  129 +
  130 + // ─── unregister ────────────────────────────────────────────────
  131 +
  132 + @Test
  133 + fun `unregisterAll drops every registration owned by the given plug-in`() {
  134 + registry.register("printing-shop", HttpMethod.GET, "/ping", handler)
  135 + registry.register("printing-shop", HttpMethod.POST, "/ping", handler)
  136 + registry.register("other-plugin", HttpMethod.GET, "/ping", handler)
  137 +
  138 + registry.unregisterAll("printing-shop")
  139 +
  140 + assertThat(registry.find("printing-shop", HttpMethod.GET, "/ping")).isNull()
  141 + assertThat(registry.find("printing-shop", HttpMethod.POST, "/ping")).isNull()
  142 + assertThat(registry.find("other-plugin", HttpMethod.GET, "/ping")).isNotNull()
  143 + assertThat(registry.size()).isEqualTo(1)
  144 + }
  145 +
  146 + // ─── scoped registrar ──────────────────────────────────────────
  147 +
  148 + @Test
  149 + fun `ScopedPluginEndpointRegistrar tags registrations with its plug-in id`() {
  150 + val scoped = ScopedPluginEndpointRegistrar(registry, "printing-shop")
  151 + scoped.register(HttpMethod.GET, "/ping") { PluginResponse() }
  152 +
  153 + assertThat(registry.find("printing-shop", HttpMethod.GET, "/ping")).isNotNull()
  154 + assertThat(registry.find("other-plugin", HttpMethod.GET, "/ping")).isNull()
  155 + }
  156 +}
reference-customer/plugin-printing-shop/build.gradle.kts
@@ -53,3 +53,23 @@ tasks.jar { @@ -53,3 +53,23 @@ tasks.jar {
53 // need for an explicit `from(...)` block — that was causing the 53 // need for an explicit `from(...)` block — that was causing the
54 // "duplicate plugin.yml" error because Gradle would try to copy it twice. 54 // "duplicate plugin.yml" error because Gradle would try to copy it twice.
55 } 55 }
  56 +
  57 +// Dev convenience: stage the built plug-in JAR into the repository's
  58 +// `plugins-dev/` directory so `./gradlew :distribution:bootRun` (which
  59 +// reads `vibeerp.plugins.directory: ./plugins-dev` from
  60 +// application-dev.yaml) finds it on boot. Older copies are deleted
  61 +// first so renaming the JAR (e.g. on a version bump) does not leave
  62 +// PF4J trying to load two copies of the same plug-in.
  63 +val installToDev by tasks.registering(Copy::class) {
  64 + dependsOn(tasks.jar)
  65 + from(layout.buildDirectory.dir("libs"))
  66 + into(rootProject.layout.projectDirectory.dir("plugins-dev"))
  67 + include("plugin-printing-shop-*.jar")
  68 + doFirst {
  69 + // Wipe any previous copies to avoid stale-version collisions.
  70 + val target = rootProject.layout.projectDirectory.dir("plugins-dev").asFile
  71 + target.mkdirs()
  72 + target.listFiles { _, name -> name.startsWith("plugin-printing-shop-") && name.endsWith(".jar") }
  73 + ?.forEach { it.delete() }
  74 + }
  75 +}
reference-customer/plugin-printing-shop/src/main/kotlin/org/vibeerp/reference/printingshop/PrintingShopPlugin.kt
1 package org.vibeerp.reference.printingshop 1 package org.vibeerp.reference.printingshop
2 2
3 -import org.vibeerp.api.v1.plugin.Plugin 3 +import org.pf4j.PluginWrapper
  4 +import org.vibeerp.api.v1.plugin.HttpMethod
4 import org.vibeerp.api.v1.plugin.PluginContext 5 import org.vibeerp.api.v1.plugin.PluginContext
  6 +import org.vibeerp.api.v1.plugin.PluginRequest
  7 +import org.vibeerp.api.v1.plugin.PluginResponse
  8 +import org.pf4j.Plugin as Pf4jPlugin
  9 +import org.vibeerp.api.v1.plugin.Plugin as VibeErpPlugin
5 10
6 /** 11 /**
7 * The reference printing-shop plug-in. 12 * The reference printing-shop plug-in.
@@ -12,22 +17,31 @@ import org.vibeerp.api.v1.plugin.PluginContext @@ -12,22 +17,31 @@ import org.vibeerp.api.v1.plugin.PluginContext
12 * extension surface is wrong and must be reshaped — see CLAUDE.md guardrail #1 17 * extension surface is wrong and must be reshaped — see CLAUDE.md guardrail #1
13 * and the architecture spec section 13 ("Verification"). 18 * and the architecture spec section 13 ("Verification").
14 * 19 *
15 - * What this v0.1 incarnation actually does:  
16 - * 1. Logs `started` / `stopped` so the operator can confirm the loader  
17 - * picked it up.  
18 - * 2. Holds onto the [PluginContext] so future versions can publish events,  
19 - * register `TaskHandler`s, and register HTTP endpoints. 20 + * **The dual-supertype trick.** This class extends [org.pf4j.Plugin]
  21 + * (so PF4J can load it via the `Plugin-Class` manifest entry) AND
  22 + * implements [org.vibeerp.api.v1.plugin.Plugin] (so the host's
  23 + * `VibeErpPluginManager` can call vibe_erp's `start(context)` after
  24 + * PF4J's no-arg `start()`). The two have the same simple name `Plugin`
  25 + * but live in different packages, so we use Kotlin import aliases —
  26 + * `Pf4jPlugin` and `VibeErpPlugin` — to keep the source readable.
20 * 27 *
21 - * What it does NOT yet do (deferred to v0.2 of the reference plug-in):  
22 - * • register a workflow task handler for "compute job card from quote"  
23 - * • register HTTP endpoints under /api/v1/plugins/printing-shop/...  
24 - * • subscribe to OrderCreated events from pbc-orders-sales  
25 - * • register custom permission checks 28 + * **What this v0.5 incarnation actually does:**
  29 + * 1. Logs `started` / `stopped` via the framework's PluginLogger.
  30 + * 2. Registers a single HTTP endpoint at
  31 + * `GET /api/v1/plugins/printing-shop/ping` that returns a small
  32 + * JSON body. This is the smoke test that proves the entire plug-in
  33 + * lifecycle works end-to-end: PF4J loads the JAR, the host calls
  34 + * vibe_erp's start, the plug-in registers a handler, the dispatcher
  35 + * routes a real HTTP request to it, and the response comes back.
26 * 36 *
27 - * Each of those deferred items maps to growing `api.v1` precisely once for a  
28 - * real reason — exactly the discipline the architecture demands. 37 + * **What it does NOT yet do (deferred):**
  38 + * • register a workflow task handler (depends on P2.1, embedded Flowable)
  39 + * • subscribe to events (depends on P1.7, event bus + outbox)
  40 + * • own its own database schema (depends on P1.4, plug-in Liquibase)
  41 + * • register custom permissions (depends on P4.3)
  42 + * • express any of the actual printing-shop workflows from the reference docs
29 */ 43 */
30 -class PrintingShopPlugin : Plugin { 44 +class PrintingShopPlugin(wrapper: PluginWrapper) : Pf4jPlugin(wrapper), VibeErpPlugin {
31 45
32 private var context: PluginContext? = null 46 private var context: PluginContext? = null
33 47
@@ -35,11 +49,47 @@ class PrintingShopPlugin : Plugin { @@ -35,11 +49,47 @@ class PrintingShopPlugin : Plugin {
35 49
36 override fun version(): String = "0.1.0-SNAPSHOT" 50 override fun version(): String = "0.1.0-SNAPSHOT"
37 51
  52 + /**
  53 + * vibe_erp lifecycle start. PF4J's no-arg `start()` runs first
  54 + * (inherited from [Pf4jPlugin] with the default no-op body), then
  55 + * the framework's `VibeErpPluginManager` calls this with a real
  56 + * context.
  57 + */
38 override fun start(context: PluginContext) { 58 override fun start(context: PluginContext) {
39 this.context = context 59 this.context = context
40 context.logger.info("printing-shop plug-in started — reference acceptance test active") 60 context.logger.info("printing-shop plug-in started — reference acceptance test active")
  61 +
  62 + context.endpoints.register(HttpMethod.GET, "/ping") { _: PluginRequest ->
  63 + PluginResponse(
  64 + status = 200,
  65 + body = mapOf(
  66 + "plugin" to "printing-shop",
  67 + "version" to version(),
  68 + "ok" to true,
  69 + "message" to "vibe_erp plug-in HTTP endpoint working",
  70 + ),
  71 + )
  72 + }
  73 + context.logger.info("registered GET /ping under /api/v1/plugins/printing-shop/")
  74 +
  75 + context.endpoints.register(HttpMethod.GET, "/echo/{name}") { request ->
  76 + val name = request.pathParameters["name"] ?: "unknown"
  77 + PluginResponse(
  78 + status = 200,
  79 + body = mapOf(
  80 + "plugin" to "printing-shop",
  81 + "echoed" to name,
  82 + ),
  83 + )
  84 + }
  85 + context.logger.info("registered GET /echo/{name} under /api/v1/plugins/printing-shop/")
41 } 86 }
42 87
  88 + /**
  89 + * vibe_erp lifecycle stop. The host removes our endpoint
  90 + * registrations from the global registry separately, so this
  91 + * method just releases our reference to the context.
  92 + */
43 override fun stop() { 93 override fun stop() {
44 context?.logger?.info("printing-shop plug-in stopped") 94 context?.logger?.info("printing-shop plug-in stopped")
45 context = null 95 context = null