diff --git a/CHANGELOG.ko.md b/CHANGELOG.ko.md index 909282a..ef9b4aa 100644 --- a/CHANGELOG.ko.md +++ b/CHANGELOG.ko.md @@ -11,6 +11,15 @@ English: [CHANGELOG.md](CHANGELOG.md) ## [Unreleased] +### 추가됨 (Added) +- **OpenAPI / Swagger UI 자동 구성** — springdoc-openapi가 classpath에 있으면(킷은 + `compileOnly`로 선언, 소비자가 `springdoc-openapi-starter-webmvc-ui`를 추가해 opt-in) + 설정 없이 `/swagger-ui.html`과 `/v3/api-docs`가 올라오고 `/admin/api/v1/**` + 엔드포인트가 하나의 그룹으로 묶입니다. 의존성을 제거하지 않고 + `devslab.kit.openapi.enabled=false`로 끌 수 있습니다(프로덕션에서 일반적). `OpenAPI` + 문서 빈과 관리자 `GroupedOpenApi` 빈은 `@ConditionalOnMissingBean`이라 소비자가 + 재정의 가능. springdoc `3.0.x`(Spring Boot 4 라인) 대상. + ## [0.1.0] — 2026-06-01 첫 공개 릴리스. diff --git a/CHANGELOG.md b/CHANGELOG.md index 453cf0a..1523744 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,16 @@ The library major aligns with the Spring Boot major: `4.x.y` targets Spring Boot ## [Unreleased] +### Added +- **OpenAPI / Swagger UI auto-configuration** — when springdoc-openapi is on the + classpath (declared `compileOnly` by the kit; a consumer opts in by adding + `springdoc-openapi-starter-webmvc-ui`), `/swagger-ui.html` and `/v3/api-docs` + come up with no wiring and the `/admin/api/v1/**` endpoints are collected into one + group. Disable without removing the dependency via `devslab.kit.openapi.enabled=false` + (typical in production). The `OpenAPI` document and admin `GroupedOpenApi` beans are + `@ConditionalOnMissingBean`, so a consumer can override either. Targets springdoc + `3.0.x` (the Spring Boot 4 line). + ## [0.1.0] — 2026-06-01 First public release. diff --git a/README.ko.md b/README.ko.md index de8903f..4bff09d 100644 --- a/README.ko.md +++ b/README.ko.md @@ -41,6 +41,7 @@ | **캐시** | 플러그형 캐시 — `in-memory` · `redis` · `none`. Redis 백엔드가 JSON 직렬화를 직접 책임지므로 `Serializable` 구현이나 직렬화기 배선이 필요 없습니다(ADR 0002). 사용자별 메뉴 캐시도 이 공유 매니저를 사용합니다. | | **최초 관리자 부트스트랩** | 첫 부팅 시 테넌트, `PLATFORM_ADMIN` 역할, `admin.*` 권한, 관리자 사용자를 멱등하게 생성 — opt-in, 프로퍼티 기반(ADR 0001). | | **관리자 REST API** | 위 모든 엔티티 + 진단 + 실시간 설정 뷰를 위한 `/admin/api/v1/**`. | +| **OpenAPI / Swagger UI** | springdoc만 추가하면 `/swagger-ui`가 관리자 API 그룹과 함께 자동으로 뜸 — 설정 불필요. `openapi.enabled=false`로 끔. | | **Override 친화적** | 모든 기본 빈이 `@ConditionalOnMissingBean` — 직접 선언하면 어느 조각이든 교체 가능. | | **GraalVM Native** | 리플렉션 중심 설계를 피하고, 샘플 앱이 `nativeCompile`을 검증. | @@ -145,6 +146,8 @@ Docker Compose(Postgres + Redis)와 Testcontainers 기반 테스트까지 갖춘 | `bootstrap.admin-login-id` | `admin` | 최초 관리자 로그인 id. | | `bootstrap.admin-password` | — | 비우면 강력한 랜덤 비밀번호를 한 번 로깅. | | `bootstrap.must-change-password` | `true` | 첫 로그인 시 변경 강제. | +| `openapi.enabled` | `true` | springdoc이 classpath에 있으면 Swagger UI / OpenAPI 노출. `false`로 비활성화(예: 프로덕션). | +| `openapi.title` | `devslab-kit Admin API` | OpenAPI 문서 / Swagger UI에 표시될 제목. | 런타임의 실제 적용값은 `GET /admin/api/v1/settings`에서도 볼 수 있습니다(시크릿 마스킹). diff --git a/README.md b/README.md index 1ec8276..c5a196b 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,7 @@ specific product's domain. | **Cache** | A pluggable cache — `in-memory`, `redis`, or `none`. The Redis backend owns JSON serialization, so you never implement `Serializable` or wire a serializer (ADR 0002). The per-user menu cache rides this shared manager. | | **First-admin bootstrap** | Idempotently provisions a tenant, a `PLATFORM_ADMIN` role, the `admin.*` permissions, and an admin user on first boot — opt-in and property-driven (ADR 0001). | | **Admin REST API** | `/admin/api/v1/**` for every entity above, plus diagnostics and a live settings view. | +| **OpenAPI / Swagger UI** | Add springdoc and `/swagger-ui` comes up with the admin API grouped — no wiring. Toggle off with `openapi.enabled=false`. | | **Override-friendly** | Every default bean is `@ConditionalOnMissingBean` — replace any piece by declaring your own. | | **GraalVM Native** | Reflection-heavy patterns are avoided; the sample app verifies `nativeCompile`. | @@ -148,6 +149,8 @@ All keys are under the `devslab.kit.*` prefix. Defaults shown. | `bootstrap.admin-login-id` | `admin` | First admin login id. | | `bootstrap.admin-password` | — | Blank → a strong random one is logged once. | | `bootstrap.must-change-password` | `true` | Force a rotation on first login. | +| `openapi.enabled` | `true` | Expose Swagger UI / OpenAPI when springdoc is on the classpath. Set `false` to disable (e.g. in production). | +| `openapi.title` | `devslab-kit Admin API` | Title shown in the OpenAPI doc / Swagger UI. | The live, effective values are also viewable at runtime via `GET /admin/api/v1/settings` (secrets masked). diff --git a/devslab-kit-autoconfigure/build.gradle.kts b/devslab-kit-autoconfigure/build.gradle.kts index 9f2a164..cb70758 100644 --- a/devslab-kit-autoconfigure/build.gradle.kts +++ b/devslab-kit-autoconfigure/build.gradle.kts @@ -21,6 +21,12 @@ dependencies { // Micrometer for custom metrics; consumer's spring-boot-actuator activates it at runtime. compileOnly("io.micrometer:micrometer-core") + + // springdoc-openapi for the optional Swagger UI auto-configuration. compileOnly: + // OpenApiAutoConfiguration is @ConditionalOnClass(GroupedOpenApi), so it stays + // dormant unless the consumer puts springdoc on their own classpath. Version + // pinned in gradle.properties (not managed by the Spring Boot BOM). + compileOnly("org.springdoc:springdoc-openapi-starter-webmvc-api:${property("SPRINGDOC_VERSION")}") } mavenPublishing { diff --git a/devslab-kit-autoconfigure/src/main/java/kr/devslab/kit/autoconfigure/DevslabKitProperties.java b/devslab-kit-autoconfigure/src/main/java/kr/devslab/kit/autoconfigure/DevslabKitProperties.java index dad5988..ccf1d57 100644 --- a/devslab-kit-autoconfigure/src/main/java/kr/devslab/kit/autoconfigure/DevslabKitProperties.java +++ b/devslab-kit-autoconfigure/src/main/java/kr/devslab/kit/autoconfigure/DevslabKitProperties.java @@ -12,6 +12,7 @@ public class DevslabKitProperties { private final Menu menu = new Menu(); private final Audit audit = new Audit(); private final Bootstrap bootstrap = new Bootstrap(); + private final OpenApi openApi = new OpenApi(); public Identity getIdentity() { return identity; @@ -37,6 +38,10 @@ public Audit getAudit() { return audit; } + public OpenApi getOpenApi() { + return openApi; + } + public static class Identity { private boolean enabled = true; @@ -355,4 +360,60 @@ public void setFailOnDefaultPasswordInProd(boolean failOnDefaultPasswordInProd) this.failOnDefaultPasswordInProd = failOnDefaultPasswordInProd; } } + + /** + * Swagger UI / OpenAPI exposure. Only takes effect when springdoc-openapi is on + * the consumer's classpath (the kit declares it {@code compileOnly}); a consumer + * opts in by adding {@code springdoc-openapi-starter-webmvc-ui}. When present and + * {@code enabled} (the default), the kit publishes a grouped OpenAPI spec and the + * Swagger UI. Set {@code devslab.kit.openapi.enabled=false} to turn it off without + * removing the dependency — e.g. in production, where the API docs are usually not + * exposed. + */ + public static class OpenApi { + + /** Master switch for the kit's OpenAPI/Swagger UI wiring. Default ON. */ + private boolean enabled = true; + + /** {@code GroupedOpenApi} group name for the admin API. */ + private String adminGroup = "admin"; + + /** Title shown in the generated OpenAPI document / Swagger UI header. */ + private String title = "devslab-kit Admin API"; + + /** Version string shown in the OpenAPI document. */ + private String version = "v1"; + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public String getAdminGroup() { + return adminGroup; + } + + public void setAdminGroup(String adminGroup) { + this.adminGroup = adminGroup; + } + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } + + public String getVersion() { + return version; + } + + public void setVersion(String version) { + this.version = version; + } + } } diff --git a/devslab-kit-autoconfigure/src/main/java/kr/devslab/kit/autoconfigure/OpenApiAutoConfiguration.java b/devslab-kit-autoconfigure/src/main/java/kr/devslab/kit/autoconfigure/OpenApiAutoConfiguration.java new file mode 100644 index 0000000..abfc4f7 --- /dev/null +++ b/devslab-kit-autoconfigure/src/main/java/kr/devslab/kit/autoconfigure/OpenApiAutoConfiguration.java @@ -0,0 +1,69 @@ +package kr.devslab.kit.autoconfigure; + +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import kr.devslab.kit.admin.AdminApiPaths; +import org.springdoc.core.models.GroupedOpenApi; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; + +/** + * Exposes the admin REST API through OpenAPI / Swagger UI with no consumer wiring — + * add {@code springdoc-openapi-starter-webmvc-ui} to the classpath and + * {@code /swagger-ui.html} (plus {@code /v3/api-docs}) come up, with the kit's + * {@code /admin/api/v1/**} endpoints collected under one group. + * + *
Dormant unless springdoc is present: {@code @ConditionalOnClass(GroupedOpenApi.class)} + * means the kit ships this config but contributes nothing until the consumer opts in + * by adding the springdoc dependency (the kit declares it {@code compileOnly}). It can + * be turned off without removing the dependency via + * {@code devslab.kit.openapi.enabled=false} — typical in production, where the API docs + * are usually not exposed. + * + *
Both beans are {@code @ConditionalOnMissingBean}, so a consumer can define their own
+ * {@link OpenAPI} (e.g. to add security schemes or servers) or their own admin
+ * {@link GroupedOpenApi} and the kit backs off.
+ */
+@AutoConfiguration
+@EnableConfigurationProperties(DevslabKitProperties.class)
+@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET)
+@ConditionalOnClass(GroupedOpenApi.class)
+@ConditionalOnProperty(
+ prefix = "devslab.kit.openapi",
+ name = "enabled",
+ havingValue = "true",
+ matchIfMissing = true
+)
+public class OpenApiAutoConfiguration {
+
+ /**
+ * Groups every {@code /admin/api/v1/**} endpoint into one Swagger UI group so the
+ * admin surface is browsable as a unit, separate from any of the consumer's own
+ * controllers. Backs off if the consumer already declares a {@code GroupedOpenApi}.
+ */
+ @Bean
+ @ConditionalOnMissingBean(name = "devslabKitAdminApiGroup")
+ public GroupedOpenApi devslabKitAdminApiGroup(DevslabKitProperties props) {
+ return GroupedOpenApi.builder()
+ .group(props.getOpenApi().getAdminGroup())
+ .pathsToMatch(AdminApiPaths.BASE + "/**")
+ .build();
+ }
+
+ /**
+ * Default OpenAPI document metadata (title + version). Only contributed if the
+ * consumer hasn't defined their own {@link OpenAPI} bean.
+ */
+ @Bean
+ @ConditionalOnMissingBean
+ public OpenAPI devslabKitOpenApi(DevslabKitProperties props) {
+ return new OpenAPI().info(new Info()
+ .title(props.getOpenApi().getTitle())
+ .version(props.getOpenApi().getVersion()));
+ }
+}
diff --git a/devslab-kit-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/devslab-kit-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
index 7eca95f..e736d08 100644
--- a/devslab-kit-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
+++ b/devslab-kit-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
@@ -6,5 +6,6 @@ kr.devslab.kit.autoconfigure.MenuAutoConfiguration
kr.devslab.kit.autoconfigure.AuditAutoConfiguration
kr.devslab.kit.autoconfigure.AdminApiAutoConfiguration
kr.devslab.kit.autoconfigure.AdminApiWebAutoConfiguration
+kr.devslab.kit.autoconfigure.OpenApiAutoConfiguration
kr.devslab.kit.autoconfigure.BootstrapAutoConfiguration
kr.devslab.kit.autoconfigure.MetricsAutoConfiguration
diff --git a/devslab-kit-sample-app/build.gradle.kts b/devslab-kit-sample-app/build.gradle.kts
index fb73f6e..cbf6991 100644
--- a/devslab-kit-sample-app/build.gradle.kts
+++ b/devslab-kit-sample-app/build.gradle.kts
@@ -17,6 +17,12 @@ dependencies {
implementation("org.springframework.boot:spring-boot-starter-webmvc")
implementation("org.flywaydb:flyway-database-postgresql")
+ // Swagger UI / OpenAPI. The kit's OpenApiAutoConfiguration activates only when
+ // springdoc is on the classpath (a consumer opts in by adding this); it then
+ // exposes /swagger-ui and groups the admin API. Version pinned in gradle.properties
+ // (the Spring Boot BOM doesn't manage springdoc).
+ implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:${property("SPRINGDOC_VERSION")}")
+
compileOnly("org.projectlombok:lombok")
developmentOnly("org.springframework.boot:spring-boot-devtools")
developmentOnly("org.springframework.boot:spring-boot-docker-compose")
diff --git a/devslab-kit-sample-app/src/test/java/kr/devslab/kit/sample/OpenApiDisabledTests.java b/devslab-kit-sample-app/src/test/java/kr/devslab/kit/sample/OpenApiDisabledTests.java
new file mode 100644
index 0000000..b1fbd81
--- /dev/null
+++ b/devslab-kit-sample-app/src/test/java/kr/devslab/kit/sample/OpenApiDisabledTests.java
@@ -0,0 +1,43 @@
+package kr.devslab.kit.sample;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import org.junit.jupiter.api.Test;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.web.server.LocalServerPort;
+import org.springframework.context.annotation.Import;
+import org.springframework.test.context.TestPropertySource;
+
+/**
+ * Proves the kill-switch: {@code devslab.kit.openapi.enabled=false} turns off the kit's
+ * OpenAPI wiring even with springdoc on the classpath. The kit's grouped admin doc must
+ * be gone; springdoc's own bare default may still answer {@code /v3/api-docs}, so this
+ * asserts the kit-specific group ({@code /v3/api-docs/admin}) is absent rather than that
+ * springdoc is fully silenced.
+ */
+@Import(TestcontainersConfiguration.class)
+@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
+@TestPropertySource(properties = "devslab.kit.openapi.enabled=false")
+class OpenApiDisabledTests {
+
+ @LocalServerPort
+ private int port;
+
+ private final HttpClient http = HttpClient.newHttpClient();
+
+ @Test
+ void adminGroupIsAbsentWhenDisabled() throws Exception {
+ HttpResponse The {@code enabled=false} kill-switch is covered by {@link OpenApiDisabledTests}.
+ */
+@Import(TestcontainersConfiguration.class)
+@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
+class OpenApiEndpointTests {
+
+ @LocalServerPort
+ private int port;
+
+ private final HttpClient http = HttpClient.newHttpClient();
+
+ private HttpResponse