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 | 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 | +} | ... | ... |