Commit 20d7ddc6d70e9a7251fd4de1a979c101e2169933
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.
Showing
12 changed files
with
817 additions
and
38 deletions
.gitignore
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 | 47 | /** |
| 48 | 48 | * Lifecycle-bound logger. Always prefer this over `LoggerFactory.getLogger` |
| 49 | 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 | 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 | 55 | |
| 56 | 56 | // `./gradlew :distribution:bootRun` — used by `make run` for local dev. |
| 57 | 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 | 62 | tasks.bootRun { |
| 60 | 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 | 26 | implementation(libs.jackson.module.kotlin) |
| 27 | 27 | |
| 28 | 28 | implementation(libs.spring.boot.starter) |
| 29 | + implementation(libs.spring.boot.starter.web) // for @RestController on the dispatcher | |
| 29 | 30 | implementation(libs.pf4j) |
| 30 | 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 | 7 | import org.springframework.beans.factory.InitializingBean |
| 8 | 8 | import org.springframework.boot.context.properties.ConfigurationProperties |
| 9 | 9 | import org.springframework.stereotype.Component |
| 10 | +import org.vibeerp.platform.plugins.endpoints.PluginEndpointRegistry | |
| 11 | +import org.vibeerp.platform.plugins.endpoints.ScopedPluginEndpointRegistrar | |
| 10 | 12 | import java.nio.file.Files |
| 11 | 13 | import java.nio.file.Path |
| 12 | 14 | import java.nio.file.Paths |
| 15 | +import org.vibeerp.api.v1.plugin.Plugin as VibeErpPlugin | |
| 13 | 16 | |
| 14 | 17 | /** |
| 15 | 18 | * The framework's PF4J host. |
| 16 | 19 | * |
| 17 | 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 | 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 | 45 | * Reference: architecture spec section 7 ("Plug-in lifecycle"). |
| 41 | 46 | */ |
| 42 | 47 | @Component |
| 43 | 48 | class VibeErpPluginManager( |
| 44 | 49 | private val properties: VibeErpPluginsProperties, |
| 50 | + private val endpointRegistry: PluginEndpointRegistry, | |
| 45 | 51 | ) : DefaultPluginManager(Paths.get(properties.directory)), InitializingBean, DisposableBean { |
| 46 | 52 | |
| 47 | 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 | 62 | override fun afterPropertiesSet() { |
| 50 | 63 | if (!properties.autoLoad) { |
| 51 | 64 | log.info("vibe_erp plug-in auto-load disabled — skipping plug-in scan") |
| ... | ... | @@ -60,16 +73,62 @@ class VibeErpPluginManager( |
| 60 | 73 | log.info("vibe_erp scanning plug-ins from {}", dir) |
| 61 | 74 | loadPlugins() |
| 62 | 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 | 81 | plugins.values.forEach { wrapper: PluginWrapper -> |
| 82 | + val pluginId = wrapper.pluginId | |
| 64 | 83 | log.info( |
| 65 | 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 | 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 | 132 | stopPlugins() |
| 74 | 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 | 53 | // need for an explicit `from(...)` block — that was causing the |
| 54 | 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 | 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 | 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 | 12 | * The reference printing-shop plug-in. |
| ... | ... | @@ -12,22 +17,31 @@ import org.vibeerp.api.v1.plugin.PluginContext |
| 12 | 17 | * extension surface is wrong and must be reshaped — see CLAUDE.md guardrail #1 |
| 13 | 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 | 46 | private var context: PluginContext? = null |
| 33 | 47 | |
| ... | ... | @@ -35,11 +49,47 @@ class PrintingShopPlugin : Plugin { |
| 35 | 49 | |
| 36 | 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 | 58 | override fun start(context: PluginContext) { |
| 39 | 59 | this.context = context |
| 40 | 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 | 93 | override fun stop() { |
| 44 | 94 | context?.logger?.info("printing-shop plug-in stopped") |
| 45 | 95 | context = null | ... | ... |