Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

첫 공개 릴리스.
Expand Down
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions README.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`을 검증. |

Expand Down Expand Up @@ -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`에서도 볼 수 있습니다(시크릿 마스킹).

Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`. |

Expand Down Expand Up @@ -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).
Expand Down
6 changes: 6 additions & 0 deletions devslab-kit-autoconfigure/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -37,6 +38,10 @@ public Audit getAudit() {
return audit;
}

public OpenApi getOpenApi() {
return openApi;
}

public static class Identity {

private boolean enabled = true;
Expand Down Expand Up @@ -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;
}
}
}
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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.
*
* <p>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()));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
6 changes: 6 additions & 0 deletions devslab-kit-sample-app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String> response = http.send(
HttpRequest.newBuilder()
.uri(URI.create("http://localhost:" + port + "/v3/api-docs/admin"))
.GET().build(),
HttpResponse.BodyHandlers.ofString());

// No kit-defined "admin" group -> springdoc has no such group -> not 200.
assertThat(response.statusCode()).isNotEqualTo(200);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
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;

/**
* Verifies the kit's OpenAPI / Swagger UI auto-configuration over the real HTTP stack:
* with springdoc on the classpath and no consumer wiring, {@code /v3/api-docs} serves a
* valid spec (this also catches the springdoc Jackson-2 vs Spring Boot 4 Jackson-3
* serialization risk — a broken doc would 500 or return non-JSON), {@code /swagger-ui}
* is reachable, and both are public (not blocked by the admin security chain).
*
* <p>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<String> get(String path) throws Exception {
return http.send(
HttpRequest.newBuilder().uri(URI.create("http://localhost:" + port + path)).GET().build(),
HttpResponse.BodyHandlers.ofString());
}

@Test
void apiDocsServesValidJsonWithoutAuth() throws Exception {
HttpResponse<String> response = get("/v3/api-docs");

assertThat(response.statusCode()).isEqualTo(200);
// Real serialization happened (guards against the springdoc/Jackson mismatch):
// a parseable OpenAPI document with our configured title.
assertThat(response.body()).contains("\"openapi\"");
assertThat(response.body()).contains("devslab-kit Admin API");
}

@Test
void adminApiGroupIsExposed() throws Exception {
HttpResponse<String> response = get("/v3/api-docs/admin");

assertThat(response.statusCode()).isEqualTo(200);
// The grouped doc only matches /admin/api/v1/** — its paths should reflect that.
assertThat(response.body()).contains("/admin/api/v1");
}

@Test
void swaggerUiIsReachableWithoutAuth() throws Exception {
// springdoc redirects /swagger-ui.html -> /swagger-ui/index.html; either the
// redirect (3xx) or the page (200) proves it's public, not a 401/403.
HttpResponse<String> response = http.send(
HttpRequest.newBuilder()
.uri(URI.create("http://localhost:" + port + "/swagger-ui/index.html"))
.GET().build(),
HttpResponse.BodyHandlers.ofString());

assertThat(response.statusCode()).isEqualTo(200);
}
}
28 changes: 28 additions & 0 deletions docs/reference/configuration.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,3 +104,31 @@

[최초 관리자 부트스트랩 가이드](../guides/bootstrap.md),
[ADR 0001](../adr/0001-bootstrap-admin.md) 참고.

## OpenAPI / Swagger UI — `devslab.kit.openapi.*` { #openapi }

킷은 **springdoc이 classpath에 있을 때** OpenAPI와 Swagger UI를 자동 구성합니다 —
springdoc을 `compileOnly`로 가지고 있으므로, 의존성을 추가해 opt-in 합니다:

```kotlin
implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.3")
```

추가하면 추가 설정 없이 `/swagger-ui.html`과 `/v3/api-docs`가 올라오고, 킷의
`/admin/api/v1/**` 엔드포인트가 하나의 그룹(`/v3/api-docs/admin`)으로 묶입니다.
springdoc `3.0.x`가 Spring Boot 4 라인입니다(`2.8.x`는 Spring Boot 3 대상).

| 프로퍼티 | 타입 | 기본값 | 설명 |
| --- | --- | --- | --- |
| `enabled` | boolean | `true` | 마스터 스위치. `false`로 두면 의존성을 제거하지 않고도 킷의 OpenAPI 구성을 끕니다. |
| `admin-group` | string | `admin` | 관리자 API의 Swagger UI 그룹 이름. |
| `title` | string | `devslab-kit Admin API` | OpenAPI 문서 / Swagger UI에 표시될 제목. |
| `version` | string | `v1` | OpenAPI 문서의 버전 문자열. |

`OpenAPI` 문서 빈과 관리자 `GroupedOpenApi` 모두 `@ConditionalOnMissingBean`이므로,
직접 선언하면(예: security scheme이나 server 추가) 킷이 물러납니다.

!!! tip "프로덕션"
API 문서는 보통 프로덕션에 노출하지 않습니다.
`devslab.kit.openapi.enabled=false`로 끄거나(또는 프로덕션 빌드에서 springdoc
의존성을 빼서) 해당 표면을 비활성화하세요.
Loading
Loading