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,6 +9,7 @@ liquibase = "4.29.2" | ||
| 9 | pf4j = "3.12.0" | 9 | pf4j = "3.12.0" |
| 10 | flowable = "7.0.1" | 10 | flowable = "7.0.1" |
| 11 | jasperreports = "6.21.3" | 11 | jasperreports = "6.21.3" |
| 12 | +springdoc = "2.6.0" | ||
| 12 | icu4j = "75.1" | 13 | icu4j = "75.1" |
| 13 | jackson = "2.18.0" | 14 | jackson = "2.18.0" |
| 14 | junitJupiter = "5.11.2" | 15 | junitJupiter = "5.11.2" |
| @@ -61,6 +62,9 @@ spring-boot-starter-quartz = { module = "org.springframework.boot:spring-boot-st | @@ -61,6 +62,9 @@ spring-boot-starter-quartz = { module = "org.springframework.boot:spring-boot-st | ||
| 61 | # Reports (JasperReports PDF rendering) | 62 | # Reports (JasperReports PDF rendering) |
| 62 | jasperreports = { module = "net.sf.jasperreports:jasperreports", version.ref = "jasperreports" } | 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 | # i18n | 68 | # i18n |
| 65 | icu4j = { module = "com.ibm.icu:icu4j", version.ref = "icu4j" } | 69 | icu4j = { module = "com.ibm.icu:icu4j", version.ref = "icu4j" } |
| 66 | 70 |
platform/platform-bootstrap/build.gradle.kts
| @@ -32,6 +32,14 @@ dependencies { | @@ -32,6 +32,14 @@ dependencies { | ||
| 32 | implementation(libs.spring.boot.starter.data.jpa) // for @EnableJpaRepositories on VibeErpApplication | 32 | implementation(libs.spring.boot.starter.data.jpa) // for @EnableJpaRepositories on VibeErpApplication |
| 33 | implementation(libs.spring.boot.starter.validation) | 33 | implementation(libs.spring.boot.starter.validation) |
| 34 | implementation(libs.spring.boot.starter.actuator) | 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 | testImplementation(libs.spring.boot.starter.test) | 44 | testImplementation(libs.spring.boot.starter.test) |
| 37 | testImplementation(libs.junit.jupiter) | 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,6 +46,16 @@ class SecurityConfiguration { | ||
| 46 | // Auth endpoints themselves cannot require auth | 46 | // Auth endpoints themselves cannot require auth |
| 47 | "/api/v1/auth/login", | 47 | "/api/v1/auth/login", |
| 48 | "/api/v1/auth/refresh", | 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 | ).permitAll() | 59 | ).permitAll() |
| 50 | auth.anyRequest().authenticated() | 60 | auth.anyRequest().authenticated() |
| 51 | } | 61 | } |
-
mentioned in commit dfd01ff6