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