Commit dfd01ff603c7999ee7fcd1a8ae27f05bdf952dda
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.
Showing
5 changed files
with
285 additions
and
1 deletions
platform/platform-bootstrap/build.gradle.kts
| @@ -40,6 +40,13 @@ dependencies { | @@ -40,6 +40,13 @@ dependencies { | ||
| 40 | // depend on platform-bootstrap so no @RestController leaks into a | 40 | // depend on platform-bootstrap so no @RestController leaks into a |
| 41 | // plug-in classloader; the scan only sees host controllers. | 41 | // plug-in classloader; the scan only sees host controllers. |
| 42 | implementation(libs.springdoc.openapi.starter.webmvc.ui) | 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 | testImplementation(libs.spring.boot.starter.test) | 51 | testImplementation(libs.spring.boot.starter.test) |
| 45 | testImplementation(libs.junit.jupiter) | 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,6 +13,7 @@ import org.springframework.beans.factory.ObjectProvider | ||
| 13 | import org.springframework.boot.info.BuildProperties | 13 | import org.springframework.boot.info.BuildProperties |
| 14 | import org.springframework.context.annotation.Bean | 14 | import org.springframework.context.annotation.Bean |
| 15 | import org.springframework.context.annotation.Configuration | 15 | import org.springframework.context.annotation.Configuration |
| 16 | +import org.vibeerp.platform.plugins.openapi.PluginEndpointsOpenApiCustomizer | ||
| 16 | 17 | ||
| 17 | /** | 18 | /** |
| 18 | * Provides the top-level [OpenAPI] bean that `springdoc-openapi` | 19 | * Provides the top-level [OpenAPI] bean that `springdoc-openapi` |
| @@ -59,6 +60,7 @@ import org.springframework.context.annotation.Configuration | @@ -59,6 +60,7 @@ import org.springframework.context.annotation.Configuration | ||
| 59 | @Configuration | 60 | @Configuration |
| 60 | class OpenApiConfiguration( | 61 | class OpenApiConfiguration( |
| 61 | private val buildPropertiesProvider: ObjectProvider<BuildProperties>, | 62 | private val buildPropertiesProvider: ObjectProvider<BuildProperties>, |
| 63 | + private val pluginEndpointsCustomizer: PluginEndpointsOpenApiCustomizer, | ||
| 62 | ) { | 64 | ) { |
| 63 | 65 | ||
| 64 | @Bean | 66 | @Bean |
| @@ -264,8 +266,17 @@ class OpenApiConfiguration( | @@ -264,8 +266,17 @@ class OpenApiConfiguration( | ||
| 264 | fun pluginEndpointsGroup(): GroupedOpenApi = | 266 | fun pluginEndpointsGroup(): GroupedOpenApi = |
| 265 | GroupedOpenApi.builder() | 267 | GroupedOpenApi.builder() |
| 266 | .group("plugins") | 268 | .group("plugins") |
| 267 | - .displayName("Plug-in HTTP dispatcher") | 269 | + .displayName("Plug-in HTTP dispatcher + registered endpoints") |
| 268 | .pathsToMatch("/api/v1/plugins/**") | 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 | .build() | 280 | .build() |
| 270 | 281 | ||
| 271 | companion object { | 282 | companion object { |
platform/platform-plugins/build.gradle.kts
| @@ -44,6 +44,14 @@ dependencies { | @@ -44,6 +44,14 @@ dependencies { | ||
| 44 | implementation(libs.asm) // for plug-in linter bytecode scan | 44 | implementation(libs.asm) // for plug-in linter bytecode scan |
| 45 | implementation(libs.pf4j) | 45 | implementation(libs.pf4j) |
| 46 | implementation(libs.pf4j.spring) | 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 | testImplementation(libs.spring.boot.starter.test) | 56 | testImplementation(libs.spring.boot.starter.test) |
| 49 | testImplementation(libs.junit.jupiter) | 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,6 +95,30 @@ class PluginEndpointRegistry { | ||
| 95 | /** Total number of registrations across all plug-ins. Used by tests. */ | 95 | /** Total number of registrations across all plug-ins. Used by tests. */ |
| 96 | fun size(): Int = registrations.size | 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 | private data class Registration( | 122 | private data class Registration( |
| 99 | val pluginId: String, | 123 | val pluginId: String, |
| 100 | val method: HttpMethod, | 124 | val method: HttpMethod, |
| @@ -110,6 +134,24 @@ class PluginEndpointRegistry { | @@ -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 | * The per-plug-in registrar exposed via | 155 | * The per-plug-in registrar exposed via |
| 114 | * [org.vibeerp.api.v1.plugin.PluginContext.endpoints]. | 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 | +} |