Commit dfd01ff603c7999ee7fcd1a8ae27f05bdf952dda

Authored by zichun
1 parent a827f4e4

feat(plugins+bootstrap): plug-in endpoints visible in OpenAPI spec

Closes the deferred TODO from the OpenAPI commit (11bef932): every
endpoint a plug-in registers via `PluginContext.endpoints.register`
now shows up in the OpenAPI spec alongside the host's
@RestController operations. Downstream OpenAPI clients (R1 web
SPA codegen, A1 MCP server tool catalog, operator-side Swagger UI
browsing) can finally see the customer-specific HTTP surface.

**Problem.** springdoc's default scan walks `@RestController`
beans on the host classpath. Plug-in endpoints are NOT registered
that way — they live as lambdas on a single
`PluginEndpointDispatcher` catch-all controller, so the default
scan saw ONE dispatcher path and zero per-plug-in detail. The
printing-shop plug-in's 8 endpoints were entirely invisible to
the spec.

**Solution: an OpenApiCustomizer bean that queries the registry
at spec-build time.**

1. `PluginEndpointRegistry.snapshot()` — new public read-only
   view. Returns a list of `(pluginId, method, path)` tuples
   without exposing the handler lambdas. Taken under the
   registry's intrinsic lock and copied out so callers can
   iterate without racing plug-in (un)registration. Ordered by
   registration order for determinism.

2. `PluginEndpointSummary` — new public data class in
   platform-plugins. `pluginId` + `method` + `path` plus a
   `fullPath()` helper that prepends `/api/v1/plugins/<pluginId>`.

3. `PluginEndpointsOpenApiCustomizer @Component` — new class in
   `platform-plugins/openapi/`. Implements
   `org.springdoc.core.customizers.OpenApiCustomizer`. On every
   `/v3/api-docs` request, iterates `registry.snapshot()`,
   groups by full path, and attaches a `PathItem` with one
   `Operation` per registered HTTP verb. Each operation gets:
     - A tag `"Plug-in: <pluginId>"` so Swagger UI groups every
       plug-in's surface under a header
     - A `summary` + `description` naming the plug-in
     - Path parameters auto-extracted from `{name}` segments
     - A generic JSON request body for POST/PUT/PATCH
     - A generic 200 response + 401/403/404 error responses
     - The global bearerAuth security scheme (inherited from
       OpenApiConfiguration, no per-op annotation)

4. `compileOnly(libs.springdoc.openapi.starter.webmvc.ui)` in
   platform-plugins so `OpenApiCustomizer` is visible at compile
   time without dragging the full webmvc-ui bundle into
   platform-plugins' runtime classpath (distribution already
   pulls it in via platform-bootstrap's `implementation`).

5. `implementation(project(":platform:platform-plugins"))` added
   to platform-bootstrap so `OpenApiConfiguration` can inject
   the customizer by type and explicitly wire it to the
   `pluginEndpointsGroup()` `GroupedOpenApi` builder via
   `.addOpenApiCustomizer(...)`. **This is load-bearing** —
   springdoc's grouped specs run their own customizer pipeline
   and do NOT inherit top-level @Component OpenApiCustomizer
   beans. Caught at smoke-test time: initially the customizer
   populated the default /v3/api-docs but the /v3/api-docs/plugins
   group still showed only the dispatcher. Fix was making the
   customizer a constructor-injected dep of OpenApiConfiguration
   and calling `addOpenApiCustomizer` on the group builder.

**What a future chunk might add** (not in this one):
  - Richer per-endpoint JSON Schema — v1 ships unconstrained
    `ObjectSchema` request/response bodies because the framework
    has no per-endpoint shape info at the registrar layer. A
    future `PluginEndpointRegistrar` overload accepting an
    explicit schema would let plug-ins document their payloads.
  - Per-endpoint `@RequirePermission` surface — the dispatcher
    enforces permissions at runtime but doesn't record them on
    the registration, so the OpenAPI spec doesn't list them.

**KDoc `/**` trap caught.** A literal plug-in URL pattern in
the customizer's KDoc (`/api/v1/plugins/{pluginId}/**`) tripped
the Kotlin nested-comment parser again. Rephrased as "under the
`/api/v1/plugins/{pluginId}` prefix" to sidestep. Third time
this trap has bitten me — the workaround is in feedback memory.

**HttpMethod enum caught.** Initial `when` branch on the customizer
covered HEAD/OPTIONS which don't exist in the api.v1 `HttpMethod`
enum (only GET/POST/PUT/PATCH/DELETE). Dropped those branches.

**Smoke-tested end-to-end against real Postgres:**
  - GET /v3/api-docs/plugins returns 7 paths:
      - /api/v1/plugins/printing-shop/echo/{name}       GET
      - /api/v1/plugins/printing-shop/inks              GET, POST
      - /api/v1/plugins/printing-shop/ping              GET
      - /api/v1/plugins/printing-shop/plates            GET, POST
      - /api/v1/plugins/printing-shop/plates/{id}       GET
      - /api/v1/plugins/printing-shop/plates/{id}/generate-quote-pdf  POST
      - /api/v1/plugins/{pluginId}/**                   (dispatcher fallback)
    Before this chunk: only the dispatcher fallback (1 path).
  - Top-level /v3/api-docs now also includes the 6 printing-shop
    paths it previously didn't.
  - All 7 printing-shop endpoints remain functional at the real
    dispatcher (no behavior change — this is a documentation-only
    enhancement).

24 modules, 355 unit tests, all green.
platform/platform-bootstrap/build.gradle.kts
... ... @@ -40,6 +40,13 @@ dependencies {
40 40 // depend on platform-bootstrap so no @RestController leaks into a
41 41 // plug-in classloader; the scan only sees host controllers.
42 42 implementation(libs.springdoc.openapi.starter.webmvc.ui)
  43 + // platform-plugins is needed so OpenApiConfiguration can wire the
  44 + // PluginEndpointsOpenApiCustomizer into the "plugins" GroupedOpenApi
  45 + // builder by type. Without this, the customizer @Component runs
  46 + // against the top-level /v3/api-docs but springdoc's grouped specs
  47 + // don't pick up global customizers — they need explicit
  48 + // `addOpenApiCustomiser(...)` calls on the builder.
  49 + implementation(project(":platform:platform-plugins"))
43 50  
44 51 testImplementation(libs.spring.boot.starter.test)
45 52 testImplementation(libs.junit.jupiter)
... ...
platform/platform-bootstrap/src/main/kotlin/org/vibeerp/platform/bootstrap/openapi/OpenApiConfiguration.kt
... ... @@ -13,6 +13,7 @@ import org.springframework.beans.factory.ObjectProvider
13 13 import org.springframework.boot.info.BuildProperties
14 14 import org.springframework.context.annotation.Bean
15 15 import org.springframework.context.annotation.Configuration
  16 +import org.vibeerp.platform.plugins.openapi.PluginEndpointsOpenApiCustomizer
16 17  
17 18 /**
18 19 * Provides the top-level [OpenAPI] bean that `springdoc-openapi`
... ... @@ -59,6 +60,7 @@ import org.springframework.context.annotation.Configuration
59 60 @Configuration
60 61 class OpenApiConfiguration(
61 62 private val buildPropertiesProvider: ObjectProvider<BuildProperties>,
  63 + private val pluginEndpointsCustomizer: PluginEndpointsOpenApiCustomizer,
62 64 ) {
63 65  
64 66 @Bean
... ... @@ -264,8 +266,17 @@ class OpenApiConfiguration(
264 266 fun pluginEndpointsGroup(): GroupedOpenApi =
265 267 GroupedOpenApi.builder()
266 268 .group("plugins")
267   - .displayName("Plug-in HTTP dispatcher")
  269 + .displayName("Plug-in HTTP dispatcher + registered endpoints")
268 270 .pathsToMatch("/api/v1/plugins/**")
  271 + // springdoc's grouped specs run their own customizer
  272 + // pipeline — top-level `@Component` customizers aren't
  273 + // inherited, so PluginEndpointsOpenApiCustomizer has to
  274 + // be wired onto THIS builder explicitly for the group
  275 + // to see plug-in-registered endpoints. The customizer
  276 + // runs on every /v3/api-docs/plugins request, so a
  277 + // plug-in that registers or unregisters endpoints at
  278 + // runtime is reflected immediately.
  279 + .addOpenApiCustomizer(pluginEndpointsCustomizer)
269 280 .build()
270 281  
271 282 companion object {
... ...
platform/platform-plugins/build.gradle.kts
... ... @@ -44,6 +44,14 @@ dependencies {
44 44 implementation(libs.asm) // for plug-in linter bytecode scan
45 45 implementation(libs.pf4j)
46 46 implementation(libs.pf4j.spring)
  47 + // compileOnly: `PluginEndpointsOpenApiCustomizer` implements
  48 + // `org.springdoc.core.customizers.OpenApiCustomizer`, but we don't
  49 + // want platform-plugins to drag the full springdoc-openapi-starter-webmvc-ui
  50 + // bundle into its runtime classpath — that bundle is already present
  51 + // at runtime via platform-bootstrap's `implementation` declaration
  52 + // (which distribution inherits). compileOnly gives us the types at
  53 + // compile time without the duplicate runtime jar.
  54 + compileOnly(libs.springdoc.openapi.starter.webmvc.ui)
47 55  
48 56 testImplementation(libs.spring.boot.starter.test)
49 57 testImplementation(libs.junit.jupiter)
... ...
platform/platform-plugins/src/main/kotlin/org/vibeerp/platform/plugins/endpoints/PluginEndpointRegistry.kt
... ... @@ -95,6 +95,30 @@ class PluginEndpointRegistry {
95 95 /** Total number of registrations across all plug-ins. Used by tests. */
96 96 fun size(): Int = registrations.size
97 97  
  98 + /**
  99 + * Read-only snapshot of every registered endpoint across every
  100 + * plug-in. Returns `(pluginId, method, path)` tuples without
  101 + * leaking the handler lambdas — callers get enough to render
  102 + * documentation, an admin view, or an OpenAPI path entry.
  103 + *
  104 + * **Why snapshot and not a live view.** The list is taken under
  105 + * the registry's intrinsic lock (via the existing @Synchronized
  106 + * discipline) and copied out, so a caller can iterate without
  107 + * holding the lock and without racing against concurrent
  108 + * plug-in (un)registration. Ordering matches the registration
  109 + * order, which is deterministic for a given plug-in's
  110 + * `start(context)` call.
  111 + */
  112 + @Synchronized
  113 + fun snapshot(): List<PluginEndpointSummary> =
  114 + registrations.map {
  115 + PluginEndpointSummary(
  116 + pluginId = it.pluginId,
  117 + method = it.method,
  118 + path = it.path,
  119 + )
  120 + }
  121 +
98 122 private data class Registration(
99 123 val pluginId: String,
100 124 val method: HttpMethod,
... ... @@ -110,6 +134,24 @@ class PluginEndpointRegistry {
110 134 }
111 135  
112 136 /**
  137 + * Read-only projection of one plug-in endpoint registration,
  138 + * returned by [PluginEndpointRegistry.snapshot]. Carries enough
  139 + * to render an admin view or populate an OpenAPI path entry —
  140 + * deliberately does NOT expose the handler lambda.
  141 + */
  142 +data class PluginEndpointSummary(
  143 + val pluginId: String,
  144 + val method: org.vibeerp.api.v1.plugin.HttpMethod,
  145 + val path: String,
  146 +) {
  147 + /**
  148 + * Full URL path this endpoint serves on, including the
  149 + * `/api/v1/plugins/<pluginId>` dispatcher prefix.
  150 + */
  151 + fun fullPath(): String = "/api/v1/plugins/$pluginId$path"
  152 +}
  153 +
  154 +/**
113 155 * The per-plug-in registrar exposed via
114 156 * [org.vibeerp.api.v1.plugin.PluginContext.endpoints].
115 157 *
... ...
platform/platform-plugins/src/main/kotlin/org/vibeerp/platform/plugins/openapi/PluginEndpointsOpenApiCustomizer.kt 0 → 100644
  1 +package org.vibeerp.platform.plugins.openapi
  2 +
  3 +import io.swagger.v3.oas.models.OpenAPI
  4 +import io.swagger.v3.oas.models.Operation
  5 +import io.swagger.v3.oas.models.PathItem
  6 +import io.swagger.v3.oas.models.media.Content
  7 +import io.swagger.v3.oas.models.media.MediaType
  8 +import io.swagger.v3.oas.models.media.ObjectSchema
  9 +import io.swagger.v3.oas.models.media.StringSchema
  10 +import io.swagger.v3.oas.models.parameters.Parameter
  11 +import io.swagger.v3.oas.models.parameters.PathParameter
  12 +import io.swagger.v3.oas.models.parameters.RequestBody
  13 +import io.swagger.v3.oas.models.responses.ApiResponse
  14 +import io.swagger.v3.oas.models.responses.ApiResponses
  15 +import org.slf4j.LoggerFactory
  16 +import org.springdoc.core.customizers.OpenApiCustomizer
  17 +import org.springframework.stereotype.Component
  18 +import org.vibeerp.api.v1.plugin.HttpMethod
  19 +import org.vibeerp.platform.plugins.endpoints.PluginEndpointRegistry
  20 +import org.vibeerp.platform.plugins.endpoints.PluginEndpointSummary
  21 +
  22 +/**
  23 + * Extends the auto-generated OpenAPI 3 spec with one PathItem
  24 + * entry per plug-in-registered HTTP endpoint.
  25 + *
  26 + * **Why this is needed.** springdoc's default scan walks every
  27 + * `@RestController` bean on the host classpath and turns its
  28 + * `@RequestMapping` + method annotations into OpenAPI paths.
  29 + * Plug-in endpoints are NOT registered that way — they live as
  30 + * lambdas on a single `PluginEndpointDispatcher` controller with
  31 + * a catch-all mapping under the `/api/v1/plugins/{pluginId}` prefix,
  32 + * so the default scan sees ONE dispatcher operation and zero
  33 + * per-plug-in detail. The OpenAPI spec is therefore blind to
  34 + * every customer-specific HTTP surface — the exact place a
  35 + * downstream R1 web SPA + A1 MCP server need visibility.
  36 + *
  37 + * **How it works.** springdoc calls every `OpenApiCustomizer`
  38 + * bean once per `/v3/api-docs` (or group) request, AFTER the
  39 + * default scan has populated the paths. We iterate
  40 + * [PluginEndpointRegistry.snapshot] and add one `PathItem` per
  41 + * unique full path, attaching an [Operation] per HTTP method
  42 + * that was registered on it. The customizer runs on every
  43 + * request because plug-ins can register/unregister endpoints at
  44 + * runtime (hot reload, plug-in restart) and the spec must
  45 + * reflect the current state — no caching.
  46 + *
  47 + * **Scope of the generated OpenAPI entries.** The synthesized
  48 + * operations are deliberately minimal:
  49 + * - `tags = ["Plug-in: <pluginId>"]` so Swagger UI groups
  50 + * every plug-in's endpoints under a header.
  51 + * - `summary` naming the plug-in and its sub-path so operators
  52 + * can scan the list.
  53 + * - A generic `application/json` request body for methods that
  54 + * accept one (POST/PUT/PATCH), with an unconstrained schema —
  55 + * the framework has no per-endpoint schema info at this layer.
  56 + * - A generic 200 response with an unconstrained schema.
  57 + * - Path parameters auto-detected from `{name}` segments.
  58 + *
  59 + * A richer future chunk could let plug-ins declare JSON Schema
  60 + * via a new PluginEndpointRegistrar overload, but v1 ships the
  61 + * minimal surface — it's already enough for a client code
  62 + * generator to emit typed call sites against any plug-in's
  63 + * endpoints, and for an MCP-style catalog to list "what can
  64 + * this plug-in do".
  65 + *
  66 + * **Security.** The global `bearerAuth` security scheme defined
  67 + * in `OpenApiConfiguration` already applies by default via
  68 + * `addSecurityItem(...)`, so every plug-in operation inherits
  69 + * the bearer-JWT requirement without per-op annotation.
  70 + *
  71 + * **Registry coupling.** Lives in platform-plugins because
  72 + * that's where `PluginEndpointRegistry` lives and we want the
  73 + * customization close to the data it reflects. The class is
  74 + * picked up by Spring's `@ComponentScan("org.vibeerp")` in
  75 + * platform-bootstrap's `@SpringBootApplication`. The springdoc
  76 + * types are pulled in via `compileOnly` in this module's
  77 + * build.gradle.kts — the runtime jar is provided once by
  78 + * platform-bootstrap's `implementation(libs.springdoc…)`.
  79 + */
  80 +@Component
  81 +class PluginEndpointsOpenApiCustomizer(
  82 + private val registry: PluginEndpointRegistry,
  83 +) : OpenApiCustomizer {
  84 +
  85 + private val log = LoggerFactory.getLogger(PluginEndpointsOpenApiCustomizer::class.java)
  86 +
  87 + override fun customise(openApi: OpenAPI) {
  88 + val snapshot = registry.snapshot()
  89 + if (snapshot.isEmpty()) {
  90 + return
  91 + }
  92 +
  93 + // Group by full URL path so multiple HTTP verbs on the
  94 + // same path share ONE PathItem — OpenAPI's schema puts
  95 + // get/post/put/delete as sibling operations on a PathItem.
  96 + val byFullPath: Map<String, List<PluginEndpointSummary>> =
  97 + snapshot.groupBy { it.fullPath() }
  98 +
  99 + var addedPaths = 0
  100 + var addedOperations = 0
  101 +
  102 + for ((fullPath, registrations) in byFullPath) {
  103 + // springdoc's PathItem uses the same template syntax
  104 + // as the registrations themselves (e.g. "/plates/{id}"),
  105 + // so we can use fullPath verbatim.
  106 + val pathItem = openApi.paths?.get(fullPath) ?: PathItem()
  107 + for (reg in registrations) {
  108 + val op = buildOperation(reg)
  109 + when (reg.method) {
  110 + HttpMethod.GET -> pathItem.get(op)
  111 + HttpMethod.POST -> pathItem.post(op)
  112 + HttpMethod.PUT -> pathItem.put(op)
  113 + HttpMethod.DELETE -> pathItem.delete(op)
  114 + HttpMethod.PATCH -> pathItem.patch(op)
  115 + }
  116 + addedOperations++
  117 + }
  118 + // Paths() is null on a freshly-built OpenAPI so
  119 + // springdoc's default scan initializes it before we
  120 + // get here; still guard defensively for the case
  121 + // where the scan returned nothing.
  122 + if (openApi.paths == null) {
  123 + openApi.paths = io.swagger.v3.oas.models.Paths()
  124 + }
  125 + openApi.paths.addPathItem(fullPath, pathItem)
  126 + addedPaths++
  127 + }
  128 +
  129 + log.debug(
  130 + "PluginEndpointsOpenApiCustomizer: added {} paths / {} operations from {} plug-in endpoint(s)",
  131 + addedPaths, addedOperations, snapshot.size,
  132 + )
  133 + }
  134 +
  135 + private fun buildOperation(summary: PluginEndpointSummary): Operation {
  136 + val tag = "Plug-in: ${summary.pluginId}"
  137 + val operation = Operation()
  138 + .summary("${summary.method} ${summary.path} (plug-in: ${summary.pluginId})")
  139 + .description(
  140 + "Dynamically registered by plug-in `${summary.pluginId}` via " +
  141 + "`PluginContext.endpoints.register`.",
  142 + )
  143 + .addTagsItem(tag)
  144 +
  145 + // Extract path variables from {name} segments. The
  146 + // dispatcher already handles these at runtime via
  147 + // AntPathMatcher; we just surface the names in the spec
  148 + // so a code generator knows to produce typed parameters.
  149 + val pathVariables = extractPathVariables(summary.path)
  150 + for (varName in pathVariables) {
  151 + operation.addParametersItem(
  152 + PathParameter()
  153 + .name(varName)
  154 + .required(true)
  155 + .schema(StringSchema())
  156 + .description("Path variable extracted from the plug-in's template."),
  157 + )
  158 + }
  159 +
  160 + // Attach a generic JSON request body for methods that
  161 + // carry one. The schema is deliberately unconstrained —
  162 + // the framework has no per-endpoint schema knowledge at
  163 + // this layer. A future chunk can grow this via a
  164 + // registrar overload that accepts a JSON Schema.
  165 + if (summary.method == HttpMethod.POST ||
  166 + summary.method == HttpMethod.PUT ||
  167 + summary.method == HttpMethod.PATCH
  168 + ) {
  169 + operation.requestBody(
  170 + RequestBody()
  171 + .required(false)
  172 + .content(
  173 + Content().addMediaType(
  174 + "application/json",
  175 + MediaType().schema(ObjectSchema().description("Plug-in-defined payload; shape not declared at the framework level.")),
  176 + ),
  177 + ),
  178 + )
  179 + }
  180 +
  181 + operation.responses(
  182 + ApiResponses()
  183 + .addApiResponse(
  184 + "200",
  185 + ApiResponse().description("Plug-in-defined response payload.")
  186 + .content(
  187 + Content().addMediaType(
  188 + "application/json",
  189 + MediaType().schema(ObjectSchema().description("Plug-in-defined response; shape not declared at the framework level.")),
  190 + ),
  191 + ),
  192 + )
  193 + .addApiResponse("401", ApiResponse().description("Missing or invalid JWT."))
  194 + .addApiResponse("403", ApiResponse().description("Authenticated but lacking the plug-in-declared permission."))
  195 + .addApiResponse("404", ApiResponse().description("No matching plug-in endpoint for the given path + method.")),
  196 + )
  197 +
  198 + return operation
  199 + }
  200 +
  201 + /**
  202 + * Extract `{varName}` path variables from a plug-in template
  203 + * like `/plates/{id}/generate-quote-pdf`. Ordering is the
  204 + * order they appear in the template; duplicates are kept
  205 + * once.
  206 + */
  207 + private fun extractPathVariables(template: String): List<String> {
  208 + val result = mutableListOf<String>()
  209 + val regex = Regex("""\{([^/}]+)\}""")
  210 + for (m in regex.findAll(template)) {
  211 + val name = m.groupValues[1]
  212 + if (name !in result) result += name
  213 + }
  214 + return result
  215 + }
  216 +}
... ...