diff --git a/platform/platform-bootstrap/build.gradle.kts b/platform/platform-bootstrap/build.gradle.kts index 6845e1e..6b641aa 100644 --- a/platform/platform-bootstrap/build.gradle.kts +++ b/platform/platform-bootstrap/build.gradle.kts @@ -40,6 +40,13 @@ dependencies { // depend on platform-bootstrap so no @RestController leaks into a // plug-in classloader; the scan only sees host controllers. implementation(libs.springdoc.openapi.starter.webmvc.ui) + // platform-plugins is needed so OpenApiConfiguration can wire the + // PluginEndpointsOpenApiCustomizer into the "plugins" GroupedOpenApi + // builder by type. Without this, the customizer @Component runs + // against the top-level /v3/api-docs but springdoc's grouped specs + // don't pick up global customizers — they need explicit + // `addOpenApiCustomiser(...)` calls on the builder. + implementation(project(":platform:platform-plugins")) testImplementation(libs.spring.boot.starter.test) testImplementation(libs.junit.jupiter) diff --git a/platform/platform-bootstrap/src/main/kotlin/org/vibeerp/platform/bootstrap/openapi/OpenApiConfiguration.kt b/platform/platform-bootstrap/src/main/kotlin/org/vibeerp/platform/bootstrap/openapi/OpenApiConfiguration.kt index 1a8b75f..abc531e 100644 --- a/platform/platform-bootstrap/src/main/kotlin/org/vibeerp/platform/bootstrap/openapi/OpenApiConfiguration.kt +++ b/platform/platform-bootstrap/src/main/kotlin/org/vibeerp/platform/bootstrap/openapi/OpenApiConfiguration.kt @@ -13,6 +13,7 @@ import org.springframework.beans.factory.ObjectProvider import org.springframework.boot.info.BuildProperties import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration +import org.vibeerp.platform.plugins.openapi.PluginEndpointsOpenApiCustomizer /** * Provides the top-level [OpenAPI] bean that `springdoc-openapi` @@ -59,6 +60,7 @@ import org.springframework.context.annotation.Configuration @Configuration class OpenApiConfiguration( private val buildPropertiesProvider: ObjectProvider, + private val pluginEndpointsCustomizer: PluginEndpointsOpenApiCustomizer, ) { @Bean @@ -264,8 +266,17 @@ class OpenApiConfiguration( fun pluginEndpointsGroup(): GroupedOpenApi = GroupedOpenApi.builder() .group("plugins") - .displayName("Plug-in HTTP dispatcher") + .displayName("Plug-in HTTP dispatcher + registered endpoints") .pathsToMatch("/api/v1/plugins/**") + // springdoc's grouped specs run their own customizer + // pipeline — top-level `@Component` customizers aren't + // inherited, so PluginEndpointsOpenApiCustomizer has to + // be wired onto THIS builder explicitly for the group + // to see plug-in-registered endpoints. The customizer + // runs on every /v3/api-docs/plugins request, so a + // plug-in that registers or unregisters endpoints at + // runtime is reflected immediately. + .addOpenApiCustomizer(pluginEndpointsCustomizer) .build() companion object { diff --git a/platform/platform-plugins/build.gradle.kts b/platform/platform-plugins/build.gradle.kts index 6fc9958..2462342 100644 --- a/platform/platform-plugins/build.gradle.kts +++ b/platform/platform-plugins/build.gradle.kts @@ -44,6 +44,14 @@ dependencies { implementation(libs.asm) // for plug-in linter bytecode scan implementation(libs.pf4j) implementation(libs.pf4j.spring) + // compileOnly: `PluginEndpointsOpenApiCustomizer` implements + // `org.springdoc.core.customizers.OpenApiCustomizer`, but we don't + // want platform-plugins to drag the full springdoc-openapi-starter-webmvc-ui + // bundle into its runtime classpath — that bundle is already present + // at runtime via platform-bootstrap's `implementation` declaration + // (which distribution inherits). compileOnly gives us the types at + // compile time without the duplicate runtime jar. + compileOnly(libs.springdoc.openapi.starter.webmvc.ui) testImplementation(libs.spring.boot.starter.test) testImplementation(libs.junit.jupiter) 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 index e0f7177..3a57f21 100644 --- 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 @@ -95,6 +95,30 @@ class PluginEndpointRegistry { /** Total number of registrations across all plug-ins. Used by tests. */ fun size(): Int = registrations.size + /** + * Read-only snapshot of every registered endpoint across every + * plug-in. Returns `(pluginId, method, path)` tuples without + * leaking the handler lambdas — callers get enough to render + * documentation, an admin view, or an OpenAPI path entry. + * + * **Why snapshot and not a live view.** The list is taken under + * the registry's intrinsic lock (via the existing @Synchronized + * discipline) and copied out, so a caller can iterate without + * holding the lock and without racing against concurrent + * plug-in (un)registration. Ordering matches the registration + * order, which is deterministic for a given plug-in's + * `start(context)` call. + */ + @Synchronized + fun snapshot(): List = + registrations.map { + PluginEndpointSummary( + pluginId = it.pluginId, + method = it.method, + path = it.path, + ) + } + private data class Registration( val pluginId: String, val method: HttpMethod, @@ -110,6 +134,24 @@ class PluginEndpointRegistry { } /** + * Read-only projection of one plug-in endpoint registration, + * returned by [PluginEndpointRegistry.snapshot]. Carries enough + * to render an admin view or populate an OpenAPI path entry — + * deliberately does NOT expose the handler lambda. + */ +data class PluginEndpointSummary( + val pluginId: String, + val method: org.vibeerp.api.v1.plugin.HttpMethod, + val path: String, +) { + /** + * Full URL path this endpoint serves on, including the + * `/api/v1/plugins/` dispatcher prefix. + */ + fun fullPath(): String = "/api/v1/plugins/$pluginId$path" +} + +/** * The per-plug-in registrar exposed via * [org.vibeerp.api.v1.plugin.PluginContext.endpoints]. * diff --git a/platform/platform-plugins/src/main/kotlin/org/vibeerp/platform/plugins/openapi/PluginEndpointsOpenApiCustomizer.kt b/platform/platform-plugins/src/main/kotlin/org/vibeerp/platform/plugins/openapi/PluginEndpointsOpenApiCustomizer.kt new file mode 100644 index 0000000..91f4317 --- /dev/null +++ b/platform/platform-plugins/src/main/kotlin/org/vibeerp/platform/plugins/openapi/PluginEndpointsOpenApiCustomizer.kt @@ -0,0 +1,216 @@ +package org.vibeerp.platform.plugins.openapi + +import io.swagger.v3.oas.models.OpenAPI +import io.swagger.v3.oas.models.Operation +import io.swagger.v3.oas.models.PathItem +import io.swagger.v3.oas.models.media.Content +import io.swagger.v3.oas.models.media.MediaType +import io.swagger.v3.oas.models.media.ObjectSchema +import io.swagger.v3.oas.models.media.StringSchema +import io.swagger.v3.oas.models.parameters.Parameter +import io.swagger.v3.oas.models.parameters.PathParameter +import io.swagger.v3.oas.models.parameters.RequestBody +import io.swagger.v3.oas.models.responses.ApiResponse +import io.swagger.v3.oas.models.responses.ApiResponses +import org.slf4j.LoggerFactory +import org.springdoc.core.customizers.OpenApiCustomizer +import org.springframework.stereotype.Component +import org.vibeerp.api.v1.plugin.HttpMethod +import org.vibeerp.platform.plugins.endpoints.PluginEndpointRegistry +import org.vibeerp.platform.plugins.endpoints.PluginEndpointSummary + +/** + * Extends the auto-generated OpenAPI 3 spec with one PathItem + * entry per plug-in-registered HTTP endpoint. + * + * **Why this is needed.** springdoc's default scan walks every + * `@RestController` bean on the host classpath and turns its + * `@RequestMapping` + method annotations into OpenAPI paths. + * Plug-in endpoints are NOT registered that way — they live as + * lambdas on a single `PluginEndpointDispatcher` controller with + * a catch-all mapping under the `/api/v1/plugins/{pluginId}` prefix, + * so the default scan sees ONE dispatcher operation and zero + * per-plug-in detail. The OpenAPI spec is therefore blind to + * every customer-specific HTTP surface — the exact place a + * downstream R1 web SPA + A1 MCP server need visibility. + * + * **How it works.** springdoc calls every `OpenApiCustomizer` + * bean once per `/v3/api-docs` (or group) request, AFTER the + * default scan has populated the paths. We iterate + * [PluginEndpointRegistry.snapshot] and add one `PathItem` per + * unique full path, attaching an [Operation] per HTTP method + * that was registered on it. The customizer runs on every + * request because plug-ins can register/unregister endpoints at + * runtime (hot reload, plug-in restart) and the spec must + * reflect the current state — no caching. + * + * **Scope of the generated OpenAPI entries.** The synthesized + * operations are deliberately minimal: + * - `tags = ["Plug-in: "]` so Swagger UI groups + * every plug-in's endpoints under a header. + * - `summary` naming the plug-in and its sub-path so operators + * can scan the list. + * - A generic `application/json` request body for methods that + * accept one (POST/PUT/PATCH), with an unconstrained schema — + * the framework has no per-endpoint schema info at this layer. + * - A generic 200 response with an unconstrained schema. + * - Path parameters auto-detected from `{name}` segments. + * + * A richer future chunk could let plug-ins declare JSON Schema + * via a new PluginEndpointRegistrar overload, but v1 ships the + * minimal surface — it's already enough for a client code + * generator to emit typed call sites against any plug-in's + * endpoints, and for an MCP-style catalog to list "what can + * this plug-in do". + * + * **Security.** The global `bearerAuth` security scheme defined + * in `OpenApiConfiguration` already applies by default via + * `addSecurityItem(...)`, so every plug-in operation inherits + * the bearer-JWT requirement without per-op annotation. + * + * **Registry coupling.** Lives in platform-plugins because + * that's where `PluginEndpointRegistry` lives and we want the + * customization close to the data it reflects. The class is + * picked up by Spring's `@ComponentScan("org.vibeerp")` in + * platform-bootstrap's `@SpringBootApplication`. The springdoc + * types are pulled in via `compileOnly` in this module's + * build.gradle.kts — the runtime jar is provided once by + * platform-bootstrap's `implementation(libs.springdoc…)`. + */ +@Component +class PluginEndpointsOpenApiCustomizer( + private val registry: PluginEndpointRegistry, +) : OpenApiCustomizer { + + private val log = LoggerFactory.getLogger(PluginEndpointsOpenApiCustomizer::class.java) + + override fun customise(openApi: OpenAPI) { + val snapshot = registry.snapshot() + if (snapshot.isEmpty()) { + return + } + + // Group by full URL path so multiple HTTP verbs on the + // same path share ONE PathItem — OpenAPI's schema puts + // get/post/put/delete as sibling operations on a PathItem. + val byFullPath: Map> = + snapshot.groupBy { it.fullPath() } + + var addedPaths = 0 + var addedOperations = 0 + + for ((fullPath, registrations) in byFullPath) { + // springdoc's PathItem uses the same template syntax + // as the registrations themselves (e.g. "/plates/{id}"), + // so we can use fullPath verbatim. + val pathItem = openApi.paths?.get(fullPath) ?: PathItem() + for (reg in registrations) { + val op = buildOperation(reg) + when (reg.method) { + HttpMethod.GET -> pathItem.get(op) + HttpMethod.POST -> pathItem.post(op) + HttpMethod.PUT -> pathItem.put(op) + HttpMethod.DELETE -> pathItem.delete(op) + HttpMethod.PATCH -> pathItem.patch(op) + } + addedOperations++ + } + // Paths() is null on a freshly-built OpenAPI so + // springdoc's default scan initializes it before we + // get here; still guard defensively for the case + // where the scan returned nothing. + if (openApi.paths == null) { + openApi.paths = io.swagger.v3.oas.models.Paths() + } + openApi.paths.addPathItem(fullPath, pathItem) + addedPaths++ + } + + log.debug( + "PluginEndpointsOpenApiCustomizer: added {} paths / {} operations from {} plug-in endpoint(s)", + addedPaths, addedOperations, snapshot.size, + ) + } + + private fun buildOperation(summary: PluginEndpointSummary): Operation { + val tag = "Plug-in: ${summary.pluginId}" + val operation = Operation() + .summary("${summary.method} ${summary.path} (plug-in: ${summary.pluginId})") + .description( + "Dynamically registered by plug-in `${summary.pluginId}` via " + + "`PluginContext.endpoints.register`.", + ) + .addTagsItem(tag) + + // Extract path variables from {name} segments. The + // dispatcher already handles these at runtime via + // AntPathMatcher; we just surface the names in the spec + // so a code generator knows to produce typed parameters. + val pathVariables = extractPathVariables(summary.path) + for (varName in pathVariables) { + operation.addParametersItem( + PathParameter() + .name(varName) + .required(true) + .schema(StringSchema()) + .description("Path variable extracted from the plug-in's template."), + ) + } + + // Attach a generic JSON request body for methods that + // carry one. The schema is deliberately unconstrained — + // the framework has no per-endpoint schema knowledge at + // this layer. A future chunk can grow this via a + // registrar overload that accepts a JSON Schema. + if (summary.method == HttpMethod.POST || + summary.method == HttpMethod.PUT || + summary.method == HttpMethod.PATCH + ) { + operation.requestBody( + RequestBody() + .required(false) + .content( + Content().addMediaType( + "application/json", + MediaType().schema(ObjectSchema().description("Plug-in-defined payload; shape not declared at the framework level.")), + ), + ), + ) + } + + operation.responses( + ApiResponses() + .addApiResponse( + "200", + ApiResponse().description("Plug-in-defined response payload.") + .content( + Content().addMediaType( + "application/json", + MediaType().schema(ObjectSchema().description("Plug-in-defined response; shape not declared at the framework level.")), + ), + ), + ) + .addApiResponse("401", ApiResponse().description("Missing or invalid JWT.")) + .addApiResponse("403", ApiResponse().description("Authenticated but lacking the plug-in-declared permission.")) + .addApiResponse("404", ApiResponse().description("No matching plug-in endpoint for the given path + method.")), + ) + + return operation + } + + /** + * Extract `{varName}` path variables from a plug-in template + * like `/plates/{id}/generate-quote-pdf`. Ordering is the + * order they appear in the template; duplicates are kept + * once. + */ + private fun extractPathVariables(template: String): List { + val result = mutableListOf() + val regex = Regex("""\{([^/}]+)\}""") + for (m in regex.findAll(template)) { + val name = m.groupValues[1] + if (name !in result) result += name + } + return result + } +}