diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b8f0c8a..be3444a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -9,6 +9,7 @@ liquibase = "4.29.2" pf4j = "3.12.0" flowable = "7.0.1" jasperreports = "6.21.3" +springdoc = "2.6.0" icu4j = "75.1" jackson = "2.18.0" junitJupiter = "5.11.2" @@ -61,6 +62,9 @@ spring-boot-starter-quartz = { module = "org.springframework.boot:spring-boot-st # Reports (JasperReports PDF rendering) jasperreports = { module = "net.sf.jasperreports:jasperreports", version.ref = "jasperreports" } +# OpenAPI / Swagger UI (runtime introspection of @RestController endpoints) +springdoc-openapi-starter-webmvc-ui = { module = "org.springdoc:springdoc-openapi-starter-webmvc-ui", version.ref = "springdoc" } + # i18n icu4j = { module = "com.ibm.icu:icu4j", version.ref = "icu4j" } diff --git a/platform/platform-bootstrap/build.gradle.kts b/platform/platform-bootstrap/build.gradle.kts index 3d00c0c..6845e1e 100644 --- a/platform/platform-bootstrap/build.gradle.kts +++ b/platform/platform-bootstrap/build.gradle.kts @@ -32,6 +32,14 @@ dependencies { implementation(libs.spring.boot.starter.data.jpa) // for @EnableJpaRepositories on VibeErpApplication implementation(libs.spring.boot.starter.validation) implementation(libs.spring.boot.starter.actuator) + // springdoc-openapi produces /v3/api-docs (the OpenAPI 3 JSON) and + // /swagger-ui/index.html from the @RestController classes it scans + // at runtime. Lives here alongside MetaController because it's part + // of the same "framework self-introspection" story that MetaController + // already serves (/_meta/info, /_meta/metadata). PBC modules never + // 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) 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 new file mode 100644 index 0000000..4378909 --- /dev/null +++ b/platform/platform-bootstrap/src/main/kotlin/org/vibeerp/platform/bootstrap/openapi/OpenApiConfiguration.kt @@ -0,0 +1,136 @@ +package org.vibeerp.platform.bootstrap.openapi + +import io.swagger.v3.oas.models.Components +import io.swagger.v3.oas.models.OpenAPI +import io.swagger.v3.oas.models.info.Contact +import io.swagger.v3.oas.models.info.Info +import io.swagger.v3.oas.models.info.License +import io.swagger.v3.oas.models.security.SecurityRequirement +import io.swagger.v3.oas.models.security.SecurityScheme +import io.swagger.v3.oas.models.servers.Server +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +/** + * Provides the top-level [OpenAPI] bean that `springdoc-openapi` + * merges into the auto-generated spec it serves at `/v3/api-docs`. + * + * **Why a @Configuration instead of just letting springdoc + * default-generate:** the default bean has no meta (title is the + * jar name, description is empty, no security scheme). Filling in + * framework identity + the bearer-JWT scheme is what makes the + * Swagger UI's "Authorize" button work out of the box and what + * lets downstream OpenAPI clients (R1 web SPA codegen, A1 MCP + * server tool catalog) pick up the bearer requirement + * declaratively instead of having every generated client paste in + * a hand-coded Authorization header. + * + * **Bearer JWT scheme, declared once.** + * - A `bearerAuth` security scheme is registered in [Components], + * matching the JWT format the framework already issues via + * `/api/v1/auth/login` and validates via `spring-boot-starter-oauth2-resource-server`. + * - It's applied globally as a default [SecurityRequirement], so + * every listed operation shows a lock icon unless it's on the + * explicit whitelist (actuator health, auth endpoints, `_meta`, + * `v3/api-docs`, `swagger-ui`). + * - Controllers that want to explicitly opt-out can still use + * `@SecurityRequirements()` (empty) on a method. + * + * **Single relative server entry.** The OpenAPI spec lists ONE + * server entry: the current host, relative path. This avoids + * baking localhost:8080 into the spec when the app is deployed + * behind a reverse proxy. A future multi-environment story can + * grow this list from configuration. + * + * **Version source.** [OPENAPI_INFO_VERSION] is a build-time + * constant kept in sync with the framework's `PROGRESS.md` at + * memory time; it's not parsed out of any manifest because the + * distribution fat-jar doesn't carry a semver yet. When a real + * version header lands this moves to a @Value on a config + * property, but hardcoding it now keeps the chunk small. + */ +@Configuration +class OpenApiConfiguration { + + @Bean + fun vibeErpOpenApi(): OpenAPI { + val bearerScheme = SecurityScheme() + .type(SecurityScheme.Type.HTTP) + .scheme("bearer") + .bearerFormat("JWT") + .description( + "JWT access token obtained from POST /api/v1/auth/login. " + + "Paste the token without the 'Bearer ' prefix — Swagger UI adds it.", + ) + + return OpenAPI() + .info( + Info() + .title("vibe_erp") + .version(OPENAPI_INFO_VERSION) + .description( + """ + Auto-generated OpenAPI 3 spec for the vibe_erp framework core and every + loaded plug-in's HTTP surface. + + **Auth.** Every endpoint except the handful whitelisted in `SecurityConfiguration` + (actuator health, `/api/v1/auth/login`, `/api/v1/auth/refresh`, `/api/v1/_meta/**`, + `/v3/api-docs/**`, `/swagger-ui/**`) requires a Bearer JWT. Obtain one by + POSTing `{ "username": "...", "password": "..." }` to `/api/v1/auth/login`. + + **Permissions.** Controller methods are annotated with `@RequirePermission("...")`; + the spec does NOT currently surface those keys into the OpenAPI `security` + array — a future chunk can extend the controllers with `@Operation(security = ...)` + so generated clients can introspect per-operation permissions. + + **Plug-in endpoints.** Endpoints mounted by plug-ins under `/api/v1/plugins/{id}/**` + are NOT auto-discovered by this scan — they are registered dynamically at plug-in + start time through `PluginContext.endpoints` and live on a single dispatcher + `@RestController`. A future chunk may extend the spec at runtime as plug-ins + load/unload. + """.trimIndent(), + ) + .contact( + Contact() + .name("vibe_erp") + .url("https://github.com/reporkey/vibe-erp"), + ) + .license( + License() + .name("Proprietary") + .url("https://github.com/reporkey/vibe-erp"), + ), + ) + .servers( + listOf( + Server() + .url("/") + .description("Current host (relative — works behind a reverse proxy)"), + ), + ) + .components( + Components() + .addSecuritySchemes(BEARER_SCHEME_NAME, bearerScheme), + ) + .addSecurityItem( + SecurityRequirement().addList(BEARER_SCHEME_NAME), + ) + } + + companion object { + /** + * Name of the security scheme registered in Components. + * Referenced from [addSecurityItem] for the global default + * and from `@SecurityRequirement(name = ...)` on any + * controller method that wants per-operation overrides. + */ + const val BEARER_SCHEME_NAME: String = "bearerAuth" + + /** + * Displayed in Swagger UI's header. Hardcoded for now; when + * a real build-time version header ships this becomes a + * configuration property. + */ + const val OPENAPI_INFO_VERSION: String = "v0.28.0" + } +} diff --git a/platform/platform-security/src/main/kotlin/org/vibeerp/platform/security/SecurityConfiguration.kt b/platform/platform-security/src/main/kotlin/org/vibeerp/platform/security/SecurityConfiguration.kt index 1f55a52..4d0d313 100644 --- a/platform/platform-security/src/main/kotlin/org/vibeerp/platform/security/SecurityConfiguration.kt +++ b/platform/platform-security/src/main/kotlin/org/vibeerp/platform/security/SecurityConfiguration.kt @@ -46,6 +46,16 @@ class SecurityConfiguration { // Auth endpoints themselves cannot require auth "/api/v1/auth/login", "/api/v1/auth/refresh", + // OpenAPI / Swagger UI — public because it describes the + // API surface, not the data. The *data* still requires a + // valid JWT: an unauthenticated "Try it out" call from + // the Swagger UI against a pbc endpoint returns 401 just + // like a curl does. Exposing the spec is the first step + // toward both the future R1 web SPA (OpenAPI codegen) + // and the A1 MCP server (discoverable tool catalog). + "/v3/api-docs/**", + "/swagger-ui/**", + "/swagger-ui.html", ).permitAll() auth.anyRequest().authenticated() }