Commit 11bef932eec9e43cc17efef45577099e833b568c
1 parent
f01ba0d7
feat(bootstrap+security): OpenAPI 3 spec + Swagger UI
Adds self-introspection of the framework's REST surface via
springdoc-openapi. Every @RestController method in the host
application is now documented in a machine-readable OpenAPI 3
spec at /v3/api-docs and rendered for humans at
/swagger-ui/index.html. This is the first step toward:
- R1 (web SPA): OpenAPI codegen feeds a typed TypeScript client
- A1 (MCP server): discoverable tool catalog
- Operator debugging: browsable "what can this instance do" page
**Dependency.** New `springdoc-openapi-starter-webmvc-ui` 2.6.0
added to platform-bootstrap (not distribution) because it ships
@Configuration classes that need to run inside a full Spring Boot
application context AND brings a Swagger UI WebJar. platform-bootstrap
is the only module with a @SpringBootApplication anyway; pbc
modules never depend on it, so plug-in classloaders stay clean
and the OpenAPI scanner only sees host controllers.
**Configuration.** New `OpenApiConfiguration` @Configuration in
platform-bootstrap provides a single @Bean OpenAPI:
- Title "vibe_erp", version v0.28.0 (hardcoded; moves to a
build property when a real version header ships)
- Description with a framework-level intro explaining the
bearer-JWT auth model, the permission whitelist, and the
fact that plug-in endpoints under /api/v1/plugins/{id}/** are
NOT scanned (they are dynamically registered via
PluginContext.endpoints on a single dispatcher controller;
a future chunk may extend the spec at runtime).
- One relative server entry ("/") so the spec works behind a
reverse proxy without baking localhost into it.
- bearerAuth security scheme (HTTP/bearer/JWT) applied globally
via addSecurityItem, so every operation in the rendered UI
shows a lock icon and the "Authorize" button accepts a raw
JWT (Swagger adds the "Bearer " prefix itself).
**Security whitelist.** SecurityConfiguration now permits three
additional path patterns without authentication:
- /v3/api-docs/** — the generated JSON spec
- /swagger-ui/** — the Swagger UI static assets + index
- /swagger-ui.html — the legacy path (redirects to the above)
The data still requires a valid JWT: an unauthenticated "Try it
out" call from the Swagger UI against a pbc endpoint returns 401
exactly like a curl would.
**Why not wire this into every PBC controller with @Operation /
@Parameter annotations in this chunk:** springdoc already
auto-generates the full path + request body + response schema
from reflection. Adding hand-written annotations is scope creep —
a future chunk can tag per-operation @Operation(security = ...)
to surface the @RequirePermission keys once a consumer actually
needs them.
**Smoke-tested end-to-end against real Postgres:**
- GET /v3/api-docs returns 200 with 64680 bytes of OpenAPI JSON
- 76 total paths listed across every PBC controller
- All v3 production paths present: /work-orders/shop-floor,
/work-orders/{id}/operations/{operationId}/start + /complete,
/work-orders/{id}/{start,complete,cancel,scrap}
- components.securitySchemes includes bearerAuth (type=http,
format=JWT)
- GET /swagger-ui/index.html returns 200 with the Swagger HTML
bundle (5 swagger markers found in the HTML)
- GET /swagger-ui.html (legacy path) returns 200 after redirect
25 modules (unchanged count — new config lives inside
platform-bootstrap), 355 unit tests, all green.
Showing
4 changed files
with
158 additions
and
0 deletions
gradle/libs.versions.toml
| ... | ... | @@ -9,6 +9,7 @@ liquibase = "4.29.2" |
| 9 | 9 | pf4j = "3.12.0" |
| 10 | 10 | flowable = "7.0.1" |
| 11 | 11 | jasperreports = "6.21.3" |
| 12 | +springdoc = "2.6.0" | |
| 12 | 13 | icu4j = "75.1" |
| 13 | 14 | jackson = "2.18.0" |
| 14 | 15 | junitJupiter = "5.11.2" |
| ... | ... | @@ -61,6 +62,9 @@ spring-boot-starter-quartz = { module = "org.springframework.boot:spring-boot-st |
| 61 | 62 | # Reports (JasperReports PDF rendering) |
| 62 | 63 | jasperreports = { module = "net.sf.jasperreports:jasperreports", version.ref = "jasperreports" } |
| 63 | 64 | |
| 65 | +# OpenAPI / Swagger UI (runtime introspection of @RestController endpoints) | |
| 66 | +springdoc-openapi-starter-webmvc-ui = { module = "org.springdoc:springdoc-openapi-starter-webmvc-ui", version.ref = "springdoc" } | |
| 67 | + | |
| 64 | 68 | # i18n |
| 65 | 69 | icu4j = { module = "com.ibm.icu:icu4j", version.ref = "icu4j" } |
| 66 | 70 | ... | ... |
platform/platform-bootstrap/build.gradle.kts
| ... | ... | @@ -32,6 +32,14 @@ dependencies { |
| 32 | 32 | implementation(libs.spring.boot.starter.data.jpa) // for @EnableJpaRepositories on VibeErpApplication |
| 33 | 33 | implementation(libs.spring.boot.starter.validation) |
| 34 | 34 | implementation(libs.spring.boot.starter.actuator) |
| 35 | + // springdoc-openapi produces /v3/api-docs (the OpenAPI 3 JSON) and | |
| 36 | + // /swagger-ui/index.html from the @RestController classes it scans | |
| 37 | + // at runtime. Lives here alongside MetaController because it's part | |
| 38 | + // of the same "framework self-introspection" story that MetaController | |
| 39 | + // already serves (/_meta/info, /_meta/metadata). PBC modules never | |
| 40 | + // depend on platform-bootstrap so no @RestController leaks into a | |
| 41 | + // plug-in classloader; the scan only sees host controllers. | |
| 42 | + implementation(libs.springdoc.openapi.starter.webmvc.ui) | |
| 35 | 43 | |
| 36 | 44 | testImplementation(libs.spring.boot.starter.test) |
| 37 | 45 | testImplementation(libs.junit.jupiter) | ... | ... |
platform/platform-bootstrap/src/main/kotlin/org/vibeerp/platform/bootstrap/openapi/OpenApiConfiguration.kt
0 → 100644
| 1 | +package org.vibeerp.platform.bootstrap.openapi | |
| 2 | + | |
| 3 | +import io.swagger.v3.oas.models.Components | |
| 4 | +import io.swagger.v3.oas.models.OpenAPI | |
| 5 | +import io.swagger.v3.oas.models.info.Contact | |
| 6 | +import io.swagger.v3.oas.models.info.Info | |
| 7 | +import io.swagger.v3.oas.models.info.License | |
| 8 | +import io.swagger.v3.oas.models.security.SecurityRequirement | |
| 9 | +import io.swagger.v3.oas.models.security.SecurityScheme | |
| 10 | +import io.swagger.v3.oas.models.servers.Server | |
| 11 | +import org.springframework.context.annotation.Bean | |
| 12 | +import org.springframework.context.annotation.Configuration | |
| 13 | + | |
| 14 | +/** | |
| 15 | + * Provides the top-level [OpenAPI] bean that `springdoc-openapi` | |
| 16 | + * merges into the auto-generated spec it serves at `/v3/api-docs`. | |
| 17 | + * | |
| 18 | + * **Why a @Configuration instead of just letting springdoc | |
| 19 | + * default-generate:** the default bean has no meta (title is the | |
| 20 | + * jar name, description is empty, no security scheme). Filling in | |
| 21 | + * framework identity + the bearer-JWT scheme is what makes the | |
| 22 | + * Swagger UI's "Authorize" button work out of the box and what | |
| 23 | + * lets downstream OpenAPI clients (R1 web SPA codegen, A1 MCP | |
| 24 | + * server tool catalog) pick up the bearer requirement | |
| 25 | + * declaratively instead of having every generated client paste in | |
| 26 | + * a hand-coded Authorization header. | |
| 27 | + * | |
| 28 | + * **Bearer JWT scheme, declared once.** | |
| 29 | + * - A `bearerAuth` security scheme is registered in [Components], | |
| 30 | + * matching the JWT format the framework already issues via | |
| 31 | + * `/api/v1/auth/login` and validates via `spring-boot-starter-oauth2-resource-server`. | |
| 32 | + * - It's applied globally as a default [SecurityRequirement], so | |
| 33 | + * every listed operation shows a lock icon unless it's on the | |
| 34 | + * explicit whitelist (actuator health, auth endpoints, `_meta`, | |
| 35 | + * `v3/api-docs`, `swagger-ui`). | |
| 36 | + * - Controllers that want to explicitly opt-out can still use | |
| 37 | + * `@SecurityRequirements()` (empty) on a method. | |
| 38 | + * | |
| 39 | + * **Single relative server entry.** The OpenAPI spec lists ONE | |
| 40 | + * server entry: the current host, relative path. This avoids | |
| 41 | + * baking localhost:8080 into the spec when the app is deployed | |
| 42 | + * behind a reverse proxy. A future multi-environment story can | |
| 43 | + * grow this list from configuration. | |
| 44 | + * | |
| 45 | + * **Version source.** [OPENAPI_INFO_VERSION] is a build-time | |
| 46 | + * constant kept in sync with the framework's `PROGRESS.md` at | |
| 47 | + * memory time; it's not parsed out of any manifest because the | |
| 48 | + * distribution fat-jar doesn't carry a semver yet. When a real | |
| 49 | + * version header lands this moves to a @Value on a config | |
| 50 | + * property, but hardcoding it now keeps the chunk small. | |
| 51 | + */ | |
| 52 | +@Configuration | |
| 53 | +class OpenApiConfiguration { | |
| 54 | + | |
| 55 | + @Bean | |
| 56 | + fun vibeErpOpenApi(): OpenAPI { | |
| 57 | + val bearerScheme = SecurityScheme() | |
| 58 | + .type(SecurityScheme.Type.HTTP) | |
| 59 | + .scheme("bearer") | |
| 60 | + .bearerFormat("JWT") | |
| 61 | + .description( | |
| 62 | + "JWT access token obtained from POST /api/v1/auth/login. " + | |
| 63 | + "Paste the token without the 'Bearer ' prefix — Swagger UI adds it.", | |
| 64 | + ) | |
| 65 | + | |
| 66 | + return OpenAPI() | |
| 67 | + .info( | |
| 68 | + Info() | |
| 69 | + .title("vibe_erp") | |
| 70 | + .version(OPENAPI_INFO_VERSION) | |
| 71 | + .description( | |
| 72 | + """ | |
| 73 | + Auto-generated OpenAPI 3 spec for the vibe_erp framework core and every | |
| 74 | + loaded plug-in's HTTP surface. | |
| 75 | + | |
| 76 | + **Auth.** Every endpoint except the handful whitelisted in `SecurityConfiguration` | |
| 77 | + (actuator health, `/api/v1/auth/login`, `/api/v1/auth/refresh`, `/api/v1/_meta/**`, | |
| 78 | + `/v3/api-docs/**`, `/swagger-ui/**`) requires a Bearer JWT. Obtain one by | |
| 79 | + POSTing `{ "username": "...", "password": "..." }` to `/api/v1/auth/login`. | |
| 80 | + | |
| 81 | + **Permissions.** Controller methods are annotated with `@RequirePermission("...")`; | |
| 82 | + the spec does NOT currently surface those keys into the OpenAPI `security` | |
| 83 | + array — a future chunk can extend the controllers with `@Operation(security = ...)` | |
| 84 | + so generated clients can introspect per-operation permissions. | |
| 85 | + | |
| 86 | + **Plug-in endpoints.** Endpoints mounted by plug-ins under `/api/v1/plugins/{id}/**` | |
| 87 | + are NOT auto-discovered by this scan — they are registered dynamically at plug-in | |
| 88 | + start time through `PluginContext.endpoints` and live on a single dispatcher | |
| 89 | + `@RestController`. A future chunk may extend the spec at runtime as plug-ins | |
| 90 | + load/unload. | |
| 91 | + """.trimIndent(), | |
| 92 | + ) | |
| 93 | + .contact( | |
| 94 | + Contact() | |
| 95 | + .name("vibe_erp") | |
| 96 | + .url("https://github.com/reporkey/vibe-erp"), | |
| 97 | + ) | |
| 98 | + .license( | |
| 99 | + License() | |
| 100 | + .name("Proprietary") | |
| 101 | + .url("https://github.com/reporkey/vibe-erp"), | |
| 102 | + ), | |
| 103 | + ) | |
| 104 | + .servers( | |
| 105 | + listOf( | |
| 106 | + Server() | |
| 107 | + .url("/") | |
| 108 | + .description("Current host (relative — works behind a reverse proxy)"), | |
| 109 | + ), | |
| 110 | + ) | |
| 111 | + .components( | |
| 112 | + Components() | |
| 113 | + .addSecuritySchemes(BEARER_SCHEME_NAME, bearerScheme), | |
| 114 | + ) | |
| 115 | + .addSecurityItem( | |
| 116 | + SecurityRequirement().addList(BEARER_SCHEME_NAME), | |
| 117 | + ) | |
| 118 | + } | |
| 119 | + | |
| 120 | + companion object { | |
| 121 | + /** | |
| 122 | + * Name of the security scheme registered in Components. | |
| 123 | + * Referenced from [addSecurityItem] for the global default | |
| 124 | + * and from `@SecurityRequirement(name = ...)` on any | |
| 125 | + * controller method that wants per-operation overrides. | |
| 126 | + */ | |
| 127 | + const val BEARER_SCHEME_NAME: String = "bearerAuth" | |
| 128 | + | |
| 129 | + /** | |
| 130 | + * Displayed in Swagger UI's header. Hardcoded for now; when | |
| 131 | + * a real build-time version header ships this becomes a | |
| 132 | + * configuration property. | |
| 133 | + */ | |
| 134 | + const val OPENAPI_INFO_VERSION: String = "v0.28.0" | |
| 135 | + } | |
| 136 | +} | ... | ... |
platform/platform-security/src/main/kotlin/org/vibeerp/platform/security/SecurityConfiguration.kt
| ... | ... | @@ -46,6 +46,16 @@ class SecurityConfiguration { |
| 46 | 46 | // Auth endpoints themselves cannot require auth |
| 47 | 47 | "/api/v1/auth/login", |
| 48 | 48 | "/api/v1/auth/refresh", |
| 49 | + // OpenAPI / Swagger UI — public because it describes the | |
| 50 | + // API surface, not the data. The *data* still requires a | |
| 51 | + // valid JWT: an unauthenticated "Try it out" call from | |
| 52 | + // the Swagger UI against a pbc endpoint returns 401 just | |
| 53 | + // like a curl does. Exposing the spec is the first step | |
| 54 | + // toward both the future R1 web SPA (OpenAPI codegen) | |
| 55 | + // and the A1 MCP server (discoverable tool catalog). | |
| 56 | + "/v3/api-docs/**", | |
| 57 | + "/swagger-ui/**", | |
| 58 | + "/swagger-ui.html", | |
| 49 | 59 | ).permitAll() |
| 50 | 60 | auth.anyRequest().authenticated() |
| 51 | 61 | } | ... | ... |
-
mentioned in commit dfd01ff6