merge: feature/techlog-studio-backend — Tech Log Studio 백엔드 기반 (Plan 01)

계약 배선·오류 코드 23종·bounded context 경계 규칙 7종·V7 스키마 28테이블·
엔드포인트 2종(getStudioSession, listStudioCatalog)·드리프트 게이트 4종.

검증: 1951 tests / 0 failures / 4 skipped
  app-bootstrap 828 · adapter:inbound:web 356 · application-core 568 · persistence-jpa 199

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-08-19 15:15:07 +09:00
co-authored by Claude Opus 5
48 changed files with 9495 additions and 220 deletions
+312
View File
@@ -916,3 +916,315 @@ errors:
runbook_link: "runbook://management/actuator-forbidden"
compatibility_impact: none
required_test: contract-verification:management-actuator
# ============================================================
# TECH LOG STUDIO (studio-v1.yaml ApiError.code — 23종, feature-techlog-studio-backend)
#
# StudioError(dev.caskeleton.application.techlog.error.StudioError)와 1:1 매핑.
# category/http_status/retryable은 그 enum의 선언과 정확히 같아야 한다
# (StudioErrorRegistryTest가 코드 존재만 보고, 값 일치는 이 파일의 리뷰 책임).
#
# PAYLOAD_TOO_LARGE / UNSUPPORTED_MEDIA_TYPE은 StudioError에도 있지만 별도 row를
# 추가하지 않는다 — feature-api-contract-baseline이 이미 동일 code로 아래(L514,
# L545)에 VALIDATION/413/415/false, VALIDATION/415/false row를 갖고 있고 값이
# StudioError 선언과 정확히 일치한다. error-codes.yaml의 identity column은 `code`
# 하나뿐이라 같은 code로 두 번째 row를 추가하면 ContractRegistrySchemaGovernanceTest
# 의 "duplicate identity" 게이트가 깨진다. 즉 이 두 코드는 기존 row가 이미 커버한다.
# ============================================================
# source: studio-v1.yaml ApiError.code — AUTHENTICATION_REQUIRED (StudioError.AUTHENTICATION_REQUIRED)
- code: AUTHENTICATION_REQUIRED
category: AUTH
http_status: 401
retryable: false
retry_after_seconds: null
owner_branch: feature-techlog-studio-backend
owner_layer: presentation
client_safe_message: "Studio 인증이 필요합니다"
log_level: INFO
runbook_link: "runbook://auth/token-missing"
compatibility_impact: additive
required_test: StudioErrorTest
# source: studio-v1.yaml ApiError.code — STUDIO_ACCESS_DENIED (StudioError.STUDIO_ACCESS_DENIED)
- code: STUDIO_ACCESS_DENIED
category: AUTHZ
http_status: 403
retryable: false
retry_after_seconds: null
owner_branch: feature-techlog-studio-backend
owner_layer: application
client_safe_message: "이 Studio 리소스에 접근할 권한이 없습니다"
log_level: WARN
runbook_link: "runbook://authz/insufficient-permission"
compatibility_impact: additive
required_test: StudioErrorTest
# source: studio-v1.yaml ApiError.code — DOCUMENT_NOT_FOUND (StudioError.DOCUMENT_NOT_FOUND)
- code: DOCUMENT_NOT_FOUND
category: NOT_FOUND
http_status: 404
retryable: false
retry_after_seconds: null
owner_branch: feature-techlog-studio-backend
owner_layer: application
client_safe_message: "요청한 문서를 찾을 수 없습니다"
log_level: INFO
runbook_link: null
compatibility_impact: additive
required_test: StudioErrorTest
# source: studio-v1.yaml ApiError.code — VERSION_CONFLICT (StudioError.VERSION_CONFLICT)
- code: VERSION_CONFLICT
category: CONFLICT
http_status: 409
retryable: false
retry_after_seconds: null
owner_branch: feature-techlog-studio-backend
owner_layer: application
client_safe_message: "저장된 version이 더 최신입니다"
log_level: INFO
runbook_link: null
compatibility_impact: additive
required_test: StudioErrorTest
# source: studio-v1.yaml ApiError.code — REQUEST_VALIDATION_FAILED (StudioError.REQUEST_VALIDATION_FAILED)
- code: REQUEST_VALIDATION_FAILED
category: VALIDATION
http_status: 422
retryable: false
retry_after_seconds: null
owner_branch: feature-techlog-studio-backend
owner_layer: presentation
client_safe_message: "요청 형식이 올바르지 않습니다"
log_level: WARN
runbook_link: null
compatibility_impact: additive
required_test: StudioErrorTest
# source: studio-v1.yaml ApiError.code — DOCUMENT_VALIDATION_FAILED
# (StudioError.DOCUMENT_VALIDATION_FAILED). 원래 계약 이름은 VALIDATION_FAILED였으나
# 스켈레톤 전역 OperationalError.VALIDATION_FAILED(400, VALIDATION)와 code 문자열이
# 충돌해(같은 문자열, 다른 http_status) 개명했다 — controller 판정, 2026-08-19.
- code: DOCUMENT_VALIDATION_FAILED
category: VALIDATION
http_status: 422
retryable: false
retry_after_seconds: null
owner_branch: feature-techlog-studio-backend
owner_layer: application
client_safe_message: "문서 검증에 실패했습니다"
log_level: WARN
runbook_link: null
compatibility_impact: additive
required_test: StudioErrorTest
# source: studio-v1.yaml ApiError.code — VALIDATION_STALE (StudioError.VALIDATION_STALE)
- code: VALIDATION_STALE
category: CONFLICT
http_status: 409
retryable: false
retry_after_seconds: null
owner_branch: feature-techlog-studio-backend
owner_layer: application
client_safe_message: "검증 결과가 최신 문서 기준이 아닙니다. 다시 검증해 주세요"
log_level: INFO
runbook_link: null
compatibility_impact: additive
required_test: StudioErrorTest
# source: studio-v1.yaml ApiError.code — PREVIEW_NOT_FOUND (StudioError.PREVIEW_NOT_FOUND)
- code: PREVIEW_NOT_FOUND
category: NOT_FOUND
http_status: 404
retryable: false
retry_after_seconds: null
owner_branch: feature-techlog-studio-backend
owner_layer: application
client_safe_message: "요청한 미리보기를 찾을 수 없습니다"
log_level: INFO
runbook_link: null
compatibility_impact: additive
required_test: StudioErrorTest
# source: studio-v1.yaml ApiError.code — PREVIEW_STALE (StudioError.PREVIEW_STALE)
- code: PREVIEW_STALE
category: CONFLICT
http_status: 409
retryable: false
retry_after_seconds: null
owner_branch: feature-techlog-studio-backend
owner_layer: application
client_safe_message: "미리보기가 최신 문서 기준이 아닙니다. 다시 생성해 주세요"
log_level: INFO
runbook_link: null
compatibility_impact: additive
required_test: StudioErrorTest
# source: studio-v1.yaml ApiError.code — PREVIEW_EXPIRED (StudioError.PREVIEW_EXPIRED)
- code: PREVIEW_EXPIRED
category: CONFLICT
http_status: 409
retryable: false
retry_after_seconds: null
owner_branch: feature-techlog-studio-backend
owner_layer: application
client_safe_message: "미리보기가 만료되었습니다. 다시 생성해 주세요"
log_level: INFO
runbook_link: null
compatibility_impact: additive
required_test: StudioErrorTest
# source: studio-v1.yaml ApiError.code — PUBLICATION_NOT_FOUND (StudioError.PUBLICATION_NOT_FOUND)
- code: PUBLICATION_NOT_FOUND
category: NOT_FOUND
http_status: 404
retryable: false
retry_after_seconds: null
owner_branch: feature-techlog-studio-backend
owner_layer: application
client_safe_message: "요청한 게시물을 찾을 수 없습니다"
log_level: INFO
runbook_link: null
compatibility_impact: additive
required_test: StudioErrorTest
# source: studio-v1.yaml ApiError.code — PUBLICATION_CONFLICT (StudioError.PUBLICATION_CONFLICT)
- code: PUBLICATION_CONFLICT
category: CONFLICT
http_status: 409
retryable: false
retry_after_seconds: null
owner_branch: feature-techlog-studio-backend
owner_layer: application
client_safe_message: "게시 작업이 다른 변경과 충돌했습니다"
log_level: INFO
runbook_link: null
compatibility_impact: additive
required_test: StudioErrorTest
# source: studio-v1.yaml ApiError.code — PUBLICATION_EVENT_NOT_FOUND (StudioError.PUBLICATION_EVENT_NOT_FOUND)
- code: PUBLICATION_EVENT_NOT_FOUND
category: NOT_FOUND
http_status: 404
retryable: false
retry_after_seconds: null
owner_branch: feature-techlog-studio-backend
owner_layer: application
client_safe_message: "요청한 게시 이벤트를 찾을 수 없습니다"
log_level: INFO
runbook_link: null
compatibility_impact: additive
required_test: StudioErrorTest
# source: studio-v1.yaml ApiError.code — PUBLICATION_SNAPSHOT_NOT_FOUND (StudioError.PUBLICATION_SNAPSHOT_NOT_FOUND)
- code: PUBLICATION_SNAPSHOT_NOT_FOUND
category: NOT_FOUND
http_status: 404
retryable: false
retry_after_seconds: null
owner_branch: feature-techlog-studio-backend
owner_layer: application
client_safe_message: "요청한 게시 스냅샷을 찾을 수 없습니다"
log_level: INFO
runbook_link: null
compatibility_impact: additive
required_test: StudioErrorTest
# source: studio-v1.yaml ApiError.code — WARNING_ACKNOWLEDGEMENT_REQUIRED (StudioError.WARNING_ACKNOWLEDGEMENT_REQUIRED)
- code: WARNING_ACKNOWLEDGEMENT_REQUIRED
category: VALIDATION
http_status: 422
retryable: false
retry_after_seconds: null
owner_branch: feature-techlog-studio-backend
owner_layer: application
client_safe_message: "경고 확인이 필요합니다. 확인 후 다시 시도해 주세요"
log_level: WARN
runbook_link: null
compatibility_impact: additive
required_test: StudioErrorTest
# source: studio-v1.yaml ApiError.code — IDEMPOTENCY_KEY_REUSED (StudioError.IDEMPOTENCY_KEY_REUSED)
- code: IDEMPOTENCY_KEY_REUSED
category: CONFLICT
http_status: 409
retryable: false
retry_after_seconds: null
owner_branch: feature-techlog-studio-backend
owner_layer: application
client_safe_message: "Idempotency 키가 다른 요청에 재사용되었습니다"
log_level: INFO
runbook_link: null
compatibility_impact: additive
required_test: StudioErrorTest
# source: studio-v1.yaml ApiError.code — ASSET_NOT_FOUND (StudioError.ASSET_NOT_FOUND)
- code: ASSET_NOT_FOUND
category: NOT_FOUND
http_status: 404
retryable: false
retry_after_seconds: null
owner_branch: feature-techlog-studio-backend
owner_layer: application
client_safe_message: "요청한 자산을 찾을 수 없습니다"
log_level: INFO
runbook_link: null
compatibility_impact: additive
required_test: StudioErrorTest
# source: studio-v1.yaml ApiError.code — ASSET_NOT_READY (StudioError.ASSET_NOT_READY)
- code: ASSET_NOT_READY
category: CONFLICT
http_status: 409
retryable: false
retry_after_seconds: null
owner_branch: feature-techlog-studio-backend
owner_layer: application
client_safe_message: "자산 처리가 아직 완료되지 않았습니다"
log_level: INFO
runbook_link: null
compatibility_impact: additive
required_test: StudioErrorTest
# source: studio-v1.yaml ApiError.code — ASSET_IN_USE (StudioError.ASSET_IN_USE)
- code: ASSET_IN_USE
category: CONFLICT
http_status: 409
retryable: false
retry_after_seconds: null
owner_branch: feature-techlog-studio-backend
owner_layer: application
client_safe_message: "자산이 사용 중이라 이 작업을 수행할 수 없습니다"
log_level: INFO
runbook_link: null
compatibility_impact: additive
required_test: StudioErrorTest
# source: studio-v1.yaml ApiError.code — ASSET_QUARANTINED (StudioError.ASSET_QUARANTINED)
- code: ASSET_QUARANTINED
category: DATA_INTEGRITY
http_status: 409
retryable: false
retry_after_seconds: null
owner_branch: feature-techlog-studio-backend
owner_layer: application
client_safe_message: "자산이 격리 처리되어 사용할 수 없습니다"
log_level: WARN
runbook_link: null
compatibility_impact: additive
required_test: StudioErrorTest
# source: studio-v1.yaml ApiError.code — STUDIO_UNAVAILABLE (StudioError.STUDIO_UNAVAILABLE)
- code: STUDIO_UNAVAILABLE
category: TRANSIENT_DEPENDENCY
http_status: 503
retryable: true
retry_after_seconds: 5
owner_branch: feature-techlog-studio-backend
owner_layer: infrastructure
client_safe_message: "Studio 서비스를 일시적으로 사용할 수 없습니다. 잠시 후 다시 시도해 주세요"
log_level: ERROR
runbook_link: "runbook://studio/unavailable"
compatibility_impact: additive
required_test: StudioErrorTest
+5 -2
View File
@@ -1,10 +1,10 @@
---
title: Runbook — AUTH_TOKEN_MISSING (인증 토큰 누락)
category: AUTH
error_codes: [AUTH_TOKEN_MISSING]
error_codes: [AUTH_TOKEN_MISSING, AUTHENTICATION_REQUIRED]
severity: P3
owner: oncall
last_updated: 2026-06-15
last_updated: 2026-08-18
status: stub
---
@@ -14,6 +14,9 @@ status: stub
- HTTP 401 responses with `error.code=AUTH_TOKEN_MISSING`
- Client missing Authorization header or Bearer token
- Tech Log Studio (`studio-v1.yaml`) surfaces the same missing-authentication scenario as
`error.code=AUTHENTICATION_REQUIRED` — same root cause, Studio-scoped code
(feature-techlog-studio-backend, `StudioError.AUTHENTICATION_REQUIRED`)
## Diagnosis
@@ -1,10 +1,10 @@
---
title: Runbook — AUTHZ_INSUFFICIENT_PERMISSION (권한 부족)
category: AUTHZ
error_codes: [AUTHZ_INSUFFICIENT_PERMISSION]
error_codes: [AUTHZ_INSUFFICIENT_PERMISSION, STUDIO_ACCESS_DENIED]
severity: P3
owner: oncall
last_updated: 2026-06-15
last_updated: 2026-08-18
status: stub
---
@@ -14,6 +14,9 @@ status: stub
- HTTP 403 with `error.code=AUTHZ_INSUFFICIENT_PERMISSION`
- Valid token but missing required role or permission
- Tech Log Studio (`studio-v1.yaml`) surfaces the same resource-authorization scenario as
`error.code=STUDIO_ACCESS_DENIED` — same root cause, Studio-scoped code
(feature-techlog-studio-backend, `StudioError.STUDIO_ACCESS_DENIED`)
## Diagnosis
+95
View File
@@ -0,0 +1,95 @@
---
title: Runbook — STUDIO_UNAVAILABLE (Tech Log Studio 일시 장애)
category: TRANSIENT_DEPENDENCY
error_codes: [STUDIO_UNAVAILABLE]
severity: P1
owner: oncall
last_updated: 2026-08-18
status: active
---
# Runbook: STUDIO_UNAVAILABLE (`runbook://studio/unavailable`)
## 1. Trigger
이 runbook은 Tech Log Studio API(`studio-v1.yaml`)가 `error.code=STUDIO_UNAVAILABLE`
(HTTP 503, category `TRANSIENT_DEPENDENCY`, `retryable=true`)을 응답할 때 발동됩니다.
- alert name: `studio_error_rate_critical` 또는 `studio_dependency_unavailable`
- alert payload 필수 field: `operation`(문서/미리보기/게시 중 어느 슬라이스인지), `error.code`,
`error.category`, `dependency_name`(가능하면), `runbook_link`
- 임계:
- P1: `STUDIO_UNAVAILABLE` 비율이 1분간 Studio 전체 트래픽의 10% 초과
- P2: 단발성 spike이나 5분 내 자연 회복
`StudioExceptionHandler`(adapter/inbound/web)가 `StudioException`을 봉투로 변환하는
지점이므로, 이 코드는 항상 Studio facade/use case(`application-core`)가 자신의 하위
의존성(영속성, 캐시, 오브젝트 스토리지, 렌더링/미리보기 파이프라인 등) 실패를
클라이언트에 안전한 단일 코드로 접어(classify) 던진 결과입니다 — 원인 그 자체가 아니라
**Studio가 판단한 결과**라는 점을 유의합니다.
## 2. First Response (5분 이내)
### Step 1 — 확인
1. 최근 배포 이력 확인 (`app-bootstrap` 롤아웃, config 변경) — 배포 직후 spike면 롤백 우선 검토
2. 로그에서 `StudioException`이 감싸고 있던 실제 원인을 확인: `dev.caskeleton.application.techlog`
패키지의 use case/facade 로그에서 `STUDIO_UNAVAILABLE`로 분류되기 직전의 원인 예외
(`PersistenceFailureException`, `DependencyFailureException` 등)를 추적
3. runtime-health Dependency Matrix에서 Studio가 의존하는 구성요소(DB, 캐시, object storage)의
required/optional 분류와 현재 상태 확인
4. 특정 slice(문서 편집/미리보기/게시)에 국한된 장애인지, Studio 전역 장애인지 구분
### Step 2 — 임시 격리
- 원인이 특정 하위 의존성이면 해당 의존성의 runbook으로 전환 (예: `runbook://db/unavailable`,
`runbook://cache/unavailable`) — `STUDIO_UNAVAILABLE`은 진입점일 뿐, 근본 원인 대응은
하위 의존성 runbook이 담당
- 클라이언트(Frontend)는 이미 `retryable=true`를 신뢰해 지수 백오프 재시도를 수행하므로,
단기 spike는 자연 회복을 우선 관찰 (조기 개입으로 인한 추가 부하 유발 방지)
## 3. Diagnosis
- log query: `{service="app"} | error.code="STUDIO_UNAVAILABLE" | stats count by operation`
- 원인 추적: 같은 요청의 correlation id로 use case 로그를 따라가 어떤 하위 호출이
`StudioException.withDetails(StudioError.STUDIO_UNAVAILABLE, ...)`로 재분류됐는지 확인
- metric panel:
- `http_server_requests_seconds_count{uri=~"/api/v1/studio/.*", outcome="SERVER_ERROR"}`
- 하위 의존성 metric (`hikaricp_connections_active`, `resilience4j_circuitbreaker_state`,
object storage client 오류율)
- 가능한 원인:
- DB/캐시/오브젝트 스토리지 등 필수 의존성 장애가 Studio 계층까지 전파
- Studio 자체 리소스 고갈 (스레드풀, 커넥션풀)
- 미리보기/렌더링 파이프라인의 타임아웃 누적
- 배포 직후 신규 코드 경로의 미검증 예외가 fallback으로 `STUDIO_UNAVAILABLE`에 접힘
## 4. Mitigation
- 단기: 근본 원인이 확인된 하위 의존성이면 해당 dependency runbook의 mitigation을 적용
- Studio 자체 리소스 고갈이면 인스턴스 스케일아웃 또는 커넥션/스레드풀 상향 검토
- 특정 slice(예: 미리보기)만 영향받는다면 해당 slice만 일시적으로 기능 차단하고
나머지(문서 편집/게시)는 정상 유지 검토 — 전면 장애보다 부분 degrade 우선
- 장기: 반복되는 하위 의존성 장애가 `STUDIO_UNAVAILABLE`로 잦게 나타나면, 해당 의존성의
circuit breaker/timeout 임계를 재조정하고 fallback 경로 보강
## 5. Escalation
- P1 5분 내 회복 신호 없으면 해당 하위 의존성 오너 팀에 page
- 여러 slice(문서/미리보기/게시)에서 동시에 발생하면 incident commander 호출
(공유 인프라 계층 문제 의심)
## 6. Recovery / Verification
- 회복 확인 metric: `STUDIO_UNAVAILABLE` 비율이 5분간 1% 미만으로 유지
- 하위 의존성 metric(circuit breaker CLOSED, connection pool 정상)도 함께 확인
- post-incident:
- 어떤 하위 의존성이 `STUDIO_UNAVAILABLE`로 접혔는지 기록하고 근본 원인 runbook에 링크
- Studio 클라이언트(Frontend `STUDIO_ERROR_CODES`) 쪽 재시도/백오프 동작이 기대대로
작동했는지 확인
- 특정 slice 반복 장애면 chaos test 시나리오 추가 검토
## 7. Related
- error-codes.yaml row: `STUDIO_UNAVAILABLE` (feature-techlog-studio-backend,
`dev.caskeleton.application.techlog.error.StudioError.STUDIO_UNAVAILABLE`)
- 매핑 지점: `dev.caskeleton.adapter.inbound.web.techlog.StudioExceptionHandler`
- 관련 runbook: `runbook://db/unavailable`, `runbook://cache/unavailable`
- 관련 branch: [[feature-techlog-studio-backend]]
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,870 @@
# Tech Log Studio Backend — 설계
- 작성일: 2026-08-18
- 대상 저장소: `tech-log-backend` (`clean-architecture-backend-template` 스냅샷)
- 브랜치: `feature/techlog-studio-backend`
- 설계 원본: `/home/donghyeon/workspace/tech-log-design-package`
- 소비자: `/home/donghyeon/workspace/desktop-server-git/tech-log-frontend` (Studio SPA, 구현 완료)
---
## 1. 배경
설계 패키지가 Tech Log의 제품·정보구조·계약·DB·백엔드 모듈을 확정했다. 프론트는
Studio SPA가 이미 구현되어 있고 백엔드만 없다. 이 문서는 **설계 패키지의 결정을
이 저장소의 구조·게이트에 맞춰 어떻게 구현할지**를 정의한다.
설계 패키지의 `docs/plans/02-backend-core-plan.md`는 본문에 STALE 배너가 붙어 있고
파일맵도 `services/api/...`라 이 저장소에 적용되지 않는다. 이 문서가 그 자리를
대신한다.
### 1.1 계약 정합 (검증됨)
`tech-log-design-package/scripts/check-contract-parity.py` 실행 결과:
```text
Frontend operation 19개 / Backend primary operation 19개
[OK] Frontend operation 13개가 모두 Backend primary 계약에 존재한다.
[OK] 공통 operation의 method/path가 모두 일치한다.
RecordKind FE=BE=['CASE','PROJECT_DECISION','QUESTION','REFERENCE']
오류 코드 FE=23개 / BE=23개 [OK]
```
`contracts/openapi/studio-v1.yaml`이 프론트가 실제로 호출하는 계약 그대로다.
---
## 2. 범위
### 2.1 In scope
`studio-v1.yaml`의 19개 operation 전부.
| operation | method · path |
| --- | --- |
| `getStudioSession` | GET `/api/v1/studio/session` |
| `getStudioDashboard` | GET `/api/v1/studio/dashboard` |
| `listStudioDocuments` | GET `/api/v1/studio/documents` |
| `createStudioDocument` | POST `/api/v1/studio/documents` |
| `getStudioDocument` | GET `/api/v1/studio/documents/{documentId}` |
| `saveStudioDocument` | PUT `/api/v1/studio/documents/{documentId}` |
| `validateStudioDocument` | POST `/api/v1/studio/documents/{documentId}/validate` |
| `createStudioPreview` | POST `/api/v1/studio/documents/{documentId}/preview` |
| `getCurrentStudioPreview` | GET `/api/v1/studio/documents/{documentId}/preview` |
| `publishStudioDocument` | POST `/api/v1/studio/documents/{documentId}/publish` |
| `listStudioPublications` | GET `/api/v1/studio/publications` |
| `unpublishStudioPublication` | POST `/api/v1/studio/publications/{publicationId}/unpublish` |
| `getStudioPublicationSnapshot` | GET `/api/v1/studio/publications/{publicationEventId}/preview` |
| `listStudioCatalog` | GET `/api/v1/studio/catalog` |
| `listStudioAssets` | GET `/api/v1/studio/assets` |
| `uploadStudioAsset` | POST `/api/v1/studio/assets` |
| `getStudioAsset` | GET `/api/v1/studio/assets/{assetId}` |
| `updateStudioAsset` | PUT `/api/v1/studio/assets/{assetId}` |
| `deleteStudioAsset` | DELETE `/api/v1/studio/assets/{assetId}` |
`RecordKind` 4종(`CASE`, `REFERENCE`, `QUESTION`, `PROJECT_DECISION`)을 하나의 문서
편집 흐름으로 다룬다.
### 2.2 Out of scope (이번 브랜치)
- `contracts/openapi/public-v1.yaml` (Public 조회 API) — 소비자(Astro Public 사이트)가
아직 없다.
- `contracts/openapi/studio-management-v1.yaml` (secondary capability 보존 계약).
- `release`, `topic`/`tag` 관리 UI, `identity`(SiteConfig/Profile/HomeFocus) 편집 API.
- 이번 범위에 필요한 만큼의 `topic`/`project` **조회**는 catalog에서 다루되, 그
편집 API는 만들지 않는다.
---
## 3. 상위 결정
| ID | 결정 | 근거 |
| --- | --- | --- |
| D1 | 새 Gradle leaf를 만들지 않고 기존 18 leaf **안의 하위 패키지**로 배치한다 | `src/settings.gradle``expectedModuleCount = 18`로 fail-closed 검증. leaf 추가는 registry·settings·dependency gate·ArchUnit을 동시에 바꾸는 template-level 변경이고 `template.lock.json` 기반 향후 sync와 충돌한다 |
| D2 | 패키지는 `dev.caskeleton.{domain,application,adapter...}` **루트 아래**에 `techlog` 하위로 넣는다 | 일부 ArchUnit 규칙이 `dev.caskeleton.application..`처럼 루트를 고정한다(`CleanArchitectureTest.java:221`). 다른 루트에 두면 가드레일이 조용히 미적용된다 |
| D3 | 계약 우선(openapi-generator)으로 **DTO(model)만** 생성하고 controller는 얇게 손으로 쓴다 (`globalProperties.set(['models': ''])`, `useOneOfInterfaces=false`) — 실제 설정은 §5.2, 실측과의 차이는 §3.1 | ADR-004를 유지하되 D4와 충돌하지 않는 형태. 생성 API interface는 봉투 wrapper 타입을 반환하게 되고, 그 타입은 `dev.caskeleton.shared.response.Envelope`가 아니라서 `EnvelopeBodyAdvice`**한 번 더 감싼다**(이중 래핑). 드리프트 위험의 실체는 93개 schema·enum·required 필드이고 그건 model 생성으로 덮인다. controller 19개 signature 드리프트는 §5.5 회귀 테스트가 잡는다 |
| D4 | `/api/v1/studio/**`도 템플릿 응답 봉투를 **그대로 쓴다**. 계약을 봉투 형태로 재정의한다 | 봉투는 `shared-contract/README.md:230`의 문서화된 결정(boundary D5/D6)이고 정보 손실이 없다. 적응 코드를 백엔드 *템플릿* 파일이 아니라 프론트 *제품* 코드에 두는 편이 template sync 충돌이 없다(§5.3) |
| D5 | `studio_idempotency` 테이블을 만들지 않고 기존 `idempotency_record`를 쓴다 | 기존 스키마가 설계 요구의 상위집합이고 `IdempotencyExecutorV2`까지 있다(§8.2) |
| D6 | Studio 통합 목록은 물리 인덱스 테이블 없이 union query로 시작한다 | 설계 08장 §4. 성능이 입증되면 read model 추가 |
| D7 | 커밋은 하지 않는다 | `AGENTS.md:64` — commit 정책 `human-only` |
### 3.1 D3 정정 — 실제 구현과의 차이 (2026-08-19, final whole-branch review B2)
이 절 작성 시점(§12 슬라이스 0 계획 단계)에는 openapi-generator 7.x가 이 저장소의 Spring Boot
버전과 함께 검증된 바 없었고, §5.2가 예정한 `generateApis=false` / `generateModels=true`
실제 스파이크(슬라이스 0)에서 두 가지가 틀린 것으로 드러났다. 아래는 spec이 스스로 요구한
"폴백을 쓰면 D3을 갱신한다"(구 §5.2)는 약속이 이행되지 않고 있던 것을 바로잡는 정정이다 —
구현이 폴백으로 넘어간 것은 아니고(생성기는 여전히 model을 생성한다), **생성기 설정 자체가
설계 시점에 존재하지 않는 API를 가정**했던 것이 실측으로 확인됐다.
- **`generateApis`/`generateModels`는 openapi-generator-gradle-plugin 7.18.0의 `openApiGenerate`
확장에 존재하지 않는다** (디컴파일로 확인, task-4-report.md). 대신 CLI `--global-property`
같은 뜻인 `globalProperties.set(['models': ''])`로 "models만" 생성하도록 제한한다. 실제 설정은
§5.2를 그대로 참조.
- **생성 소스는 `sourceSets.main.java.srcDir`에 있지 않다.** 별도 `generatedOpenapi` sourceSet에
있고, `main`/`test`의 compile·runtime classpath와 `jar` 산출물에 각각 명시적으로 이어 붙인다
(`src/adapter/inbound/web/build.gradle:22-38`). 이유: 생성 코드가 deprecated
`org.springframework.lang.Nullable`을 참조하는데, 저장소 루트 `build.gradle`
`-Werror`/`-Xlint:deprecation`이 이걸 컴파일 실패로 승격한다. `main`에 직접 넣으면 그 플래그가
생성 코드에도 적용돼 빌드가 깨진다 — 별도 sourceSet으로 분리하고 그 sourceSet의
`compileGeneratedOpenapiJava` 태스크에서만 `-Werror`/`-Xlint:deprecation`을 뺀다.
- **spec에 없던 `useOneOfInterfaces=false`가 결정적 옵션으로 추가됐다.** discriminator(`oneOf`)
union을 부모 Java interface로 생성하면(`useOneOfInterfaces=true`, openapi-generator 기본값)
discriminator getter가 항상 `String`을 반환하도록 SpringCodegen이 고정하는데, 하위 타입의 실제
getter 타입(공유 enum이든 아니든)과 충돌해 컴파일이 깨진다. 판별 필드를 하위 타입에서
narrowing하지 않도록 계약을 고쳐도 동일하게 깨지는 것까지 스크래치에서 직접 검증했다(3개 설정
조합, task-4-report.md) — 계약 쪽에서 우회할 수 없는 SpringCodegen 자체의 제약이다.
**`useOneOfInterfaces=false`의 대가 — 생성 union 5종이 파손됐다.** 이 경고는 지금
`src/adapter/inbound/web/build.gradle:139-155`의 주석에만 있고 spec 본문에는 없었다 — Plan 02
작성자가 읽는 문서는 이 spec이므로 여기 옮긴다.
`useOneOfInterfaces=false`는 컴파일은 통과시키지만, discriminator union 5종
(`WorkingCopyInput`, `WorkingCopy`, `Inline`, `CaseRenderBlock`, `PublicRenderModel`)의 하위
타입이 Java `implements` 관계를 전혀 갖지 않는 독립 클래스로 생성된다. 실측 결과 Jackson
양방향 배선도 계약을 어긴다:
- **역직렬화**는 `InvalidTypeIdException`으로 실패한다 (예: `Class CaseInput not subtype of
WorkingCopyInput`).
- **직렬화**는 생성된 `@JsonIgnoreProperties(value="kind", allowSetters=true)` 때문에 실제
discriminator 값 대신 클래스 simple name이 나간다 (예: 응답에 `"kind":"WorkingCopyInput"`).
이 union들을 요청·응답에 직접 또는 (409 `VersionConflictDetails`처럼) 간접적으로 포함하는
operation은 최소 `createStudioDocument`/`getStudioDocument`/`saveStudioDocument`
(`WorkingCopyInput`/`WorkingCopy`)와 `createStudioPreview`/`getCurrentStudioPreview`/
`getStudioPublicationSnapshot`(`PublicRenderModel`)이며, `validateStudioDocument`를 포함해
409 conflict 응답 경로로 더 넓게 새어 들어갈 수 있다 — Task 8/9(`getStudioSession`,
`listStudioCatalog`)는 이 union들을 쓰지 않아 막히지 않았을 뿐, 영향받는 operation의 정확한
목록과 대응 전략(수동 Jackson `@JsonTypeInfo`/`@JsonSubTypes` 재작성, 계약 재구조화, 또는 별도
수기 DTO)은 **Plan 02가 착수 전에 확정해야 한다.**
---
## 4. 코드 배치
### 4.1 패키지
```text
:domain-core
dev.caskeleton.domain.techlog.content Document / CaseDetail / ReferenceDetail
dev.caskeleton.domain.techlog.inquiry OpenQuestion
dev.caskeleton.domain.techlog.project ProjectDecision
dev.caskeleton.domain.techlog.asset Asset
dev.caskeleton.domain.techlog.publication Publication / PublicationEvent
dev.caskeleton.domain.techlog.<ctx>.vo Value Object
:application-core
dev.caskeleton.application.techlog.<ctx>.port.in Command · Query · *UseCase 인터페이스
dev.caskeleton.application.techlog.<ctx>.port.out Repository · Renderer · Clock 포트
dev.caskeleton.application.techlog.<ctx>.service *UseCase 구현
dev.caskeleton.application.techlog.studio.facade WorkingCopy / Validation / Preview / Publication
dev.caskeleton.application.techlog.studio.query Dashboard / Document / Publication / Catalog
dev.caskeleton.application.techlog.studio.mapper API 용어 ↔ Domain 용어
dev.caskeleton.application.techlog.studio.nextaction NextAction 계산
dev.caskeleton.application.techlog.studio.port.out Studio 자신의 read 포트(예: CatalogQueryPort) —
§4.3 경계 규칙 2가 금지하는 것은 studio가
*타* context의 port.out을 참조하는 것이지,
studio 자신의 port.out이 아니다
:adapter:outbound:persistence-jpa
dev.caskeleton.adapter.outbound.persistence.techlog.<ctx>.entity
dev.caskeleton.adapter.outbound.persistence.techlog.<ctx>.repository
dev.caskeleton.adapter.outbound.persistence.techlog.<ctx>.mapper
dev.caskeleton.adapter.outbound.persistence.techlog.query JdbcClient union query
:adapter:inbound:web
dev.caskeleton.adapter.inbound.web.techlog.studio.controller
dev.caskeleton.adapter.inbound.web.techlog.studio.mapper
dev.caskeleton.adapter.inbound.web.techlog.studio.problem
:app-bootstrap
dev.caskeleton.bootstrap.techlog 조립·설정만
```
`modules.json`, `settings.gradle`, `verifyCleanArchitectureDependencies`는 변경하지
않는다.
### 4.2 상속되는 기존 가드레일
신규 코드에 자동으로 적용된다.
- `..domain..` 순수성 (프레임워크·전송·DB 의존 금지)
- `..domain.vo..` / `@ValueObject`: public 무인자 생성자 금지
- `@AggregateRoot`: `set*` 메서드 public 금지
- `@DomainEvent`: record 필수
- `..application..`: `@Transactional` 금지 → `TransactionPort` 사용
- `..application..`: `ApplicationContext` 의존 금지
- `CommandUseCase`/`QueryUseCase` 구현: 이름이 `UseCase`로 끝나야 하고
`@UseCaseCapability` 필수
- `verifyApplicationCoreDependencyPurity`: application-core 생산 의존은 project-only,
클래스패스에 Spring/slf4j/logback/micrometer 금지
### 4.3 추가할 경계 규칙 — `TechLogBoundaryArchTest`
`:app-bootstrap` 테스트에 추가한다.
1. `techlog.content` / `techlog.inquiry` / `techlog.project` / `techlog.asset`은
서로 의존하지 않는다.
2. `application.techlog.studio`는 타 context의 `port.in`만 참조한다.
타 context의 `domain`, `port.out`, `service` 직접 참조는 위반이다. (studio 자신의
`application.techlog.studio.port.out`은 이 규칙의 대상이 아니다 — §4.1 참조.)
3. 타 context는 `application.techlog.studio`를 참조하지 않는다 (역방향 금지).
4. `domain.techlog.publication`을 제외한 어떤 domain 패키지도 `Publication`을
직접 변경하지 않는다.
설계 08장의 "`studio`는 도메인 모듈이 아니다"를 빌드로 강제하는 장치다.
---
## 5. 계약 → 코드
### 5.1 계약 원본
`studio-v1.yaml`을 `src/config/openapi/studio-v1.yaml`로 복사한다
(`src/config/architecture`, `src/config/messaging`과 같은 authority 위치).
`src/config/openapi/MANIFEST.sha256`에 해시를 기록해 설계 패키지와의 드리프트를
가시화한다.
### 5.2 생성
`:adapter:inbound:web`에 `org.openapi.generator` 플러그인을 추가한다. 아래는 실제 구현 설정
(`src/adapter/inbound/web/build.gradle`)이다 — 이 절이 원래 예정했던 `generateApis=false`/
`generateModels=true`는 openapi-generator-gradle-plugin 7.18.0에 존재하지 않는 프로퍼티였다.
무엇이 왜 달라졌는지는 §3.1 참조.
```text
generatorName spring
globalProperties ['models': ''] ← "models만" 생성 (CLI --global-property와 동치)
modelPackage dev.caskeleton.adapter.inbound.web.techlog.studio.api.model
useSpringBoot3 true
useJakartaEe true
openApiNullable true (jackson-databind-nullable 이미 선언됨)
useOneOfInterfaces false ← §3.1 — discriminator union 5종의 Jackson 배선이 깨지는 대가
output build/generated/openapi
```
- 생성 소스는 `sourceSets.main.java.srcDir`가 아니라 **별도 `generatedOpenapi` sourceSet**에
있다. 생성 코드가 deprecated `org.springframework.lang.Nullable`을 쓰는데 저장소 루트의
`-Werror`가 이를 빌드 실패로 승격하기 때문이다 — 자세한 배선은 §3.1과
`build.gradle:1-38`의 주석 참조. `jar`/`test` 클래스패스에는 별도로 명시적으로 얹는다.
- spotless / checkstyle / spotbugs / errorprone 대상에서 제외한다.
- `adapter/inbound/web/gradle.lockfile`을 `--write-locks`로 재생성한다.
#### 왜 API interface를 생성하지 않는가
D4로 응답 봉투를 유지하면 계약의 성공 응답 스키마가 봉투 wrapper가 된다. 그러면
생성 interface의 signature가 `ResponseEntity<StudioSessionEnvelope>`가 되는데,
`StudioSessionEnvelope`는 생성된 별개 클래스라 `dev.caskeleton.shared.response.Envelope`가
아니다. `EnvelopeBodyAdvice.beforeBodyWrite`는 `Envelope`/`BulkEnvelope`만 통과시키므로
이 본문을 **한 번 더 감싼다**.
```text
controller → StudioSessionEnvelope
EnvelopeBodyAdvice → Envelope<StudioSessionEnvelope>
wire → {"success":true,"data":{"success":true,"data":{...},"meta":{...}},"meta":{...}}
```
이걸 피하려면 `EnvelopeBodyAdvice`(템플릿 파일)를 고쳐야 하는데, 그건 D4가 피하려던
바로 그 template sync 충돌이다.
따라서 **model만 생성하고 controller는 손으로 쓴다.**
```java
@RestController
final class StudioSessionController {
@GetMapping("/api/v1/studio/session")
StudioSession getStudioSession() { // 생성된 payload DTO를 그대로 반환
return mapper.toApi(facade.currentSession());
}
}
// EnvelopeBodyAdvice가 여기서 정확히 한 번 감싼다.
```
controller는 facade 호출과 매핑만 하고 비즈니스 로직을 두지 않는다.
봉투 wrapper 스키마도 함께 생성되지만 백엔드는 쓰지 않는다. 계약의 wrapper는
소비자(프론트·외부 도구)를 위한 기술이고, 백엔드에서는 advice가 그 역할을 한다.
**선행 검증 결과:** openapi-generator 7.x × 이 저장소의 Spring Boot 조합은 슬라이스 0의 폐기용
스파이크로 검증했다. 폴백(생성기 1회 실행 후 수기 유지)으로 넘어가지는 않았다 — 생성기는 지금도
빌드마다 model을 생성한다. 대신 §5.2 상단에 적은 세 가지(`globalProperties`, 별도 sourceSet,
`useOneOfInterfaces=false`)가 스파이크에서 드러난 실제 조건이었다. §3.1이 그 정정 기록이다.
### 5.3 wire format — 템플릿 봉투를 그대로 쓴다
성공과 실패가 한 모양을 공유한다.
```jsonc
// 성공 (200 / 201)
{ "success": true, "data": { /* studio-v1 payload */ }, "meta": { "requestId": "...", "traceId": "...", "correlationId": "..." } }
// 실패 (4xx / 5xx) — HTTP status는 그대로 의미를 갖는다
{ "success": false, "error": { "code": "VERSION_CONFLICT", "category": "CONFLICT",
"message": "...", "retryable": false,
"details": { /* code별 polymorphic */ } },
"meta": { "requestId": "...", "traceId": "...", "correlationId": "..." } }
// 204 (deleteStudioAsset) — 본문 없음. EnvelopeBodyAdvice는 null body를 감싸지 않는다.
```
미디어 타입은 성공·실패 모두 `application/json`이다. `application/problem+json`은 쓰지
않는다.
#### 왜 이 방향인가
- 봉투는 이 템플릿의 **문서화된 결정**이다. `shared-contract/README.md:230` —
"RFC 7807 ProblemDetail 을 대체한다(boundary D5/D6)", "D5 가 RFC 7807 ProblemDetail 을
거부하고, D10 이 `category`를 1급 필드로 추가했다".
- **정보 손실이 없다.** §5.4의 매핑표 참조. `error.category`는 봉투 쪽이 추가로 준다.
- **적응 코드의 위치가 결정적이다.** 봉투를 벗기려면 `EnvelopeBodyAdvice.supports()`를
고쳐야 하는데 이는 템플릿 파일이고, 이 저장소는 `template.lock.json`의
`"materialization": "tracked-snapshot"`이라 이후 모든 template sync의 충돌 지점이 된다.
반대로 봉투를 유지하면 적응은 프론트 **제품 코드**
(`src/features/tech-log/contracts/tech-log-studio-contract-contribution.ts`) 안에서
끝나고 프론트 플랫폼(`src/adapters/http/http-execution-v3.ts`)도 무변경이다.
#### 백엔드가 해야 할 일
**봉투 관련 신규 작업은 없다.** `EnvelopeBodyAdvice`, `ErrorResponseFactory`,
`GlobalExceptionHandler`, `EnvelopeAuthenticationEntryPoint`,
`EnvelopeAccessDeniedHandler`, `RateLimitInterceptor`를 그대로 쓴다. 필요한 것은
§5.4의 오류 코드 등록과 Studio 전용 예외 → `ApiError` 매핑뿐이다.
#### 계약과 프론트가 해야 할 일
이 결정은 세 저장소에 걸친다. 상세는 §5.6.
```text
tech-log-design-package studio-v1.yaml을 봉투 형태로 재정의 (+ 06장 · ADR · MASTER_SPEC · 검증 스크립트)
tech-log-frontend 계약 재생성 + validator 2개를 봉투 언랩으로 교체
tech-log-backend 오류 코드 등록 + 예외 매핑 (봉투 자체는 무변경)
```
### 5.4 오류 코드
계약의 오류 코드 23개를 `ApiError.code`에 그대로 싣는다.
```text
AUTHENTICATION_REQUIRED STUDIO_ACCESS_DENIED DOCUMENT_NOT_FOUND
VERSION_CONFLICT REQUEST_VALIDATION_FAILED VALIDATION_FAILED
VALIDATION_STALE PREVIEW_NOT_FOUND PREVIEW_STALE
PREVIEW_EXPIRED PUBLICATION_NOT_FOUND PUBLICATION_CONFLICT
PUBLICATION_EVENT_NOT_FOUND PUBLICATION_SNAPSHOT_NOT_FOUND
WARNING_ACKNOWLEDGEMENT_REQUIRED IDEMPOTENCY_KEY_REUSED
ASSET_NOT_FOUND ASSET_NOT_READY ASSET_IN_USE
ASSET_QUARANTINED PAYLOAD_TOO_LARGE UNSUPPORTED_MEDIA_TYPE
STUDIO_UNAVAILABLE
```
#### 필드 매핑 — 정보 손실 없음
| 기존 계약 `ProblemDetails` | 봉투 |
| --- | --- |
| `code` | `error.code` |
| `retryable` | `error.retryable` (1급) |
| `detail` | `error.message` (client-safe. stack trace·내부 ID 금지) |
| `status` | HTTP status (봉투도 실제 status를 유지한다) |
| `traceId` | `meta.traceId` (D7 — 응답에서 절대 null이 아니다) |
| `fieldErrors` | `error.details` = `ValidationErrorDetails` |
| `latestDocument` | `error.details` = `VersionConflictDetails` |
| `latestPublication` | `error.details` = `PublicationConflictDetails` |
| `conflictingFields` | 위 두 details 안 |
| `type` / `title` | 버린다. 프론트가 이미 `code`에서 합성한다(`studio-error-mapping.ts`의 `synthetic()`) |
| — | `error.category` — 봉투가 추가로 준다 |
`details`는 code별 polymorphic이므로 계약에서 `oneOf` 타입 변형으로 선언한다.
`Object` 자유형으로 두지 않는다.
#### 레지스트리 등록
`docs/registries/error-codes.yaml`에 23개 row를 additive로 추가한다. row 스키마가
요구하는 필드를 모두 채운다 — `category`(10-value `Category` enum), `http_status`,
`retryable`, `owner_layer`, `client_safe_message`, `log_level`, `runbook_link`,
`compatibility_impact: additive`, `required_test`.
runbook 정책: `retryable=false`이고 category가 `AUTH`/`AUTHZ`/`RATE_LIMIT`/`INTERNAL`/
`TRANSIENT_DEPENDENCY`/`PERMANENT_DEPENDENCY`면 `runbook_link`가 필수다.
`VALIDATION`/`NOT_FOUND`/`CONFLICT`/`DATA_INTEGRITY`는 client-error로 면제 가능하다.
따라서 `AUTHENTICATION_REQUIRED`(AUTH), `STUDIO_ACCESS_DENIED`(AUTHZ),
`STUDIO_UNAVAILABLE`(TRANSIENT_DEPENDENCY)는 runbook을 함께 작성한다.
category 배정:
```text
AUTH AUTHENTICATION_REQUIRED
AUTHZ STUDIO_ACCESS_DENIED
NOT_FOUND DOCUMENT_NOT_FOUND PREVIEW_NOT_FOUND PUBLICATION_NOT_FOUND
PUBLICATION_EVENT_NOT_FOUND PUBLICATION_SNAPSHOT_NOT_FOUND
ASSET_NOT_FOUND
CONFLICT VERSION_CONFLICT PUBLICATION_CONFLICT IDEMPOTENCY_KEY_REUSED
VALIDATION_STALE PREVIEW_STALE PREVIEW_EXPIRED
ASSET_IN_USE ASSET_NOT_READY
VALIDATION REQUEST_VALIDATION_FAILED VALIDATION_FAILED
WARNING_ACKNOWLEDGEMENT_REQUIRED
PAYLOAD_TOO_LARGE UNSUPPORTED_MEDIA_TYPE
DATA_INTEGRITY ASSET_QUARANTINED
TRANSIENT_DEPENDENCY STUDIO_UNAVAILABLE
```
### 5.5 계약 회귀 테스트
**실제 구현 범위 (Task 10, `StudioContractDriftTest`, `:app-bootstrap`의 functional test
소스셋):** springdoc이 노출하는 `/v3/api-docs`를 `src/config/openapi/studio-v1.yaml`과 대조해
**operation id · method · path**가 어긋나면 실패시킨다 (`published ⊆ contract` 방향 — 아직
구현하지 않은 operation이 있어도 green을 유지한다). `dev.caskeleton.adapter.inbound.web.techlog`
아래를 `@ComponentScan`하므로 그 패키지 트리 밖에 컨트롤러를 두면 이 게이트가 못 본다 (스캔
범위를 벗어나는 즉시 유효성을 잃는다는 뜻이므로 §4.1의 패키지 배치를 반드시 따른다). 별도로
`getStudioSession`/`listStudioCatalog` 각 1개 케이스에 대해 성공 응답이
`{success:true, data, meta}` 모양이고 이중 래핑이 없는지도 고정한다 — **모든 operation**의
봉투 래핑을 확인하는 것은 아니다.
**아직 구현하지 않은 것 (Plan 02 몫):**
- 응답 media type 대조 (`application/json` 고정 여부)
- 스키마 필수 필드 대조 (93개 schema·enum·required 필드가 model 생성으로 덮인다는 D3의 전제를
실제로 검증하는 회귀 테스트는 없다 — 지금은 생성이 컴파일에 성공하는 것으로 암묵 검증한다)
- 나머지 17개 operation에 대한 봉투 래핑 확인
- `deleteStudioAsset` 204 본문 없음 확인 (해당 operation 미구현)
**401/403/429 코드 주장 — 삭제.** 이 절은 원래 401/403/429가 각각
`AUTHENTICATION_REQUIRED`/`STUDIO_ACCESS_DENIED`/rate-limit 코드로 난다고 적었으나 이는
코드상 사실이 아니다. 기존(템플릿 소유, 무변경 재사용) 경로가 실제로 내는 코드는:
- 401 — `EnvelopeAuthenticationEntryPoint` → `SecurityErrorClassifier.classifyAuthentication`
(`src/adapter/inbound/web/src/main/java/.../auth/SecurityErrorClassifier.java:21-35`)이
`AUTH_TOKEN_MISSING`(`InsufficientAuthenticationException`) 또는 `AUTH_TOKEN_MALFORMED`(그 외
분류 불가 인증 실패)를 낸다.
- 403 — `EnvelopeAccessDeniedHandler` → `SecurityErrorClassifier.classifyAccessDenied` (같은
파일:39)가 `AUTHZ_INSUFFICIENT_PERMISSION`을 낸다.
- 429 — `RateLimitInterceptor:75`가 `RATE_LIMIT_EXCEEDED`를 낸다.
이 세 코드 모두 계약(studio-v1.yaml) 23종 `ApiError.code` enum에 없다. 429는 계약에
rate-limit 코드 자체가 존재하지 않는다. §5.3 "백엔드가 해야 할 일"이 "봉투 관련 신규 작업은
없다"고 못 박았고 이 경로들은 템플릿 파일(`EnvelopeAuthenticationEntryPoint`,
`EnvelopeAccessDeniedHandler`, `RateLimitInterceptor`, `SecurityErrorClassifier`)만으로
동작해 Studio가 손댈 수 없다 — 계약을 이 세 코드로 확장할지, 별도 Studio 매핑 계층을 둘지는
이 spec이 결정하지 않은 채 남아 있다. **Plan 02가 착수 전에 결정해야 한다.**
### 5.6 계약 재정의 명세
`studio-v1.yaml`을 다음과 같이 바꾼다. 이것이 세 저장소의 공유 SSOT가 된다.
**추가 스키마**
```text
ResponseMeta requestId, traceId, correlationId, page(nullable)
ApiError code(enum 23), category(enum 10), message, retryable, details(oneOf|null)
ErrorEnvelope success(const false), error(ApiError), meta(ResponseMeta)
ValidationErrorDetails fieldErrors[]
VersionConflictDetails latestDocument, conflictingFields[]
PublicationConflictDetails latestPublication
```
**성공 응답 래핑** — JSON 본문을 갖는 18개 operation(200 15개 / 201 3개)의 응답
스키마를 `{success: const true, data: <기존 payload>, meta: ResponseMeta}` 래퍼로
교체한다. OpenAPI 3.1에 제네릭이 없으므로 payload별 래퍼 스키마를 만든다.
기존 payload 스키마(`StudioSession`, `WorkingCopyDetail`, …)는 **그대로 남긴다** —
프론트가 `components["schemas"]["StudioSession"]`로 도메인 타입을 계속 뽑아 쓴다.
**오류 응답 교체** — `components.responses`의 16개 항목 content를
`application/problem+json` + `ProblemDetails`에서 `application/json` + `ErrorEnvelope`로
바꾼다. `ProblemDetails` 스키마는 제거한다.
`deleteStudioAsset`의 204는 변경 없다.
**`public-v1.yaml` / `studio-management-v1.yaml`** — 두 계약도 `ProblemDetails`를
쓰지만 이번 구현 범위 밖이고 소비자가 없다. 지금 변환하지 않고 각 파일 상단에
"봉투 결정(ADR-006) 반영 대기" 배너를 붙인다. 설계 패키지가 `docs/plans/02`에 쓴
STALE 배너와 같은 방식이다. 구현에 착수할 때 변환한다.
**따라오는 파일**
```text
tech-log-design-package
contracts/openapi/studio-v1.yaml 위 변경
docs/specs/06-api-contract-design.md 7장 오류 계약 재작성
decisions/ADR-006-response-envelope.md 신규 — 봉투 채택 근거와 RFC 7807 미채택 기록
TECH_LOG_MASTER_SPEC.md scripts/build-master-spec.sh 재생성
MANIFEST.sha256 scripts/update-manifest.sh 재생성
scripts/check-contract-parity.py ProblemDetails.code → ApiError.code 참조 변경
scripts/check-consistency.py 동일
tech-log-frontend
src/features/tech-log/contracts/studio/studio-api.openapi.yaml 동기화
src/features/tech-log/contracts/studio/generated.ts 재생성
src/features/tech-log/contracts/studio/canonical-source.json 재생성
src/features/tech-log/contracts/studio/contract.ts 타입 export 확인
src/features/tech-log/contracts/tech-log-studio-contract-contribution.ts
outputValidator passthrough → 봉투 data 언랩
problemValidator PROBLEM(bare) → 봉투 error 언랩 + code enum 검증 유지
src/features/tech-log/adapters/http/studio-error-mapping.ts ApiError → StudioGatewayError
관련 테스트 · 픽스처의 HTTP 본문
```
`mock-studio-gateway.ts` 등 `StudioGateway` 포트를 직접 구현하는 mock은 전송 경계
아래가 아니라 위에 있으므로 **변경 대상이 아니다.** 앱·도메인·프레젠테이션 계층도
언랩이 경계에서 끝나므로 무변경이다.
---
## 6. 도메인 모델
### 6.1 Aggregate
| Aggregate | 소유 테이블 | 비고 |
| --- | --- | --- |
| `Document` | `document`, `case_detail`, `reference_detail`, `document_tag`, `document_relation` | `RecordKind.CASE` / `REFERENCE` |
| `OpenQuestion` | `open_question`, `question_point`, `question_update`, `question_tag`, `question_document_link` | `RecordKind.QUESTION` |
| `ProjectDecision` | `project_decision` | `RecordKind.PROJECT_DECISION` |
| `Asset` | `asset`, `asset_reference` | |
| `Publication` | `publication`, `publication_event`, `publication_snapshot` | Event·Snapshot은 불변 |
`WorkingCopy`는 Aggregate가 아니라 **API projection**이다. `working_copy` 범용
테이블을 만들지 않는다.
### 6.2 상태 기계 (설계 09장)
```text
Document DRAFT → IN_REVIEW → PUBLISHED → ARCHIVED (+ unpublish: PUBLISHED → DRAFT)
OpenQuestion OPEN → INVESTIGATING → PAUSED → RESOLVED → ARCHIVED
Publication PUBLISHED → REPUBLISHED → UNPUBLISHED → REPUBLISHED
```
**API 용어와 Domain 용어를 분리한다.**
```text
API questionStatus=OPEN ← Domain OPEN | INVESTIGATING | PAUSED
API questionStatus=RESOLVED ← Domain RESOLVED
```
`saveStudioDocument`는 편집 가능한 content field만 저장하며 **lifecycle 전이를
유발하지 않는다.** OPEN 계열 안에서의 값 변화는 무시한다. 프론트가 축약 상태를
보냈다는 이유로 `INVESTIGATING`을 `OPEN`으로 덮어쓰면 조사 이력이 소실된다.
### 6.3 저장하지 않는 값
`nextAction`, `publicationStatus`, `hasUnpublishedChanges`, Preview state는
**조회 시점 계산**이다. `workflow_status` 같은 domain 컬럼에 저장하지 않는다.
---
## 7. 애플리케이션 계층
### 7.1 use case 규약
context별 use case는 `CommandUseCase<C, R>` / `QueryUseCase<Q, R>`를 구현하고
이름이 `UseCase`로 끝나며 `@UseCaseCapability`를 선언한다.
```java
@UseCaseCapability(
transactionMode = TransactionMode.WRITE,
idempotency = Idempotency.KEYED,
repositoryAccess = RepositoryAccess.WRITE_REPOSITORY)
final class PublishCaseUseCase implements CommandUseCase<PublishCaseCommand, PublishResult> { }
```
트랜잭션 경계는 `@Transactional`이 아니라 `TransactionPort`로 연다.
`TransactionMode`는 `WRITE` / `READ_ONLY` / `REQUIRES_NEW` 계열이고,
`Idempotency`는 `IDEMPOTENT` / `KEYED` / `NOT_IDEMPOTENT`,
`RepositoryAccess`는 `NONE` / `READ_REPOSITORY` / `WRITE_REPOSITORY`다.
`Idempotency-Key`를 쓰는 mutation은 `KEYED`로 선언한다.
### 7.2 studio facade
```text
StudioWorkingCopyFacade create / get / save / list → 유형별 port.in dispatch
StudioValidationFacade validate → validation artifact 영속
StudioPreviewFacade create / get preview artifact + state 계산
StudioPublicationFacade publish / unpublish / 이력 / snapshot 조회
StudioDashboardQuery union query 기반 대시보드
StudioDocumentQuery union query 기반 문서 목록
StudioCatalogQuery TOPIC | PROJECT | RELATION | EVIDENCE
NextActionCalculator §7.4
StudioDocumentLocator documentId(UUID) → (sourceKind, sourceId)
```
facade는 domain object를 직접 수정하지 않는다. 타 context의 `port.in`만 호출하고
결과를 통합 DTO로 조립한다.
`documentId`는 계약상 **source aggregate id를 그대로 쓴다**(별도 surrogate 없음).
`StudioDocumentLocator`가 `document` / `open_question` / `project_decision` union
query로 kind를 해소한다.
### 7.3 Validation / Preview artifact
```text
studio_validation validationId, sourceKind, sourceId, validatedVersion,
status(INVALID|WARNINGS|VALID), issues(jsonb),
dependencyRevision, validatedAt, validUntil, createdBy
studio_preview previewId, sourceKind, sourceId, sourceVersion,
validationId(FK), dependencyRevision,
renderModel(jsonb), createdAt, expiresAt, createdBy
```
`dependencyRevision`은 검증에 쓴 외부 의존 상태(topic/project publishability,
relation target 상태, asset READY/QUARANTINED, slug/route ownership, catalog
revision, renderer/content-format version)의 identity+version을 정규화해 만든
해시다. 전역 카운터가 아니다.
상태는 저장하지 않고 조회 시 계산한다.
```text
Validation 유효 validatedVersion == 현재 working version
AND dependencyRevision == 현재 계산값
AND now() < validUntil
Preview CURRENT sourceVersion == 현재 working version
AND dependencyRevision == 현재 계산값
AND now() < expiresAt
STALE version 또는 dependencyRevision 불일치
EXPIRED now() >= expiresAt
```
`VALIDATION_FAILED`(지금 검증하면 실패)와 `VALIDATION_STALE`(통과했으나 전제가
바뀜)은 다른 사건이며 코드도 다르다.
### 7.4 nextAction 계산
```text
저장 가능한 형태조차 미달 → CONTINUE_EDITING
현재 version에 대한 Validation 없음 → VALIDATE
현재 Validation = INVALID → FIX_VALIDATION
유효 Validation + 현재 version Preview 없음 → CREATE_PREVIEW
Preview가 STALE 또는 EXPIRED → CREATE_PREVIEW
현재 Validation + 현재 Preview
+ Publication version 불일치 → PUBLISH
Publication version == working version → NONE
```
### 7.5 Publish 트랜잭션 (설계 07장 §14, 20단계)
```text
0. idempotency 선검사 — 동일 key + 동일 fingerprint면 최초 응답 재생, 이하 미실행
1. Document SELECT FOR UPDATE
2. expectedVersion 검증 불일치 → VERSION_CONFLICT
3. validationId 조회 + dependencyRevision 재계산 비교 불일치 → VALIDATION_STALE
4. previewId 조회 + sourceVersion / dependencyRevision 비교
불일치 → PREVIEW_STALE, 만료 → PREVIEW_EXPIRED
5. acknowledgedWarningCodes가 WARNING 집합을 덮는지
미달 → WARNING_ACKNOWLEDGEMENT_REQUIRED
6. 유형별 publication validation (게시 필수 필드 / slug 충돌)
7. Markdown 분석
8. Asset READY 및 사용 위치 alt/decorative 검증
9. 공개 payload 생성
10. Publication Event 생성 (PUBLISHED | REPUBLISHED)
11. Publication Snapshot 생성 (immutable)
12. public_resource_projection upsert
13. public_route canonical/alias 변경
14. public_resource_tag / project link 교체
15. PUBLISHED asset_reference 교체
16. asset.first_published_at 갱신
17. Publication aggregate 갱신 (latest_event_id, publication_revision)
18. Document publish metadata 갱신
19. 선택적 ProjectActivity 생성
20. Commit
```
전 단계가 하나의 트랜잭션이다.
**Snapshot의 render model은 게시 시점에 다시 렌더링하지 않고 사용자가 확인한
Preview의 render model을 그대로 쓴다.** 재렌더링하면 승인한 화면과 공개된 화면이
달라질 수 있다.
Unpublish는 `expectedPublicationRevision` 검증 → `UNPUBLISHED` Event(반드시
`sourcePublishedEventId` 보유) → Publication 상태 전환 및 revision 증가 →
Projection `ACTIVE → WITHDRAWN` → Working `workflow_status → DRAFT` → route 유지 →
commit. Snapshot은 삭제하지 않는다.
---
## 8. 영속화
### 8.1 마이그레이션
`PostgreSqlPersistenceConfig.java:57`이 Flyway location을
`classpath:db/migration/postgresql`로 고정한다. 기존 최대 버전이 V6이고
`out-of-order: false`이므로 **`V7__techlog_core.sql`부터** 추가한다.
이번 범위에 필요한 테이블:
```text
topic tag document case_detail reference_detail document_tag document_relation
open_question question_point question_update question_tag question_document_link
project project_decision project_document_link project_question_link project_activity
asset asset_reference
studio_validation studio_preview
publication publication_event publication_snapshot
public_resource_projection public_route public_resource_tag public_resource_project_link
```
`publication.latest_event_id` ↔ `publication_event.publication_id` 순환 FK는
`DEFERRABLE INITIALLY DEFERRED`로 선언한다. 즉시 검사로 두면 첫 게시가 불가능하다.
`publication_event` / `publication_snapshot`은 어떤 cleanup job도 삭제하지 않는다.
### 8.2 멱등 — 기존 자산 재사용 (D5)
설계의 `studio_idempotency` 테이블을 만들지 않는다.
| 설계 `studio_idempotency` | 기존 `idempotency_record` |
| --- | --- |
| `idempotency_key` | `idempotency_key` |
| `operation_id` | `use_case_name` |
| `request_fingerprint` | `request_hash` (char(64) sha256) |
| `response_status` / `response_body` | `response_payload` |
| `created_at` / `expires_at` | `created_at` / `expires_at` |
| `created_by` | `principal` (+ `tenant`) |
기존 것이 상위집합이고 `IdempotencyExecutorV2`, `IdempotencyStorePortV2`,
`IdempotencyKeySupport`(header 추출 · scope · sha256 fingerprint · JSON codec)까지
있다. 설계 대비 결손은 두 가지뿐이며 web 계층에서 채운다.
1. 재생 시 `Idempotency-Replayed: true` 응답 헤더
2. fingerprint 불일치 시 `IDEMPOTENCY_KEY_REUSED` ProblemDetails (409)
적용 대상: create / save / validate / create preview / publish / unpublish /
asset upload · update · delete.
### 8.3 읽기·쓰기 분리
- 쓰기: JPA aggregate + `@Version` 낙관적 잠금
- Studio 목록 / 대시보드 / 카탈로그: `JdbcClient` union query (도메인 repository 우회)
- 공통 CRUD repository를 만들지 않는다
---
## 9. 재사용 매핑 (신규 구현 금지)
| 설계 요구 | 재사용 대상 |
| --- | --- |
| `Idempotency-Key` | `application/idempotency/v2`, `web/idempotency/IdempotencyKeySupport`, `idempotency_record` |
| `expectedVersion` / 409 | JPA `@Version`, `web/conditional/ETags`, `PreconditionFailedException` |
| Studio cursor 페이지네이션 | `web/cursor/CursorCodec`, `web/pagination/PageParams` |
| 오류 응답 골격 | `shared/response/{Envelope,ApiError,ResponseMeta}`, `web/error/ErrorResponseFactory`, `GlobalExceptionHandler` — **무변경 재사용** |
| 401 / 403 / 429 | `EnvelopeAuthenticationEntryPoint`, `EnvelopeAccessDeniedHandler`, `RateLimitInterceptor` — **무변경 재사용** |
| Keycloak 세션 · CSRF | `web/auth/SecurityConfig`, `JwtDecoderConfig`, `PrimitiveSessionSecurityContextRepository`, `RedisSessionWebConfig` |
| 권한 | `application/security/RequiresPermission`, `web/authz/RolePermissionPolicy`, `RequiresPermissionAuthorizationManager` |
| Asset 업로드 · 저장 | `adapter:outbound:fileserver`, `adapter:outbound:objectstorage` |
| ID 생성 | `adapter:outbound:identifier` |
| 캐시 | `application/cache`, `adapter:outbound:cache-redis` (필요 입증 시에만) |
| 관측 | `web/observability`, `docs/registries/{metrics,mdc-keys,headers}.yaml` |
| 트랜잭션 | `application/transaction/TransactionPort` |
**Asset은 새 저장 계층을 만들지 않는다.** `asset` 테이블은 metadata·`asset_key
lifecycle만 소유하고 바이너리 저장·전송은 fileserver/objectstorage 어댑터에 위임한다.
`asset_key`(안정 참조)와 `object_key`(저장 위치)를 분리한다 — 본문에 object storage
URL을 저장하지 않는다.
---
## 10. 렌더러
`PublicRenderModel`은 Markdown을 의미 블록으로 변환한 결과다. 계약이 정의하는
블록·인라인 타입:
```text
블록 HeadingBlock ParagraphBlock CodeBlock BlockquoteBlock CalloutBlock
OrderedListBlock UnorderedListBlock DataTableBlock EvidenceFigureBlock
인라인 InlineText InlineStrong InlineEmphasis InlineCode InlineLink InlineStatus
```
제약:
- Public과 Studio Preview가 **같은 의미의 렌더 결과**를 써야 한다 (ADR-005).
- Snapshot은 `contentFormatVersion`, `rendererContractVersion`, asset manifest를 함께
보존한다. 이후 Asset이 교체되어도 과거 Snapshot의 표현이 변하지 않는다.
- Asset은 `assetKey`로 참조하고 렌더 시 `ResolvedAsset`으로 해소한다.
**이 항목이 이번 구현의 최대 리스크다.** 슬라이스 3 착수 전에 프론트의 기존 렌더
모델 구현(`src/features/tech-log/adapters/mock/project-public-render-model.ts` 및
static content 경로)을 기준선으로 대조해 블록 의미가 일치하는지 확인한다.
---
## 11. 테스트 전략
leaf별 템플릿 정책을 그대로 따른다.
| 대상 | 방식 |
| --- | --- |
| `domain-core` | 순수 JUnit. 상태 전이·불변 조건 |
| `application-core` | 손수 만든 fake 포트. 웹·영속 컨텍스트 금지 |
| `adapter:inbound:web` | 전송 slice 테스트 + §5.5 봉투 래핑·오류 코드 회귀 테스트 |
| `adapter:outbound:persistence-jpa` | 매핑·포트 계약 테스트. 벤더 의미가 필요한 것(deferrable FK, `SELECT FOR UPDATE`)은 `postgresqlIntegrationTest` 소스셋 |
| `app-bootstrap` | 배선·`TechLogBoundaryArchTest`·계약 회귀 테스트 |
필수 시나리오:
- publish 재시도가 중복 `publication_event`를 만들지 않는다
- `VALIDATION_STALE`과 `VALIDATION_FAILED`가 구분된다
- `saveStudioDocument`가 `INVESTIGATING`을 `OPEN`으로 되돌리지 않는다
- unpublish 후 과거 Snapshot이 그대로 조회된다
- 성공 응답이 `{success:true, data, meta}`이고 `meta.traceId`가 non-null이다
- 오류 응답이 `{success:false, error, meta}`이고 `error.code`가 계약의 23개 중 하나다
- 409가 `error.details`로 `latestDocument` / `latestPublication`을 싣는다
검증 명령 (`src/`에서):
```bash
./gradlew :domain-core:test --console=plain
./gradlew :application-core:test --console=plain
./gradlew :adapter:inbound:web:test --console=plain
./gradlew :adapter:outbound:persistence-jpa:test --console=plain
./gradlew verifyCleanArchitectureDependencies --console=plain
./gradlew :app-bootstrap:test --tests '*CleanArchitectureTest' --console=plain
./gradlew :app-bootstrap:test --tests '*TechLogBoundaryArchTest' --console=plain
```
---
## 12. 슬라이스
| # | 내용 | 완료 판정 |
| --- | --- | --- |
| 0 | **계약 봉투 재정의(§5.6) → 프론트 재생성·validator 교체**, 생성기 스파이크 → 배선, 계약 복사, `TechLogBoundaryArchTest`, 오류코드 레지스트리 23개, `V7__techlog_core.sql` | 빌드·아키텍처 검증 통과 + 3 저장소 계약 parity 통과 |
| 1 | `getStudioSession`, `listStudioCatalog` | 프론트 로그인·카탈로그 실동작 |
| 2 | `listStudioDocuments`, `createStudioDocument`, `getStudioDocument`, `saveStudioDocument` (+ 낙관적 잠금, 멱등) | 프론트 편집 실동작 |
| 3 | `validateStudioDocument`, `createStudioPreview`, `getCurrentStudioPreview`, `dependencyRevision`, `nextAction`, 렌더러 | 프론트 검증·미리보기 실동작 |
| 4 | `publishStudioDocument`, `unpublishStudioPublication`, `listStudioPublications`, `getStudioPublicationSnapshot`, `getStudioDashboard` | 프론트 게시 실동작 |
| 5 | asset 5종 | 프론트 자산 실동작 |
`getStudioDashboard`는 publication·validation 데이터에 의존하므로 슬라이스 4에서
완성한다.
---
## 13. 리스크
| 리스크 | 완화 |
| --- | --- |
| openapi-generator × Spring Boot 4.0.0 미검증 | 슬라이스 0 첫 스텝을 폐기용 스파이크로. 실패 시 §5.2 폴백 |
| 3 저장소(계약·프론트·백엔드) 동기화 실패 | 슬라이스 0에서 계약을 먼저 확정하고 `check-contract-parity.py`를 게이트로 삼는다. 백엔드는 §5.5 계약 회귀 테스트로 고정 |
| 프론트 언랩 누락 시 조용한 실패 (`outputValidator`가 passthrough라 전송 계층이 잡지 못한다) | 언랩 validator에 `success`/`data` 존재 검증을 넣어 하드 실패로 바꾼다 |
| 렌더 모델 의미 불일치 | 슬라이스 3 착수 전 프론트 구현 대조 |
| publish 20단계 트랜잭션 복잡도 | 단계별 실패 코드를 먼저 테스트로 고정한 뒤 구현 |
| 설계 DDL과 템플릿 스키마 충돌 | `studio_idempotency` 제거(D5) 외에는 이름 충돌 없음을 V7 작성 시 재확인 |
---
## 14. 운영 제약
- **커밋 금지** — `AGENTS.md:64` commit 정책 `human-only`. 브랜치 생성과 파일 작성까지
수행하고 stage/commit/push는 사용자가 한다. 이 제약은 `tech-log-backend`의 규약이며
`tech-log-design-package` / `tech-log-frontend`에는 적용되지 않는다.
- 이 설계는 **세 저장소**를 건드린다(§5.6). 계약이 SSOT이므로 순서는
`tech-log-design-package` → `tech-log-frontend` → `tech-log-backend`다.
- `git flow init`은 워킹트리에 미커밋 삭제분(`*-superpowers-package/`,
`scripts/verify-httpclient-docs.py`)이 있어 중단되었다. gitflow 설정은 기록했고
`develop` / `feature/techlog-studio-backend` 브랜치는 수동 생성했다. 미커밋 삭제분은
손대지 않았다.
---
## 15. 참조
- 설계: `tech-log-design-package/docs/specs/{06,07,08,09}`, `decisions/ADR-00{1..5}`
- 계약: `tech-log-design-package/contracts/openapi/studio-v1.yaml`
- 프론트 계약: `tech-log-frontend/src/features/tech-log/contracts/studio/studio-api.openapi.yaml`
- 프론트 실행 경로: `tech-log-frontend/src/adapters/http/http-execution-v3.ts`
- 저장소 권위: `src/config/architecture/modules.json`, `src/settings.gradle`, `AGENTS.md`
+140
View File
@@ -1,3 +1,51 @@
plugins { id 'org.openapi.generator' }
// ---------------------------------------------------------------------------
// Studio 계약 DTO 생성 — sourceSet 정의와 jar/test classpath 배선 (ADR-004/ADR-006).
// 이 블록은 파일 맨 위, 아래 커스텀 Test 태스크 등록(jpaPersistenceRedactionContractTest,
// webSecurityBoundaryTest)보다 반드시 먼저 와야 한다. 그 둘은
// `classpath = sourceSets.test.runtimeClasspath`로 **eager** 대입한다(Gradle 9.0.0의
// DefaultSourceSet을 디컴파일해 확인 — lazy ConventionMapping이 아니라 단순
// getfield/putfield). 이 배선이 그보다 아래 있으면, 커스텀 태스크는 아직 생성 DTO가
// 안 얹힌 옛 FileCollection 참조를 이미 붙잡은 뒤라 나중에 sourceSet 필드를 새
// composite로 갈아 끼워도 못 본다 — 컴파일이 아니라 테스트 런타임에 NoClassDefFoundError로
// 터진다(발견 당시 실측). 표준 `test` 태스크는 JvmTestSuitePlugin이 lazy
// ConventionMapping Callable로 배선해 괜찮지만(이것도 디컴파일로 확인), register()로 만든
// 이 두 커스텀 Test 태스크는 lazy 배선을 안 타서 못 본다.
//
// openApiGenerate 확장 자체(무엇을 어떤 옵션으로 생성하는지)는 이 파일 아래쪽, 같은 제목의
// 주석 섹션에 그대로 있다 — 여기는 sourceSet 정의와 그 소비자(jar, test classpath)만
// 옮겼다. generatedOpenapiImplementation의 의존성 상속(configurations 블록)과
// compileGeneratedOpenapiJava/checkstyle/spotbugs/spotless 배선도 원래 자리에 남아있다 —
// 전부 lazy(tasks.named/tasks.matching)라 순서 문제가 없다.
// ---------------------------------------------------------------------------
sourceSets {
generatedOpenapi {
java.srcDir(layout.buildDirectory.dir('generated/openapi/src/main/java'))
}
// main이 생성 DTO를 참조할 수 있어야 한다(Task 8/9 controller). implementation
// Configuration으로 연결하면(즉 main의 implementation에 generatedOpenapi.output을
// 넣으면) 아래 generatedOpenapiImplementation.extendsFrom(implementation)과 맞물려
// "컴파일하려면 자기 자신의 산출물이 먼저 있어야 한다"는 순환 태스크 의존성이 생긴다
// (직접 겪음). 그래서 Configuration이 아니라 SourceSet의 compile/runtime classpath
// FileCollection에 직접 이어 붙인다 — 태스크 의존성은 그대로 따라가면서 순환은 없다.
main {
compileClasspath += generatedOpenapi.output
runtimeClasspath += generatedOpenapi.output
}
}
// main.runtimeClasspath로는 부족하다 — 그건 "실행 시 클래스를 찾을 수 있다"는 뜻일 뿐,
// consumer(app-bootstrap 등)가 보는 web 모듈의 runtimeElements(= jar 산출물)에는 여전히
// 생성 DTO가 없다. app-bootstrap은 web을 project dependency로만 물고 web의 build/classes를
// 직접 보지 않으므로, jar 안에 없으면 부팅/요청 시 NoClassDefFoundError로 터진다.
// 같은 이유로 test sourceSet도 main.output만 물려받지 generatedOpenapi.output까지
// 자동으로 따라오지 않는다 — controller 테스트가 컴파일조차 안 된다. 셋 다 명시적으로
// 채워야 한다.
tasks.named('jar') { from sourceSets.generatedOpenapi.output }
sourceSets.test.compileClasspath += sourceSets.generatedOpenapi.output
sourceSets.test.runtimeClasspath += sourceSets.generatedOpenapi.output
// HTTP / web adapters. Depends on application and shared operational contracts.
dependencies {
implementation project(':application-core')
@@ -72,3 +120,95 @@ tasks.register('webSecurityBoundaryTest', Test) {
tasks.named('check') {
dependsOn tasks.named('webSecurityBoundaryTest')
}
// ---------------------------------------------------------------------------
// Studio 계약 DTO 생성 (ADR-004 / ADR-006).
//
// generateApis에 해당하는 효과: 계약이 봉투를 기술하므로 API interface까지 생성하면
// 봉투 wrapper 타입을 반환하게 되고, 그 타입은 dev.caskeleton.shared.response.Envelope가
// 아니라서 EnvelopeBodyAdvice가 한 번 더 감싼다(이중 래핑). controller는 손으로 쓴다.
//
// globalProperties.set(['models': '']): openapi-generator-gradle-plugin 7.18.0의
// openApiGenerate 확장에는 generateApis/generateModels/generateSupportingFiles 프로퍼티가
// 존재하지 않는다(디컴파일로 확인, task-4-report.md 참고) — 대신 CLI --global-property와
// 같은 의미인 globalProperties로 "models만" 생성하게 제한한다.
//
// useOneOfInterfaces=false: discriminator(oneOf) union을 부모 Java interface로 생성하면
// 하위 타입이 그 인터페이스를 구현하는데, 판별 필드가 enum이면(narrowing 여부와 무관하게)
// 인터페이스의 getter는 무조건 String을 반환하고 하위 타입의 getter는 그 프로퍼티의 실제
// 타입(nested enum이든 공유 named enum이든)을 반환해 컴파일이 깨진다. 판별 필드를 하위
// 타입에서 narrowing하지 않고 base의 공유 enum(RecordKind 등)을 그대로 상속하게 계약을
// 고쳐도 동일하게 깨진다는 것까지 스크래치에서 직접 검증했다 — SpringCodegen이
// useOneOfInterfaces=true일 때 discriminator getter를 String으로 고정하는 게 근본
// 원인이라 계약 쪽에서 우회할 수 없다(3개 설정 조합 + 이 검증 전부 task-4-report.md 참고).
//
// useOneOfInterfaces=false는 컴파일은 통과시키지만 대가가 있다: 각 하위 타입이 독립
// 클래스로 생성되고(WorkingCopyInput 등 union 타입과 CaseInput 등 하위 타입 사이에
// Java의 implements 관계가 전혀 없다), 그리고 실측 결과 Jackson 배선도 계약대로 동작하지
// 않는다 — 역직렬화는 InvalidTypeIdException으로 실패하고("Class CaseInput not subtype
// of WorkingCopyInput"), 직렬화는 @JsonIgnoreProperties(value="kind", allowSetters=true)
// 때문에 실제 kind 값 대신 클래스 simple name이 나간다. 즉 생성된 union 클래스는 Jackson
// 양방향 모두 계약을 위반한다. union을 필드 타입으로 쓰는 5개 union(WorkingCopyInput,
// WorkingCopy, Inline, CaseRenderBlock, PublicRenderModel)을 실제로 쓰는 operation은
// Plan 02에서 전략을 정한 뒤 구현한다 — 이번 Task 8/9(getStudioSession,
// listStudioCatalog)는 이 union들을 쓰지 않으므로 막히지 않는다.
// ---------------------------------------------------------------------------
openApiGenerate {
generatorName = 'spring'
inputSpec = "${rootDir}/config/openapi/studio-v1.yaml".toString()
outputDir = layout.buildDirectory.dir('generated/openapi').get().asFile.path
modelPackage = 'dev.caskeleton.adapter.inbound.web.techlog.studio.api.model'
globalProperties.set(['models': ''])
generateModelTests = false
generateModelDocumentation = false
configOptions = [
useSpringBoot3: 'true',
useJakartaEe: 'true',
openApiNullable: 'true',
useOneOfInterfaces: 'false',
]
}
// sourceSets.generatedOpenapi 정의와 그걸 소비하는 jar/test classpath 배선은 이 파일
// 맨 위로 옮겼다(리뷰 라운드 2 fix) — 이유는 그 자리의 주석 참고. 여기 남은 건
// generatedOpenapiImplementation의 의존성 상속뿐이다.
configurations {
// 생성 DTO 컴파일에 필요한 의존성(jakarta.validation, swagger-annotations,
// jackson-databind-nullable, spring-web의 @Nullable/@DateTimeFormat 등)은 이미 main의
// implementation에 다 있다 — 따로 중복 선언하지 않고 그대로 물려받는다. main의
// implementation은 generatedOpenapi.output을 포함하지 않으므로(위 참고) 이 확장은
// 순환을 만들지 않는다.
generatedOpenapiImplementation.extendsFrom(implementation)
}
tasks.named('compileGeneratedOpenapiJava', JavaCompile) {
dependsOn 'openApiGenerate'
options.errorprone.enabled = false
// 루트 build.gradle의 tasks.withType(JavaCompile).configureEach가 -Werror와
// -Xlint:deprecation을 이미 넣어 놓은 뒤에 이 설정이 평가되므로(subprojects 블록이
// 먼저, 이 파일이 나중) 여기서 빼는 게 마지막 값으로 남는다.
doFirst {
options.compilerArgs.removeAll(['-Werror', '-Xlint:deprecation'])
}
}
// checkstyle/spotbugs는 sourceSet마다 별도 태스크(checkstyleGeneratedOpenapi,
// spotbugsGeneratedOpenapi)를 만든다 — 그 태스크만 끈다. checkstyleMain/spotbugsMain은
// 생성 코드가 더 이상 main sourceSet에 없으므로 애초에 이 파일들을 보지 않는다.
tasks.matching { it.name == 'checkstyleGeneratedOpenapi' }.configureEach {
dependsOn 'openApiGenerate'
enabled = false
}
tasks.matching { it.name == 'spotbugsGeneratedOpenapi' }.configureEach {
dependsOn 'openApiGenerate'
enabled = false
}
// spotless는 sourceSet과 무관하게 글롭으로 java 파일을 찾으므로 생성 경로를 명시적으로
// 뺀다. spotlessJava가 openApiGenerate보다 먼저 돌면 아직 없는 디렉터리를 글롭 검사하다
// 있으나 마나 한 차이라 dependsOn은 필요 없지만, 생성 전 상태에서 우연히 이전 빌드의 생성물이
// 남아 채점되는 걸 막기 위해 순서를 맞춘다.
tasks.matching { it.name.startsWith('spotless') }.configureEach {
dependsOn 'openApiGenerate'
}
spotless { java { targetExclude('build/generated/**') } }
+103 -103
View File
@@ -1,65 +1,65 @@
# This is a Gradle generated file for dependency locking.
# Manual edits can break the build and are not advised.
# This file is expected to be part of source control.
biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=compileClasspath,testCompileClasspath
ch.qos.logback:logback-classic:1.5.21=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
ch.qos.logback:logback-core:1.5.21=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
com.fasterxml.jackson.core:jackson-annotations:2.20=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
com.fasterxml.jackson.core:jackson-core:2.20.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
com.fasterxml.jackson.core:jackson-databind:2.20.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.20.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.20.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
com.fasterxml.jackson:jackson-bom:2.20.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
com.fasterxml:classmate:1.7.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor
com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor
biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=compileClasspath,generatedOpenapiCompileClasspath,testCompileClasspath
ch.qos.logback:logback-classic:1.5.21=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
ch.qos.logback:logback-core:1.5.21=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
com.fasterxml.jackson.core:jackson-annotations:2.20=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
com.fasterxml.jackson.core:jackson-core:2.20.1=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
com.fasterxml.jackson.core:jackson-databind:2.20.1=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.20.1=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.20.1=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
com.fasterxml.jackson:jackson-bom:2.20.1=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
com.fasterxml:classmate:1.7.1=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,generatedOpenapiAnnotationProcessor,testAnnotationProcessor
com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,generatedOpenapiAnnotationProcessor,testAnnotationProcessor
com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs
com.github.spotbugs:spotbugs-annotations:4.8.6=compileClasspath,testCompileClasspath
com.github.spotbugs:spotbugs-annotations:4.8.6=compileClasspath,generatedOpenapiCompileClasspath,testCompileClasspath
com.github.spotbugs:spotbugs:4.10.2=spotbugs
com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs
com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,testAnnotationProcessor
com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,testAnnotationProcessor
com.google.auto:auto-common:1.2.2=annotationProcessor,testAnnotationProcessor
com.google.code.findbugs:jsr305:3.0.2=checkstyle,compileClasspath,spotbugs,testCompileClasspath
com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,generatedOpenapiAnnotationProcessor,testAnnotationProcessor
com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,generatedOpenapiAnnotationProcessor,testAnnotationProcessor
com.google.auto:auto-common:1.2.2=annotationProcessor,generatedOpenapiAnnotationProcessor,testAnnotationProcessor
com.google.code.findbugs:jsr305:3.0.2=checkstyle,compileClasspath,generatedOpenapiCompileClasspath,spotbugs,testCompileClasspath
com.google.code.gson:gson:2.13.2=spotbugs
com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,testAnnotationProcessor
com.google.errorprone:error_prone_annotations:2.38.0=compileClasspath,testCompileClasspath
com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,generatedOpenapiAnnotationProcessor,testAnnotationProcessor
com.google.errorprone:error_prone_annotations:2.38.0=compileClasspath,generatedOpenapiCompileClasspath,testCompileClasspath
com.google.errorprone:error_prone_annotations:2.41.0=spotbugs
com.google.errorprone:error_prone_annotations:2.47.0=checkstyle
com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,testAnnotationProcessor
com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,testAnnotationProcessor
com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,testAnnotationProcessor
com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,testAnnotationProcessor
com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,testAnnotationProcessor
com.google.guava:guava:33.5.0-jre=annotationProcessor,testAnnotationProcessor
com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,generatedOpenapiAnnotationProcessor,testAnnotationProcessor
com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,generatedOpenapiAnnotationProcessor,testAnnotationProcessor
com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,generatedOpenapiAnnotationProcessor,testAnnotationProcessor
com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,generatedOpenapiAnnotationProcessor,testAnnotationProcessor
com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,generatedOpenapiAnnotationProcessor,testAnnotationProcessor
com.google.guava:guava:33.5.0-jre=annotationProcessor,generatedOpenapiAnnotationProcessor,testAnnotationProcessor
com.google.guava:guava:33.6.0-jre=checkstyle
com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,testAnnotationProcessor
com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,testAnnotationProcessor
com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,testAnnotationProcessor
com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,generatedOpenapiAnnotationProcessor,testAnnotationProcessor
com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,generatedOpenapiAnnotationProcessor,testAnnotationProcessor
com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,generatedOpenapiAnnotationProcessor,testAnnotationProcessor
com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins
com.jayway.jsonpath:json-path:2.9.0=testCompileClasspath,testRuntimeClasspath
com.nimbusds:nimbus-jose-jwt:10.4=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
com.nimbusds:nimbus-jose-jwt:10.4=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
com.puppycrawl.tools:checkstyle:13.5.0=checkstyle
com.vaadin.external.google:android-json:0.0.20131108.vaadin1=testCompileClasspath,testRuntimeClasspath
commons-beanutils:commons-beanutils:1.11.0=checkstyle
commons-collections:commons-collections:3.2.2=checkstyle
commons-io:commons-io:2.21.0=spotbugs
commons-logging:commons-logging:1.3.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
commons-logging:commons-logging:1.3.5=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
info.picocli:picocli:4.7.7=checkstyle
io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor
io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor
io.micrometer:micrometer-commons:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.micrometer:micrometer-observation:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.projectreactor:reactor-core:3.8.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,generatedOpenapiAnnotationProcessor,testAnnotationProcessor
io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,generatedOpenapiAnnotationProcessor,testAnnotationProcessor
io.micrometer:micrometer-commons:1.16.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.micrometer:micrometer-observation:1.16.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.projectreactor:reactor-core:3.8.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.projectreactor:reactor-test:3.8.0=testCompileClasspath,testRuntimeClasspath
io.swagger.core.v3:swagger-annotations-jakarta:2.2.38=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.swagger.core.v3:swagger-core-jakarta:2.2.38=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.swagger.core.v3:swagger-models-jakarta:2.2.38=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
jakarta.activation:jakarta.activation-api:2.1.4=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
jakarta.annotation:jakarta.annotation-api:3.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
jakarta.validation:jakarta.validation-api:3.1.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor
io.swagger.core.v3:swagger-annotations-jakarta:2.2.38=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.swagger.core.v3:swagger-core-jakarta:2.2.38=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.swagger.core.v3:swagger-models-jakarta:2.2.38=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
jakarta.activation:jakarta.activation-api:2.1.4=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
jakarta.annotation:jakarta.annotation-api:3.0.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
jakarta.validation:jakarta.validation-api:3.1.1=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
javax.inject:javax.inject:1=annotationProcessor,generatedOpenapiAnnotationProcessor,testAnnotationProcessor
jaxen:jaxen:2.0.0=spotbugs
net.bytebuddy:byte-buddy-agent:1.17.8=testCompileClasspath,testRuntimeClasspath
net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath
@@ -68,21 +68,21 @@ net.minidev:json-smart:2.6.0=testCompileClasspath,testRuntimeClasspath
net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs
org.antlr:antlr4-runtime:4.13.2=checkstyle
org.apache.bcel:bcel:6.12.0=spotbugs
org.apache.commons:commons-lang3:3.20.0=checkstyle,compileClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath
org.apache.commons:commons-lang3:3.20.0=checkstyle,compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath
org.apache.commons:commons-text:1.15.0=spotbugs
org.apache.commons:commons-text:1.3=checkstyle
org.apache.httpcomponents:httpclient:4.5.13=checkstyle
org.apache.httpcomponents:httpcore:4.4.16=checkstyle
org.apache.logging.log4j:log4j-api:2.25.2=compileClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath
org.apache.logging.log4j:log4j-api:2.25.2=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath
org.apache.logging.log4j:log4j-core:2.25.2=spotbugs
org.apache.logging.log4j:log4j-to-slf4j:2.25.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.apache.logging.log4j:log4j-to-slf4j:2.25.2=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.apache.maven.doxia:doxia-core:1.12.0=checkstyle
org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle
org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle
org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle
org.apache.tomcat.embed:tomcat-embed-core:11.0.14=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.apache.tomcat.embed:tomcat-embed-el:11.0.14=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.apache.tomcat.embed:tomcat-embed-core:11.0.14=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.apache.tomcat.embed:tomcat-embed-el:11.0.14=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.apache.xbean:xbean-reflect:3.7=checkstyle
org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath
org.assertj:assertj-core:3.27.6=testCompileClasspath,testRuntimeClasspath
@@ -93,10 +93,10 @@ org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle
org.codehaus.plexus:plexus-utils:3.3.0=checkstyle
org.dom4j:dom4j:2.2.0=spotbugs
org.hamcrest:hamcrest:3.0=testCompileClasspath,testRuntimeClasspath
org.hibernate.validator:hibernate-validator:9.0.1.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.hibernate.validator:hibernate-validator:9.0.1.Final=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.javassist:javassist:3.28.0-GA=checkstyle
org.jboss.logging:jboss-logging:3.6.1.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath
org.jboss.logging:jboss-logging:3.6.1.Final=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,generatedOpenapiAnnotationProcessor,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter-api:6.0.1=testCompileClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter-engine:6.0.1=testRuntimeClasspath
org.junit.jupiter:junit-jupiter-params:6.0.1=testCompileClasspath,testRuntimeClasspath
@@ -109,80 +109,80 @@ org.junit:junit-bom:6.1.0=spotbugs
org.mockito:mockito-core:5.20.0=mockitoAgent,testCompileClasspath,testRuntimeClasspath
org.mockito:mockito-junit-jupiter:5.20.0=testCompileClasspath,testRuntimeClasspath
org.objenesis:objenesis:3.3=testRuntimeClasspath
org.openapitools:jackson-databind-nullable:0.2.6=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.openapitools:jackson-databind-nullable:0.2.6=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath
org.osgi:org.osgi.annotation.bundle:2.0.0=compileClasspath,testCompileClasspath
org.osgi:org.osgi.annotation.versioning:1.1.2=compileClasspath,testCompileClasspath
org.osgi:org.osgi.resource:1.0.0=compileClasspath,testCompileClasspath
org.osgi:org.osgi.service.serviceloader:1.0.0=compileClasspath,testCompileClasspath
org.osgi:org.osgi.annotation.bundle:2.0.0=compileClasspath,generatedOpenapiCompileClasspath,testCompileClasspath
org.osgi:org.osgi.annotation.versioning:1.1.2=compileClasspath,generatedOpenapiCompileClasspath,testCompileClasspath
org.osgi:org.osgi.resource:1.0.0=compileClasspath,generatedOpenapiCompileClasspath,testCompileClasspath
org.osgi:org.osgi.service.serviceloader:1.0.0=compileClasspath,generatedOpenapiCompileClasspath,testCompileClasspath
org.ow2.asm:asm-analysis:9.10.1=spotbugs
org.ow2.asm:asm-commons:9.10.1=spotbugs
org.ow2.asm:asm-tree:9.10.1=spotbugs
org.ow2.asm:asm-util:9.10.1=spotbugs
org.ow2.asm:asm:9.10.1=spotbugs
org.ow2.asm:asm:9.7.1=testCompileClasspath,testRuntimeClasspath
org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor
org.reactivestreams:reactive-streams:1.0.4=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.pcollections:pcollections:4.0.1=annotationProcessor,generatedOpenapiAnnotationProcessor,testAnnotationProcessor
org.reactivestreams:reactive-streams:1.0.4=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.reflections:reflections:0.10.2=checkstyle
org.skyscreamer:jsonassert:1.5.3=testCompileClasspath,testRuntimeClasspath
org.slf4j:jul-to-slf4j:2.0.17=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.slf4j:slf4j-api:2.0.17=compileClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath
org.slf4j:jul-to-slf4j:2.0.17=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.slf4j:slf4j-api:2.0.17=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath
org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j
org.springdoc:springdoc-openapi-starter-common:3.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springdoc:springdoc-openapi-starter-webmvc-api:3.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springdoc:springdoc-openapi-starter-common:3.0.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springdoc:springdoc-openapi-starter-webmvc-api:3.0.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-configuration-processor:4.0.0=annotationProcessor
org.springframework.boot:spring-boot-http-client:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-http-converter:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-jackson:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-http-converter:4.0.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-jackson:4.0.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-restclient:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-resttestclient:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-security-oauth2-resource-server:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-security:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-servlet:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-security-oauth2-resource-server:4.0.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-security:4.0.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-servlet:4.0.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-jackson:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-logging:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-oauth2-resource-server:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-security:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-jackson:4.0.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-logging:4.0.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-oauth2-resource-server:4.0.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-security:4.0.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-test:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-tomcat:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-validation:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-web:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-tomcat:4.0.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-validation:4.0.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-web:4.0.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter:4.0.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-test:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-tomcat:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-validation:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-web-server:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-tomcat:4.0.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-validation:4.0.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-web-server:4.0.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-webmvc:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.security:spring-security-config:7.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.security:spring-security-core:7.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.security:spring-security-crypto:7.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.security:spring-security-oauth2-core:7.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.security:spring-security-oauth2-jose:7.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.security:spring-security-oauth2-resource-server:7.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-webmvc:4.0.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot:4.0.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.security:spring-security-config:7.0.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.security:spring-security-core:7.0.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.security:spring-security-crypto:7.0.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.security:spring-security-oauth2-core:7.0.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.security:spring-security-oauth2-jose:7.0.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.security:spring-security-oauth2-resource-server:7.0.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.security:spring-security-test:7.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.security:spring-security-web:7.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.session:spring-session-core:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-aop:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-beans:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-context:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-core:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-expression:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.security:spring-security-web:7.0.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.session:spring-session-core:4.0.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-aop:7.0.1=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-beans:7.0.1=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-context:7.0.1=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-core:7.0.1=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-expression:7.0.1=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-test:7.0.1=testCompileClasspath,testRuntimeClasspath
org.springframework:spring-web:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-webflux:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-webmvc:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-web:7.0.1=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-webflux:7.0.1=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-webmvc:7.0.1=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs
org.xmlunit:xmlunit-core:2.10.4=testCompileClasspath,testRuntimeClasspath
org.yaml:snakeyaml:2.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
tools.jackson.core:jackson-core:3.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
tools.jackson.core:jackson-databind:3.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
tools.jackson:jackson-bom:3.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.yaml:snakeyaml:2.5=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
tools.jackson.core:jackson-core:3.0.2=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
tools.jackson.core:jackson-databind:3.0.2=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
tools.jackson:jackson-bom:3.0.2=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
empty=
@@ -0,0 +1,52 @@
package dev.caskeleton.adapter.inbound.web.techlog;
import dev.caskeleton.application.techlog.error.StudioError;
/**
* Studio 실패의 client-safe {@code error.message} 단일 출처.
*
* <p>{@code StudioException#getMessage()}는 Studio use case/facade가 진단용으로 채우는 원문이라 {@code
* ApiErrorCarrier} javadoc이 경고하는 대로 SQLState나 upstream detail을 실을 수 있다. 그래서 응답에는 절대 흘리지 않고, 이 클래스가
* code별 고정 문구만 내보낸다 — 스켈레톤의 {@code ClientSafeErrorMessages}가 {@code OperationalError}에 대해 하는 것과 같은
* 역할을 {@link StudioError}에 대해 한다.
*
* <p>문구는 {@code docs/registries/error-codes.yaml}의 각 row {@code client_safe_message}와 정확히 같아야 한다 —
* {@code StudioErrorRegistryTest}가 그 일치를 고정한다. {@code PAYLOAD_TOO_LARGE}/{@code
* UNSUPPORTED_MEDIA_TYPE}은 Studio 전용 row가 없고 {@code feature-api-contract-baseline}이 이미 등록한 row를
* 재사용하므로(Task 5 report 5절) 그 row의 영문 문구를 그대로 따른다 — 나머지는 Studio 전용 한국어 문구다.
*
* <p>{@link StudioError}를 exhaustive switch로 매핑하므로(default 없음) 새 상수를 추가하면 이 파일도 컴파일 타임에 고쳐야 한다 — 문구
* 누락이 생길 수 없다.
*/
public final class StudioClientSafeMessages {
private StudioClientSafeMessages() {}
public static String forError(StudioError error) {
return switch (error) {
case AUTHENTICATION_REQUIRED -> "Studio 인증이 필요합니다";
case STUDIO_ACCESS_DENIED -> "이 Studio 리소스에 접근할 권한이 없습니다";
case DOCUMENT_NOT_FOUND -> "요청한 문서를 찾을 수 없습니다";
case VERSION_CONFLICT -> "저장된 version이 더 최신입니다";
case REQUEST_VALIDATION_FAILED -> "요청 형식이 올바르지 않습니다";
case DOCUMENT_VALIDATION_FAILED -> "문서 검증에 실패했습니다";
case VALIDATION_STALE -> "검증 결과가 최신 문서 기준이 아닙니다. 다시 검증해 주세요";
case PREVIEW_NOT_FOUND -> "요청한 미리보기를 찾을 수 없습니다";
case PREVIEW_STALE -> "미리보기가 최신 문서 기준이 아닙니다. 다시 생성해 주세요";
case PREVIEW_EXPIRED -> "미리보기가 만료되었습니다. 다시 생성해 주세요";
case PUBLICATION_NOT_FOUND -> "요청한 게시물을 찾을 수 없습니다";
case PUBLICATION_CONFLICT -> "게시 작업이 다른 변경과 충돌했습니다";
case PUBLICATION_EVENT_NOT_FOUND -> "요청한 게시 이벤트를 찾을 수 없습니다";
case PUBLICATION_SNAPSHOT_NOT_FOUND -> "요청한 게시 스냅샷을 찾을 수 없습니다";
case WARNING_ACKNOWLEDGEMENT_REQUIRED -> "경고 확인이 필요합니다. 확인 후 다시 시도해 주세요";
case IDEMPOTENCY_KEY_REUSED -> "Idempotency 키가 다른 요청에 재사용되었습니다";
case ASSET_NOT_FOUND -> "요청한 자산을 찾을 수 없습니다";
case ASSET_NOT_READY -> "자산 처리가 아직 완료되지 않았습니다";
case ASSET_IN_USE -> "자산이 사용 중이라 이 작업을 수행할 수 없습니다";
case ASSET_QUARANTINED -> "자산이 격리 처리되어 사용할 수 없습니다";
case PAYLOAD_TOO_LARGE -> "Request payload is too large";
case UNSUPPORTED_MEDIA_TYPE -> "Request Content-Type is not supported";
case STUDIO_UNAVAILABLE -> "Studio 서비스를 일시적으로 사용할 수 없습니다. 잠시 후 다시 시도해 주세요";
};
}
}
@@ -0,0 +1,92 @@
package dev.caskeleton.adapter.inbound.web.techlog;
import dev.caskeleton.adapter.inbound.web.error.ErrorResponseFactory;
import dev.caskeleton.application.techlog.error.StudioError;
import dev.caskeleton.application.techlog.error.StudioException;
import dev.caskeleton.shared.response.Envelope;
import java.util.List;
import java.util.Map;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MissingServletRequestParameterException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
/**
* Studio 실패를 스켈레톤 봉투로 옮긴다. 템플릿의 {@code GlobalExceptionHandler}를 수정하지 않기 위해 별도 advice로 둔다 — 그 파일은
* template sync 대상이다.
*
* <p>{@code error.message}에는 {@link StudioClientSafeMessages}가 주는 code별 고정 문구만 싣는다 — {@link
* StudioException#getMessage()}(진단용 원문)는 SQLState·upstream detail을 실을 수 있어 client-unsafe하다({@code
* ApiErrorCarrier} javadoc). 원문은 버리지 않고 서버 로그에만 남긴다 — {@code GlobalExceptionHandler}의 {@code
* handlePersistenceFailure}/{@code handleDependencyFailure}가 분류된 하위 계층 실패를 로깅하는 것과 같은 패턴이다.
*
* <p><b>{@code basePackages} 스코프 (final whole-branch review B4).</b> 이 advice는 {@code
* dev.caskeleton.adapter.inbound.web.techlog} 아래의 컨트롤러(현재 studio 컨트롤러 전부가 여기 산다, {@code
* studio.controller})에만 적용된다. {@link #handleMissingParameter}/{@link #handleTypeMismatch}는 Spring
* MVC 표준 바인딩 예외를 계약 코드로 옮기는데, 스코프 없이 전역으로 두면 fileserver·healthcheck 같은 studio 밖 컨트롤러의 같은 예외까지 가로채 그
* 기능들의 기존 오류 응답 모양(바로 이 advice가 없었을 때의 {@code GlobalExceptionHandler} 동작)을 바꿔버린다 — 이 브랜치가 건드릴 권한이
* 없는 기능이다. {@code StudioException} 처리는 애초에 studio 코드만 이 예외를 던지므로 스코프를 좁혀도 동작이 바뀌지 않는다.
*/
@Order(Ordered.HIGHEST_PRECEDENCE)
@RestControllerAdvice(basePackages = "dev.caskeleton.adapter.inbound.web.techlog")
public class StudioExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(StudioExceptionHandler.class);
@ExceptionHandler(StudioException.class)
public ResponseEntity<Envelope<Void>> handleStudio(StudioException ex) {
StudioError error = ex.studioError();
log.error(
"studio failure classified as {} (category={}, retryable={}): {}",
error.code(),
error.category(),
error.retryable(),
ex.getMessage(),
ex);
return ErrorResponseFactory.envelope(
error, StudioClientSafeMessages.forError(error), ex.details());
}
/**
* 필수 쿼리 파라미터 누락(예: {@code GET /api/v1/studio/catalog}의 {@code type}). {@code
* GlobalExceptionHandler}는 이 예외를 오버라이드하지 않으므로 부모 {@code ResponseEntityExceptionHandler}가 그대로 bare
* {@code ProblemDetail}(content-type {@code application/problem+json})을 만들고, {@code
* EnvelopeBodyAdvice}의 JSON 미디어타입 검사에 걸려 봉투를 못 씌운다 — ADR-006이 쓰지 않기로 한 RFC 7807이 그대로 나간다(final
* whole-branch review B4). studio 스코프에서 계약 코드 {@link StudioError#REQUEST_VALIDATION_FAILED}(422)로
* 옮긴다.
*/
@ExceptionHandler(MissingServletRequestParameterException.class)
public ResponseEntity<Envelope<Void>> handleMissingParameter(
MissingServletRequestParameterException ex) {
return requestValidationFailed(ex.getParameterName(), "Required parameter is missing");
}
/**
* 쿼리 파라미터 타입 불일치(예: {@code type=BOGUS}, {@code limit=abc}). {@code GlobalExceptionHandler}도 이 예외를
* 처리하지만 {@code OperationalError.BAD_PARAMETER}를 낸다 — Studio 계약 23종에 없는 코드다. studio 스코프에서 계약 코드로
* 옮긴다.
*/
@ExceptionHandler(MethodArgumentTypeMismatchException.class)
public ResponseEntity<Envelope<Void>> handleTypeMismatch(MethodArgumentTypeMismatchException ex) {
return requestValidationFailed(ex.getName(), "Parameter value is invalid");
}
/**
* {@code details}를 계약의 {@code ValidationErrorDetails}({@code fieldErrors: [{path, message}]}) 모양에
* 맞춰 싣는다 — 자유형 {@code Object}로 아무 모양이나 실으면 계약의 {@code oneOf} 제약을 위반한다.
*/
private static ResponseEntity<Envelope<Void>> requestValidationFailed(
String parameterName, String message) {
Map<String, Object> fieldError = Map.of("path", "/" + parameterName, "message", message);
Map<String, Object> details = Map.of("fieldErrors", List.of(fieldError));
return ErrorResponseFactory.envelope(
StudioError.REQUEST_VALIDATION_FAILED,
StudioClientSafeMessages.forError(StudioError.REQUEST_VALIDATION_FAILED),
details);
}
}
@@ -0,0 +1,59 @@
package dev.caskeleton.adapter.inbound.web.techlog.studio.controller;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.CatalogEntry;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.CatalogPage;
import dev.caskeleton.application.techlog.studio.query.CatalogEntryType;
import dev.caskeleton.application.techlog.studio.query.CatalogEntryView;
import dev.caskeleton.application.techlog.studio.query.CatalogPageView;
import dev.caskeleton.application.techlog.studio.query.ListCatalogQuery;
import dev.caskeleton.application.techlog.studio.service.ListCatalogUseCase;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* 반환값을 Envelope로 감싸지 않는다 — EnvelopeBodyAdvice가 감싼다.
*
* <p>{@code CatalogEntryType}은 application({@code
* dev.caskeleton.application.techlog.studio.query})과 웹 계약 생성 DTO({@code
* dev.caskeleton.adapter.inbound.web.techlog.studio.api.model})에 같은 이름으로 각각 존재한다 — 한 파일에서 둘 다 단일 타입
* import로 쓰면 컴파일이 깨진다. 이 컨트롤러는 application 쪽을 단일 import로 쓰고(요청 파라미터·use case 입력), 웹 계약 쪽은 {@link
* #toApi}에서 FQN으로만 참조한다(응답 DTO 조립).
*/
@RestController
public class StudioCatalogController {
private final ListCatalogUseCase listCatalog;
public StudioCatalogController(ListCatalogUseCase listCatalog) {
this.listCatalog = listCatalog;
}
@GetMapping("/api/v1/studio/catalog")
public CatalogPage listStudioCatalog(
@RequestParam("type") CatalogEntryType type,
@RequestParam(value = "q", required = false) String q,
@RequestParam(value = "cursor", required = false) String cursor,
@RequestParam(value = "limit", defaultValue = "20") int limit) {
CatalogPageView page = listCatalog.handle(new ListCatalogQuery(type, q, cursor, limit));
CatalogPage body = new CatalogPage();
body.setItems(page.items().stream().map(StudioCatalogController::toApi).toList());
body.setNextCursor(page.nextCursor());
return body;
}
private static CatalogEntry toApi(CatalogEntryView view) {
CatalogEntry entry = new CatalogEntry();
entry.setId(view.id());
entry.setType(
dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.CatalogEntryType.fromValue(
view.type().name()));
entry.setLabel(view.label());
entry.setDependencyRevision(view.dependencyRevision());
if (view.kind() != null) {
entry.setKind(CatalogEntry.KindEnum.fromValue(view.kind()));
}
entry.setPublicPath(view.publicPath());
return entry;
}
}
@@ -0,0 +1,105 @@
package dev.caskeleton.adapter.inbound.web.techlog.studio.controller;
import dev.caskeleton.adapter.inbound.web.auth.AuthenticatedPrincipal;
import dev.caskeleton.adapter.inbound.web.settings.SecuritySettings;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.StudioSession;
import dev.caskeleton.application.techlog.error.StudioError;
import dev.caskeleton.application.techlog.error.StudioException;
import java.util.Set;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.web.csrf.CsrfToken;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* 세션은 순수 전송 상태다 — principal과 CSRF 토큰뿐이라 도메인 규칙이 없다. application use case를 끼우지 않는 이유이고,
* application-core는 Spring을 볼 수 없어 SecurityContext에 접근할 수도 없다.
*
* <p>반환값을 {@code Envelope}로 감싸지 않는다. {@code EnvelopeBodyAdvice}가 감싼다.
*
* <p><b>CSRF가 꺼진 auth-mode.</b> {@code SecurityConfig.filterChain}의 JWT 분기는 {@code csrf(csrf ->
* csrf.disable())}로 {@code CsrfConfigurer} 자체를 제거한다 — {@code CsrfFilter}가 돌지 않고 request attribute를
* 아무도 채우지 않는다. 그런데 Spring Security 7.0.0의 {@code CsrfTokenArgumentResolver}는
* {@code @EnableWebSecurity}만 있으면 무조건 등록되고, {@code resolveArgument}는 request attribute를 캐스팅만 할 뿐
* null 체크가 없다 — 그래서 CSRF가 꺼진 모드에서는 {@code csrfToken} 파라미터가 항상 {@code null}이다. Studio는 CSRF 없이 실제로
* 동작할 수 없으므로, 가짜 토큰을 지어내 200을 돌려주는 대신 {@link StudioError#STUDIO_UNAVAILABLE}(503)로 그 사실을 정직하게 보고한다
* — 세션 인프라가 갖춰지면(redis-session + 완전한 CSRF 배선) 코드 변경 없이 200으로 바뀐다. ({@code
* StudioSessionCsrfDisabledTest}가 이 경로를 고정한다.)
*/
@RestController
public class StudioSessionController {
/** studio-v1.yaml {@code StudioSession.csrfHeaderName}은 {@code const} — 이 값만 유효하다. */
private static final String CONTRACT_CSRF_HEADER_NAME = "X-CSRF-TOKEN";
/** studio-v1.yaml {@code StudioSession.roles.maxItems}. */
private static final int CONTRACT_MAX_ROLES = 20;
private final String csrfHeaderName;
/**
* {@code csrf-header-name}을 하드코드하지 않고 설정에서 읽되, 계약이 고정한 값과 다르면 부팅 시점에 즉시 실패한다 — 계약의 {@code const}와
* 설정이 갈라지는 건 배포 오류이지 런타임에 조용히 넘어갈 문제가 아니다.
*/
public StudioSessionController(SecuritySettings securitySettings) {
String configured = securitySettings.session().csrfHeaderName();
if (!CONTRACT_CSRF_HEADER_NAME.equals(configured)) {
throw new IllegalStateException(
"ca-skeleton.security.session.csrf-header-name must be \""
+ CONTRACT_CSRF_HEADER_NAME
+ "\" (studio-v1.yaml StudioSession.csrfHeaderName is a contract const) but was"
+ " configured as \""
+ configured
+ "\"");
}
this.csrfHeaderName = configured;
}
@GetMapping("/api/v1/studio/session")
public StudioSession getStudioSession(
@AuthenticationPrincipal AuthenticatedPrincipal principal, CsrfToken csrfToken) {
if (csrfToken == null) {
throw StudioException.of(
StudioError.STUDIO_UNAVAILABLE,
"CSRF token unavailable: CSRF protection is disabled for the active auth-mode");
}
Set<String> roles = Set.copyOf(principal.roles());
if (roles.size() > CONTRACT_MAX_ROLES) {
// 조용히 잘라내면 클라이언트가 실제 권한과 다른 role 집합을 받는다 — IdP 쪽 role 매핑이 잘못됐다는
// 신호를 숨기는 셈이라, 잘라내는 대신 실패시켜 드러낸다.
throw StudioException.of(
StudioError.STUDIO_UNAVAILABLE,
"principal role count "
+ roles.size()
+ " exceeds contract max "
+ CONTRACT_MAX_ROLES
+ " (studio-v1.yaml StudioSession.roles.maxItems)");
}
String displayName = displayNameOf(principal);
if (displayName == null || displayName.isBlank()) {
throw StudioException.of(
StudioError.STUDIO_UNAVAILABLE,
"principal has neither a usable email nor idpUserId; cannot satisfy"
+ " StudioSession.displayName minLength 1");
}
StudioSession session = new StudioSession();
session.setAuthenticated(true);
session.setDisplayName(displayName);
session.setRoles(roles);
session.setCsrfToken(csrfToken.getToken());
session.setCsrfHeaderName(csrfHeaderName);
return session;
}
/**
* `displayName`은 계약상 1자 이상이다. profile capability(identity 모듈)가 들어오기 전까지 email을 쓰고, 없으면 IdP
* subject로 대체한다. 둘 다 비어 있으면 {@code getStudioSession}이 실패시킨다(위 참조).
*/
private static String displayNameOf(AuthenticatedPrincipal principal) {
String email = principal.email();
return (email == null || email.isBlank()) ? principal.idpUserId() : email;
}
}
@@ -0,0 +1,72 @@
package dev.caskeleton.adapter.inbound.web.error;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import dev.caskeleton.adapter.inbound.web.envelope.EnvelopeBodyAdvice;
import dev.caskeleton.adapter.inbound.web.techlog.StudioExceptionHandler;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.SpringBootConfiguration;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.security.autoconfigure.SecurityAutoConfiguration;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* final whole-branch review B4: proves the negative for {@link StudioExceptionHandler}'s
* {@code @RestControllerAdvice(basePackages = "dev.caskeleton.adapter.inbound.web.techlog")}
* scoping.
*
* <p>{@link Probe} lives in {@code dev.caskeleton.adapter.inbound.web.error} — outside the {@code
* ...web.techlog} tree the advice is scoped to, standing in for a non-Studio feature (fileserver,
* healthcheck). A missing required parameter on it must still fall through to the inherited {@code
* ResponseEntityExceptionHandler} behaviour (bare {@code ProblemDetail}, {@code
* application/problem+json}) exactly as it did before {@code StudioExceptionHandler} grew {@code
* MissingServletRequestParameterException}/{@code MethodArgumentTypeMismatchException} handlers —
* this branch has no mandate to change fileserver/healthcheck's error shape. If the {@code
* basePackages} scope were ever dropped or widened to cover this package, this probe would start
* seeing {@code REQUEST_VALIDATION_FAILED} at 422 instead and this test would fail.
*/
@WebMvcTest(
controllers = StudioExceptionHandlerScopeTest.Probe.class,
excludeAutoConfiguration = SecurityAutoConfiguration.class)
@AutoConfigureMockMvc(addFilters = false)
@Import({
StudioExceptionHandlerScopeTest.Probe.class,
StudioExceptionHandler.class,
GlobalExceptionHandler.class,
EnvelopeBodyAdvice.class
})
class StudioExceptionHandlerScopeTest {
@Autowired private MockMvc mvc;
@Test
void missingParameterOnANonStudioControllerKeepsTheUnenvelopedGlobalHandlerBehaviour()
throws Exception {
mvc.perform(get("/probe/non-studio"))
.andExpect(status().is(400))
.andExpect(
content().contentTypeCompatibleWith(MediaType.valueOf("application/problem+json")));
}
@RestController
static class Probe {
@GetMapping("/probe/non-studio")
String probe(@RequestParam("required") String required) {
return required;
}
}
@SpringBootConfiguration
@EnableAutoConfiguration(exclude = SecurityAutoConfiguration.class)
static class TestBootstrap {}
}
@@ -0,0 +1,62 @@
package dev.caskeleton.adapter.inbound.web.techlog;
import static org.assertj.core.api.Assertions.assertThat;
import dev.caskeleton.application.techlog.error.StudioError;
import dev.caskeleton.application.techlog.error.StudioException;
import dev.caskeleton.shared.response.Envelope;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.springframework.http.ResponseEntity;
class StudioExceptionHandlerTest {
private final StudioExceptionHandler handler = new StudioExceptionHandler();
@Test
void mapsStudioExceptionToFailureEnvelopeWithContractCode() {
ResponseEntity<Envelope<Void>> response =
handler.handleStudio(StudioException.of(StudioError.DOCUMENT_NOT_FOUND, "없음"));
assertThat(response.getStatusCode().value()).isEqualTo(404);
Envelope<Void> body = response.getBody();
assertThat(body).isNotNull();
assertThat(body.success()).isFalse();
assertThat(body.error().code()).isEqualTo("DOCUMENT_NOT_FOUND");
assertThat(body.error().category()).isEqualTo("NOT_FOUND");
assertThat(body.error().retryable()).isFalse();
}
/**
* Regression test for the client-safe message leak: the handler must never surface {@link
* StudioException#getMessage()} (diagnostic-only, may carry SQLState/upstream detail) — only
* {@link StudioClientSafeMessages#forError(StudioError)}'s fixed, per-code text.
*/
@Test
void neverLeaksTheRawExceptionMessageAndUsesTheClientSafeTextInstead() {
String rawDiagnosticMessage = "pg constraint fk_document_project violated for id=42";
ResponseEntity<Envelope<Void>> response =
handler.handleStudio(
StudioException.of(StudioError.DOCUMENT_NOT_FOUND, rawDiagnosticMessage));
String message = response.getBody().error().message();
assertThat(message).isNotEqualTo(rawDiagnosticMessage);
assertThat(message)
.isEqualTo(StudioClientSafeMessages.forError(StudioError.DOCUMENT_NOT_FOUND));
}
@Test
void carriesDetailsForConflicts() {
ResponseEntity<Envelope<Void>> response =
handler.handleStudio(
StudioException.withDetails(
StudioError.VERSION_CONFLICT,
"충돌",
Map.of("latestDocument", Map.of("version", 8))));
assertThat(response.getStatusCode().value()).isEqualTo(409);
assertThat(response.getBody()).isNotNull();
assertThat(response.getBody().error().details()).isNotNull();
}
}
@@ -0,0 +1,138 @@
package dev.caskeleton.adapter.inbound.web.techlog.studio.controller;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import dev.caskeleton.adapter.inbound.web.envelope.EnvelopeBodyAdvice;
import dev.caskeleton.adapter.inbound.web.error.GlobalExceptionHandler;
import dev.caskeleton.adapter.inbound.web.techlog.StudioExceptionHandler;
import dev.caskeleton.application.techlog.studio.port.out.CatalogQueryPort;
import dev.caskeleton.application.techlog.studio.service.ListCatalogUseCase;
import dev.caskeleton.application.transaction.TransactionPort;
import java.util.function.Supplier;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.SpringBootConfiguration;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.security.autoconfigure.SecurityAutoConfiguration;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
/**
* final whole-branch review B4: {@code GET /api/v1/studio/catalog} without its required {@code
* type} query parameter throws {@code MissingServletRequestParameterException}. Neither {@code
* GlobalExceptionHandler} (which we cannot modify — template file) nor the old {@code
* StudioExceptionHandler} handled it, so it fell through to the inherited {@code
* ResponseEntityExceptionHandler} behaviour: a bare {@code ProblemDetail} body with content-type
* {@code application/problem+json}. {@code EnvelopeBodyAdvice#beforeBodyWrite}'s {@code
* MediaType.APPLICATION_JSON.includes(...)} guard then skips wrapping, so a bare RFC 7807 body
* leaks past the envelope — exactly the wire shape ADR-006 says this backend does not use.
*
* <p>{@code ?type=BOGUS} and {@code ?limit=abc} throw {@code MethodArgumentTypeMismatchException}
* instead — {@code GlobalExceptionHandler} does handle that one directly (so the body stays
* enveloped), but with {@code OperationalError.BAD_PARAMETER}, a code outside the 23-code Studio
* contract.
*
* <p>This test pins the fix: a Studio-scoped {@code StudioExceptionHandler} handler moves both
* exceptions to the contract's {@code REQUEST_VALIDATION_FAILED} at 422 (the status the {@code
* StudioError} enum and {@code docs/registries/error-codes.yaml} agree on), enveloped like every
* other Studio failure.
*
* <p>Follows the {@code @WebMvcTest} + hand-built {@code TestBootstrap} slice pattern documented in
* {@link StudioSessionEnvelopeTest} — this module's test source set has no {@code
* CaSkeletonApplication} for {@code @WebMvcTest} to bootstrap from. Security is fully excluded
* (like {@code NoResourceFoundErrorHandlingTest}) since none of these scenarios are
* authentication/authorization-related. {@link ListCatalogUseCase} is real, not mocked (it is a
* {@code final} class and this module's Mockito is not configured with the inline mock maker) —
* built from hand-written fake ports, and its port never runs because request binding fails before
* the controller method body is entered.
*/
@WebMvcTest(
controllers = StudioCatalogController.class,
excludeAutoConfiguration = SecurityAutoConfiguration.class)
@AutoConfigureMockMvc(addFilters = false)
@Import({
StudioCatalogController.class,
StudioExceptionHandler.class,
GlobalExceptionHandler.class,
EnvelopeBodyAdvice.class,
StudioCatalogBindingErrorEnvelopeTest.TestBeans.class
})
class StudioCatalogBindingErrorEnvelopeTest {
@Autowired private MockMvc mvc;
@Test
void missingRequiredTypeParameterIsEnvelopedAsRequestValidationFailed() throws Exception {
mvc.perform(get("/api/v1/studio/catalog"))
.andExpect(status().is(422))
.andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON))
.andExpect(jsonPath("$.success").value(false))
.andExpect(jsonPath("$.error.code").value("REQUEST_VALIDATION_FAILED"))
.andExpect(jsonPath("$.error.category").value("VALIDATION"));
}
@Test
void unknownTypeEnumValueIsEnvelopedAsRequestValidationFailed() throws Exception {
mvc.perform(get("/api/v1/studio/catalog").param("type", "BOGUS"))
.andExpect(status().is(422))
.andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON))
.andExpect(jsonPath("$.success").value(false))
.andExpect(jsonPath("$.error.code").value("REQUEST_VALIDATION_FAILED"));
}
@Test
void nonNumericLimitIsEnvelopedAsRequestValidationFailed() throws Exception {
mvc.perform(get("/api/v1/studio/catalog").param("type", "TOPIC").param("limit", "abc"))
.andExpect(status().is(422))
.andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON))
.andExpect(jsonPath("$.success").value(false))
.andExpect(jsonPath("$.error.code").value("REQUEST_VALIDATION_FAILED"));
}
static class TestBeans {
@Bean
ListCatalogUseCase listCatalogUseCase() {
CatalogQueryPort neverInvoked =
(type, query, cursor, limit) -> {
throw new AssertionError(
"ListCatalogUseCase must not run when request binding already failed");
};
return new ListCatalogUseCase(neverInvoked, new PassthroughTransactionPort());
}
}
/** Runs the action synchronously with no real transactional semantics — a slice test fake. */
private static final class PassthroughTransactionPort implements TransactionPort {
@Override
public <T> T inWrite(Supplier<T> action) {
return action.get();
}
@Override
public <T> T inRootWrite(Supplier<T> action) {
return action.get();
}
@Override
public <T> T inRead(Supplier<T> action) {
return action.get();
}
@Override
public <T> T inNew(Supplier<T> action) {
return action.get();
}
}
@SpringBootConfiguration
@EnableAutoConfiguration(exclude = SecurityAutoConfiguration.class)
static class TestBootstrap {}
}
@@ -0,0 +1,106 @@
package dev.caskeleton.adapter.inbound.web.techlog.studio.controller;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import dev.caskeleton.adapter.inbound.web.auth.AuthenticatedPrincipal;
import dev.caskeleton.adapter.inbound.web.settings.SecuritySettings;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.StudioSession;
import dev.caskeleton.application.techlog.error.StudioError;
import dev.caskeleton.application.techlog.error.StudioException;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
import org.junit.jupiter.api.Test;
import org.springframework.security.web.csrf.DefaultCsrfToken;
class StudioSessionControllerTest {
private final StudioSessionController controller =
new StudioSessionController(securitySettingsWithCsrfHeaderName("X-CSRF-TOKEN"));
@Test
void reportsAuthenticatedPrincipalAndCsrfToken() {
StudioSession session =
controller.getStudioSession(
new AuthenticatedPrincipal("sub-1", "donghyeon@example.com", Set.of("STUDIO_EDITOR")),
new DefaultCsrfToken("X-CSRF-TOKEN", "_csrf", "token-value"));
assertThat(session.getAuthenticated()).isTrue();
assertThat(session.getDisplayName()).isEqualTo("donghyeon@example.com");
assertThat(session.getRoles()).containsExactly("STUDIO_EDITOR");
assertThat(session.getCsrfToken()).isEqualTo("token-value");
assertThat(session.getCsrfHeaderName()).isEqualTo("X-CSRF-TOKEN");
}
@Test
void fallsBackToIdpUserIdWhenEmailIsAbsent() {
StudioSession session =
controller.getStudioSession(
new AuthenticatedPrincipal("sub-1", null, Set.of()),
new DefaultCsrfToken("X-CSRF-TOKEN", "_csrf", "t"));
assertThat(session.getDisplayName()).isEqualTo("sub-1");
}
@Test
void rejectsAMisconfiguredCsrfHeaderNameAtConstructionRatherThanServingTheWrongHeader() {
assertThatThrownBy(() -> new StudioSessionController(securitySettingsWithCsrfHeaderName(null)))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("X-CSRF-TOKEN")
.hasMessageContaining("X-XSRF-TOKEN");
}
@Test
void reportsStudioUnavailableRatherThanNpeWhenCsrfTokenIsNull() {
assertThatThrownBy(
() ->
controller.getStudioSession(
new AuthenticatedPrincipal(
"sub-1", "donghyeon@example.com", Set.of("STUDIO_EDITOR")),
null))
.isInstanceOf(StudioException.class)
.extracting(ex -> ((StudioException) ex).studioError())
.isEqualTo(StudioError.STUDIO_UNAVAILABLE);
}
@Test
void reportsStudioUnavailableRatherThanTruncatingRolesBeyondTheContractMax() {
Set<String> tooManyRoles =
IntStream.range(0, 21).mapToObj(i -> "ROLE_" + i).collect(Collectors.toUnmodifiableSet());
assertThatThrownBy(
() ->
controller.getStudioSession(
new AuthenticatedPrincipal("sub-1", "donghyeon@example.com", tooManyRoles),
new DefaultCsrfToken("X-CSRF-TOKEN", "_csrf", "t")))
.isInstanceOf(StudioException.class)
.extracting(ex -> ((StudioException) ex).studioError())
.isEqualTo(StudioError.STUDIO_UNAVAILABLE);
}
@Test
void reportsStudioUnavailableRatherThanAnEmptyDisplayNameWhenEmailAndIdpUserIdAreBothBlank() {
assertThatThrownBy(
() ->
controller.getStudioSession(
new AuthenticatedPrincipal(" ", " ", Set.of()),
new DefaultCsrfToken("X-CSRF-TOKEN", "_csrf", "t")))
.isInstanceOf(StudioException.class)
.extracting(ex -> ((StudioException) ex).studioError())
.isEqualTo(StudioError.STUDIO_UNAVAILABLE);
}
private static SecuritySettings securitySettingsWithCsrfHeaderName(String csrfHeaderName) {
SecuritySettings.SessionCookieSettings session =
new SecuritySettings.SessionCookieSettings(
null, null, null, null, null, null, csrfHeaderName);
return new SecuritySettings(
SecuritySettings.AuthenticationMode.JWT,
"https://issuer.example",
null,
List.of(),
session);
}
}
@@ -0,0 +1,116 @@
package dev.caskeleton.adapter.inbound.web.techlog.studio.controller;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import dev.caskeleton.adapter.inbound.web.auth.AuthenticatedPrincipal;
import dev.caskeleton.adapter.inbound.web.envelope.EnvelopeBodyAdvice;
import dev.caskeleton.adapter.inbound.web.error.GlobalExceptionHandler;
import dev.caskeleton.adapter.inbound.web.observability.MdcKeys;
import dev.caskeleton.adapter.inbound.web.settings.SecuritySettings;
import dev.caskeleton.adapter.inbound.web.techlog.StudioExceptionHandler;
import java.util.List;
import java.util.Set;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.slf4j.MDC;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.SpringBootConfiguration;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Import;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.test.web.servlet.MockMvc;
/**
* 프로덕션 JWT auth-mode를 재현한다: {@code SecurityConfig.filterChain}의 JWT 분기는 {@code csrf(csrf ->
* csrf.disable())}로 {@code CsrfConfigurer} 자체를 제거한다 — {@code CsrfFilter}가 돌지 않고 {@code CsrfToken}
* request attribute를 아무도 채우지 않는다.
*
* <p>그런데 {@code CsrfTokenArgumentResolver}(Spring Security 7.0.0, {@code
* WebMvcSecurityConfiguration.addArgumentResolvers}가 {@code @EnableWebSecurity}만 있으면 무조건 등록한다)의
* {@code resolveArgument}는 request attribute를 캐스팅만 할 뿐 null 체크가 없다 — attribute가 없으면 그냥 {@code
* null}을 돌려준다. 그래서 JWT 모드에서는 컨트롤러의 {@code CsrfToken csrfToken} 파라미터가 **항상 null**이다.
*
* <p>이 테스트는 그 사실을 슬라이스 필터체인으로 직접 재현한다 — {@link StudioSessionEnvelopeTest}의 {@code
* SecurityTestConfig}(CSRF 켜짐, Spring 기본값)와 정확히 반대다. 일부러 {@code
* SecurityMockMvcRequestPostProcessors.csrf()}를 쓰지 않는다 — 그 포스트 프로세서는 실제 필터체인 여부와 무관하게 request
* attribute를 직접 채워버려서, 쓰면 이 재현이 무력화된다(CSRF가 꺼져 있어도 토큰이 채워진 것처럼 보이게 된다).
*/
@WebMvcTest(controllers = StudioSessionController.class)
@Import({
StudioSessionController.class,
EnvelopeBodyAdvice.class,
StudioExceptionHandler.class,
GlobalExceptionHandler.class,
StudioSessionCsrfDisabledTest.SecurityTestConfig.class
})
class StudioSessionCsrfDisabledTest {
@Autowired private MockMvc mvc;
@AfterEach
void clearMdc() {
MDC.remove(MdcKeys.TRACE_ID);
}
@Test
void reportsStudioUnavailableRatherThanCrashingWhenCsrfIsDisabled() throws Exception {
MDC.put(MdcKeys.TRACE_ID, "test-trace-id");
mvc.perform(
get("/api/v1/studio/session")
.with(
authentication(
UsernamePasswordAuthenticationToken.authenticated(
new AuthenticatedPrincipal(
"sub-1", "donghyeon@example.com", Set.of("STUDIO_EDITOR")),
null,
Set.of(new SimpleGrantedAuthority("ROLE_STUDIO_EDITOR"))))))
.andExpect(status().isServiceUnavailable())
.andExpect(jsonPath("$.success").value(false))
.andExpect(jsonPath("$.error.code").value("STUDIO_UNAVAILABLE"))
.andExpect(jsonPath("$.error.retryable").value(true))
.andExpect(jsonPath("$.meta.traceId").isNotEmpty());
}
/**
* {@code SecurityConfig.filterChain}의 JWT 분기와 같은 모양 — {@code csrf().disable()} + {@code
* anyRequest().authenticated()}뿐. 실제 앱의 {@code SecurityConfig} 전체(CORS, JWT-vs-redis-session 분기,
* entry point 등)는 가져오지 않는다.
*/
@EnableWebSecurity
static class SecurityTestConfig {
@Bean
SecurityFilterChain csrfDisabledFilterChain(HttpSecurity http) throws Exception {
http.csrf(csrf -> csrf.disable())
.authorizeHttpRequests(auth -> auth.anyRequest().authenticated());
return http.build();
}
@Bean
SecuritySettings securitySettings() {
SecuritySettings.SessionCookieSettings session =
new SecuritySettings.SessionCookieSettings(
null, null, null, null, null, null, "X-CSRF-TOKEN");
return new SecuritySettings(
SecuritySettings.AuthenticationMode.JWT,
"https://issuer.example",
null,
List.of(),
session);
}
}
@SpringBootConfiguration
@EnableAutoConfiguration
static class TestBootstrap {}
}
@@ -0,0 +1,141 @@
package dev.caskeleton.adapter.inbound.web.techlog.studio.controller;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import dev.caskeleton.adapter.inbound.web.auth.AuthenticatedPrincipal;
import dev.caskeleton.adapter.inbound.web.envelope.EnvelopeBodyAdvice;
import dev.caskeleton.adapter.inbound.web.observability.MdcKeys;
import dev.caskeleton.adapter.inbound.web.settings.SecuritySettings;
import java.util.List;
import java.util.Set;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.slf4j.MDC;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.SpringBootConfiguration;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Import;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.test.web.servlet.MockMvc;
/**
* 응답이 봉투로 정확히 한 번 감싸이는지 고정한다. 두 번 감싸이면 프론트가 조용히 깨진다. CSRF가 **켜진** 필터체인(Spring 기본값)에서의 해피 패스만 다룬다 —
* CSRF가 **꺼진**(프로덕션 JWT 모드와 같은 모양) 경로는 {@link StudioSessionCsrfDisabledTest}가 별도로 고정한다. 두 필터체인을 한
* 테스트 클래스에 같이 둘 수 없다({@code @WebMvcTest}는 클래스당 Spring 컨텍스트 하나뿐이라 {@code SecurityFilterChain} 빈도
* 하나뿐이다).
*
* <p>이 모듈(adapter:inbound:web)의 테스트 소스셋에는 {@code CaSkeletonApplication}이 없다 — 그건 app-bootstrap 모듈
* 소유다. 그래서 {@code @WebMvcTest}가 컨텍스트를 부트스트랩할 {@code @SpringBootConfiguration}을 못 찾는다. {@link
* dev.caskeleton.adapter.inbound.web.envelope.EnvelopeBodyAdviceTest}와 {@link
* dev.caskeleton.adapter.inbound.web.error.NoResourceFoundErrorHandlingTest}가 쓰는 것과 같은 방식으로 — 테스트
* 전용 nested {@code TestBootstrap}을 두고 필요한 빈을 명시적으로 {@code @Import}한다.
*
* <p>보안: 이 슬라이스는 실제 {@code SecurityConfig}를 끌어오지 않는다(그건 {@code CorsSettings}, {@code
* JwtToAuthenticatedPrincipalConverter} 등 앱 전체 배선이 필요해서 슬라이스 테스트에 과하다). 이 저장소에 이 조합 (WebMvcTest +
* 실제 보안 필터 체인 + AuthenticatedPrincipal)의 선례가 없다 — {@code SecurityModeWebContractTest}는 {@code
* WebApplicationContextRunner}+standalone MockMvc를 쓰지 slice가 아니고, {@code
* EnvelopeBodyAdviceTest}/{@code NoResourceFoundErrorHandlingTest}는 반대로 Security를 통째로 배제한다 ({@code
* excludeAutoConfiguration = SecurityAutoConfiguration.class} + {@code addFilters = false}). 그래서
* 브리프가 제시한 fallback을 따른다 — {@code @WithMockUser} 대신 {@code
* SecurityMockMvcRequestPostProcessors.csrf()}와 커스텀 {@code authentication(...)}을 쓴다.
* {@code @WithMockUser}는 principal을 {@code org.springframework.security.core.userdetails.User}로
* 채우는데 컨트롤러가 기대하는 타입은 {@code AuthenticatedPrincipal}이라 타입 불일치로 {@code @AuthenticationPrincipal}이
* null을 주입하고 컨트롤러가 NPE를 던진다 — 실측으로 확인했다(task-8 report 참조).
*
* <p>{@code @WebMvcTest}는 표준 {@code @EnableAutoConfiguration}을
* {@code @OverrideAutoConfiguration(enabled = false)}로 끄고 test-slice 전용의 제한된 auto-configuration 목록만
* 적용한다 — 그 목록은 Boot의 {@code ServletWebSecurityAutoConfiguration}(기본 {@code SecurityFilterChain} +
* {@code @EnableWebSecurity})을 포함하지 않는다. 그래서 {@code TestBootstrap}이
* {@code @EnableAutoConfiguration}을 달고 있어도 {@code CsrfToken}/{@code @AuthenticationPrincipal} 인자
* 리졸버가 등록되지 않는다 — 실제로 시도했더니 {@code CsrfToken}이 인자 리졸버 없이 {@code @ModelAttribute} 데이터바인딩 경로로 떨어져 "No
* primary or single unique constructor found for interface CsrfToken" {@code
* IllegalStateException}으로 500이 났다. 그래서 {@code @EnableWebSecurity}를 이 테스트가 직접 명시적으로 붙인다({@code
* SecurityTestConfig}) — 그래야 그 인자 리졸버들이 등록된다.
*
* <p>{@code meta.traceId}는 프로덕션에서 {@code RequestLoggingFilter}가 MDC에 채운다. 그 필터는 {@code
* UserPrincipalPseudonymizerPort} 빈이 필요하고 자체 테스트({@code RequestLoggingFilterTest})가 이미 있으므로 여기서는
* 재현하지 않는다 — MockMvc가 테스트 스레드에서 동기 실행되는 점을 이용해 MDC를 직접 채운다.
*/
@WebMvcTest(controllers = StudioSessionController.class)
@Import({
StudioSessionController.class,
EnvelopeBodyAdvice.class,
StudioSessionEnvelopeTest.SecurityTestConfig.class
})
class StudioSessionEnvelopeTest {
@Autowired private MockMvc mvc;
@AfterEach
void clearMdc() {
MDC.remove(MdcKeys.TRACE_ID);
}
@Test
void wrapsTheSessionPayloadExactlyOnce() throws Exception {
MDC.put(MdcKeys.TRACE_ID, "test-trace-id");
mvc.perform(
get("/api/v1/studio/session")
.with(csrf())
.with(
authentication(
UsernamePasswordAuthenticationToken.authenticated(
new AuthenticatedPrincipal(
"sub-1", "donghyeon@example.com", Set.of("STUDIO_EDITOR")),
null,
Set.of(new SimpleGrantedAuthority("ROLE_STUDIO_EDITOR"))))))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.csrfHeaderName").value("X-CSRF-TOKEN"))
.andExpect(jsonPath("$.data.data").doesNotExist())
.andExpect(jsonPath("$.meta.traceId").isNotEmpty());
}
/**
* {@code CsrfToken}/{@code @AuthenticationPrincipal} 인자 리졸버를 등록하는 최소 보안 구성. 실제 앱의 {@code
* SecurityConfig}(CORS, JWT/redis-session 분기, entry point 등)는 가져오지 않고 이 슬라이스가 필요로 하는 것 — 인증된 요청만
* 통과, CSRF는 Spring 기본값(켜짐) — 만 남긴다. {@link StudioSessionCsrfDisabledTest}의 {@code
* SecurityTestConfig}가 정확히 반대(CSRF 꺼짐)를 재현한다.
*/
@EnableWebSecurity
static class SecurityTestConfig {
@Bean
SecurityFilterChain testSecurityFilterChain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests(auth -> auth.anyRequest().authenticated());
return http.build();
}
/**
* 컨트롤러가 이제 {@code csrfHeaderName}을 이 설정에서 읽는다(하드코드하지 않음) — 계약값 {@code X-CSRF-TOKEN}과 일치해야 생성자가
* 통과한다.
*/
@Bean
SecuritySettings securitySettings() {
SecuritySettings.SessionCookieSettings session =
new SecuritySettings.SessionCookieSettings(
null, null, null, null, null, null, "X-CSRF-TOKEN");
return new SecuritySettings(
SecuritySettings.AuthenticationMode.JWT,
"https://issuer.example",
null,
List.of(),
session);
}
}
@SpringBootConfiguration
@EnableAutoConfiguration
static class TestBootstrap {}
}
@@ -107,6 +107,15 @@ def postgresqlFileserverMetadataIntegrationTest = registerPostgreSqlReadinessTes
def postgresqlFileserverReclamationIntegrationTest = registerPostgreSqlReadinessTest(
'postgresqlFileserverReclamationIntegrationTest',
'dev.caskeleton.adapter.outbound.persistence.readiness.PostgreSqlFileserverReclamationIntegrationTest')
def postgresqlTechLogSchemaMigrationTest = registerPostgreSqlReadinessTest(
'postgresqlTechLogSchemaMigrationTest',
'dev.caskeleton.adapter.outbound.persistence.techlog.TechLogSchemaMigrationTest')
// Task 9 (listStudioCatalog): JdbcCatalogQueryAdapterTest has no @SpringBootConfiguration to hang a
// @SpringBootTest off in this module (same reason as postgresqlTechLogSchemaMigrationTest above), so it
// needs its own opt-in Testcontainers task rather than reusing an existing one.
def postgresqlTechLogCatalogQueryIntegrationTest = registerPostgreSqlReadinessTest(
'postgresqlTechLogCatalogQueryIntegrationTest',
'dev.caskeleton.adapter.outbound.persistence.techlog.query.JdbcCatalogQueryAdapterTest')
def verifyJpaSqlConstructionSafety = tasks.register('verifyJpaSqlConstructionSafety') {
group = 'verification'
@@ -0,0 +1,78 @@
package dev.caskeleton.adapter.outbound.persistence.techlog.query;
import dev.caskeleton.application.techlog.studio.port.out.CatalogQueryPort;
import dev.caskeleton.application.techlog.studio.query.CatalogEntryType;
import dev.caskeleton.application.techlog.studio.query.CatalogEntryView;
import dev.caskeleton.application.techlog.studio.query.CatalogPageView;
import java.util.List;
import java.util.Locale;
import java.util.UUID;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Repository;
/**
* catalog는 도메인 repository를 거치지 않고 전용 union query를 쓴다 (설계 08장 §4).
*
* <p>RELATION / EVIDENCE는 슬라이스 2·5에서 채운다. 그때까지 빈 페이지를 반환하며 이는 계약상 유효한 응답이다.
*/
@Repository
public class JdbcCatalogQueryAdapter implements CatalogQueryPort {
private final JdbcClient jdbcClient;
public JdbcCatalogQueryAdapter(JdbcClient jdbcClient) {
this.jdbcClient = jdbcClient;
}
@Override
public CatalogPageView search(CatalogEntryType type, String query, String cursor, int limit) {
String pattern =
(query == null || query.isBlank()) ? "%" : "%" + query.toLowerCase(Locale.ROOT) + "%";
List<CatalogEntryView> items =
switch (type) {
case TOPIC -> searchTopics(pattern, limit);
case PROJECT -> searchProjects(pattern, limit);
case RELATION, EVIDENCE -> List.of();
};
return new CatalogPageView(items, null);
}
private List<CatalogEntryView> searchTopics(String pattern, int limit) {
return jdbcClient
.sql(
"SELECT id, name, updated_at FROM topic "
+ "WHERE status = 'ACTIVE' AND lower(name) LIKE :pattern "
+ "ORDER BY name LIMIT :limit")
.param("pattern", pattern)
.param("limit", limit)
.query(
(rs, rowNum) ->
new CatalogEntryView(
UUID.fromString(rs.getString("id")),
CatalogEntryType.TOPIC,
rs.getString("name"),
null,
null,
"topic:" + rs.getTimestamp("updated_at").toInstant()))
.list();
}
private List<CatalogEntryView> searchProjects(String pattern, int limit) {
return jdbcClient
.sql(
"SELECT id, name, updated_at FROM project "
+ "WHERE lower(name) LIKE :pattern ORDER BY name LIMIT :limit")
.param("pattern", pattern)
.param("limit", limit)
.query(
(rs, rowNum) ->
new CatalogEntryView(
UUID.fromString(rs.getString("id")),
CatalogEntryType.PROJECT,
rs.getString("name"),
"PROJECT",
null,
"project:" + rs.getTimestamp("updated_at").toInstant()))
.list();
}
}
@@ -0,0 +1,779 @@
-- Tech Log 코어 스키마.
-- 원본: tech-log-design-package/database/V1__init.sql
-- (branch feature/response-envelope-adr-006, HEAD b20d7a2 — 이 파일은 그 이후 바뀌지 않았다)
--
-- 원본 DDL과의 차이:
-- 1. `CREATE SCHEMA IF NOT EXISTS tech_log;` / `SET search_path TO tech_log, public;` 제거.
-- 이 저장소의 기존 마이그레이션(V1/V3/V4/V5/V6)과 JPA 설정(@EntityScan, @EnableJpaRepositories,
-- PostgreSqlPersistenceConfig의 FlywayConfigurationCustomizer)은 모두 기본 public 스키마를
-- 전제한다. tech_log 전용 스키마로 옮기면 Task 9 이후의 JPA 엔티티가 이 테이블들을 찾지
-- 못한다. 그래서 테이블은 이 저장소의 다른 모든 테이블과 마찬가지로 public 스키마에 만든다.
-- 2. `studio_idempotency` 테이블과 전용 인덱스(`idx_studio_idempotency_expiry`)를 제외한다.
-- 기존 `idempotency_record`를 재사용한다 (spec D5).
-- 3. `release` / `site_config` / `profile_page` / `home_focus_config` /
-- `topic_featured_document` / `project_topic` 테이블과 전용 인덱스(`uq_topic_start_here`)를
-- 제외한다. 이번 범위 밖이다 (spec §2.2). site_config/profile_page/home_focus_config를
-- 시딩하던 마지막 INSERT 구문도 대상 테이블이 없으므로 함께 제외했다.
-- 4. 그 밖의 테이블·컬럼·CHECK·UNIQUE·인덱스·주석·순서는 원본을 그대로 보존한다. 순환 FK
-- `publication.latest_event_id` -> `publication_event.publication_id`의
-- `DEFERRABLE INITIALLY DEFERRED`도 그대로 유지한다 — 즉시 검사로 바꾸면 첫 게시가
-- 구조적으로 불가능해진다.
-- Tech Log initial PostgreSQL schema
-- Target: PostgreSQL 16+
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE EXTENSION IF NOT EXISTS pgcrypto;
-- ---------------------------------------------------------------------------
-- Taxonomy and assets
-- ---------------------------------------------------------------------------
CREATE TABLE topic (
id uuid PRIMARY KEY,
name varchar(80) NOT NULL,
normalized_name varchar(80) NOT NULL,
slug varchar(100) NOT NULL,
description varchar(600),
scope text,
status varchar(20) NOT NULL DEFAULT 'ACTIVE'
CHECK (status IN ('ACTIVE', 'ARCHIVED')),
version bigint NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
created_by varchar(255) NOT NULL,
updated_at timestamptz NOT NULL DEFAULT now(),
updated_by varchar(255) NOT NULL,
CONSTRAINT uq_topic_normalized_name UNIQUE (normalized_name),
CONSTRAINT uq_topic_slug UNIQUE (slug)
);
CREATE TABLE tag (
id uuid PRIMARY KEY,
name varchar(40) NOT NULL,
normalized_name varchar(40) NOT NULL,
slug varchar(60) NOT NULL,
version bigint NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
created_by varchar(255) NOT NULL,
updated_at timestamptz NOT NULL DEFAULT now(),
updated_by varchar(255) NOT NULL,
CONSTRAINT uq_tag_normalized_name UNIQUE (normalized_name),
CONSTRAINT uq_tag_slug UNIQUE (slug)
);
-- asset_key는 Public content가 참조하는 안정적인 key다.
-- object_key(스토리지 경로)와 분리되며 immutable이다.
--
-- asset_key -> Asset lookup -> current approved delivery path
--
-- 콘텐츠 원문에 object storage URL을 직접 영속하지 않는다.
-- 공개 이력이 있는 asset_key의 재사용 금지는 application rule로 강제한다.
-- (DB는 현재 행의 유일성만 보장한다.)
CREATE TABLE asset (
id uuid PRIMARY KEY,
asset_key varchar(200) NOT NULL,
asset_kind varchar(20) NOT NULL
CHECK (asset_kind IN ('IMAGE', 'DIAGRAM', 'ATTACHMENT')),
management_status varchar(20) NOT NULL
CHECK (management_status IN ('READY', 'ARCHIVED', 'REJECTED', 'QUARANTINED')),
object_key varchar(500) NOT NULL,
original_name varchar(255) NOT NULL,
display_name varchar(255),
content_type varchar(150) NOT NULL,
size_bytes bigint NOT NULL CHECK (size_bytes >= 0),
width integer CHECK (width IS NULL OR width > 0),
height integer CHECK (height IS NULL OR height > 0),
checksum_sha256 char(64) NOT NULL,
alt_text varchar(300),
decorative boolean NOT NULL DEFAULT false,
first_published_at timestamptz,
version bigint NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
created_by varchar(255) NOT NULL,
updated_at timestamptz NOT NULL DEFAULT now(),
updated_by varchar(255) NOT NULL,
CONSTRAINT uq_asset_key UNIQUE (asset_key),
CONSTRAINT uq_asset_object_key UNIQUE (object_key)
);
-- alt/decorative는 DB CHECK로 강제하지 않는다.
--
-- 업로드 시점에는 alt를 아직 정하지 않을 수 있어야 하고(Asset Picker에서 이후 수정),
-- 최종 판단은 syntax parser가 아니라 Publication Validation이 한다.
--
-- Asset decorative=false + 사용 위치 alt 비어 있음 -> PublishValidationFailed
-- Asset decorative=true -> alt="" 허용
--
-- 즉 판단 대상은 asset.alt_text 자체가 아니라 "해당 사용 위치의 alt"다.
-- 같은 Asset이 문서마다 다른 alt로 쓰일 수 있으므로 행 단위 CHECK로 표현할 수 없다.
-- ---------------------------------------------------------------------------
-- Knowledge documents
-- ---------------------------------------------------------------------------
CREATE TABLE document (
id uuid PRIMARY KEY,
document_type varchar(20) NOT NULL
CHECK (document_type IN ('CASE', 'REFERENCE')),
slug varchar(180),
title varchar(180) NOT NULL,
body_markdown text NOT NULL DEFAULT '',
content_format varchar(20) NOT NULL DEFAULT 'MARKDOWN'
CHECK (content_format IN ('MARKDOWN')),
content_format_version smallint NOT NULL DEFAULT 1
CHECK (content_format_version > 0),
workflow_status varchar(20) NOT NULL DEFAULT 'DRAFT'
CHECK (workflow_status IN ('DRAFT', 'IN_REVIEW', 'PUBLISHED', 'ARCHIVED')),
target_visibility varchar(20) NOT NULL DEFAULT 'PRIVATE'
CHECK (target_visibility IN ('PRIVATE', 'UNLISTED', 'PUBLIC')),
primary_topic_id uuid REFERENCES topic(id),
cover_asset_id uuid REFERENCES asset(id),
last_verified_at timestamptz,
first_published_at timestamptz,
last_published_at timestamptz,
version bigint NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
created_by varchar(255) NOT NULL,
updated_at timestamptz NOT NULL DEFAULT now(),
updated_by varchar(255) NOT NULL,
CONSTRAINT uq_document_id_type UNIQUE (id, document_type),
CONSTRAINT uq_document_type_slug UNIQUE (document_type, slug),
CONSTRAINT ck_document_slug_non_blank CHECK (slug IS NULL OR length(trim(slug)) > 0),
CONSTRAINT ck_document_publish_time_order CHECK (
first_published_at IS NULL
OR last_published_at IS NULL
OR first_published_at <= last_published_at
)
);
CREATE TABLE case_detail (
document_id uuid PRIMARY KEY,
document_type varchar(20) NOT NULL DEFAULT 'CASE'
CHECK (document_type = 'CASE'),
problem_summary varchar(600) NOT NULL DEFAULT '',
conclusion_summary varchar(600) NOT NULL DEFAULT '',
environment_items jsonb NOT NULL DEFAULT '[]'::jsonb
CHECK (jsonb_typeof(environment_items) = 'array'),
CONSTRAINT fk_case_detail_document
FOREIGN KEY (document_id, document_type)
REFERENCES document(id, document_type)
ON DELETE CASCADE
);
CREATE TABLE reference_detail (
document_id uuid PRIMARY KEY,
document_type varchar(20) NOT NULL DEFAULT 'REFERENCE'
CHECK (document_type = 'REFERENCE'),
scope_summary varchar(600) NOT NULL DEFAULT '',
applies_to jsonb NOT NULL DEFAULT '[]'::jsonb
CHECK (jsonb_typeof(applies_to) = 'array'),
excluded_scope jsonb NOT NULL DEFAULT '[]'::jsonb
CHECK (jsonb_typeof(excluded_scope) = 'array'),
freshness_status varchar(20) NOT NULL DEFAULT 'CURRENT'
CHECK (freshness_status IN ('CURRENT', 'REVIEW_DUE', 'HISTORICAL')),
CONSTRAINT fk_reference_detail_document
FOREIGN KEY (document_id, document_type)
REFERENCES document(id, document_type)
ON DELETE CASCADE
);
CREATE TABLE document_tag (
document_id uuid NOT NULL REFERENCES document(id) ON DELETE CASCADE,
tag_id uuid NOT NULL REFERENCES tag(id),
display_order integer NOT NULL CHECK (display_order >= 0),
PRIMARY KEY (document_id, tag_id),
CONSTRAINT uq_document_tag_order UNIQUE (document_id, display_order)
);
CREATE TABLE document_relation (
source_document_id uuid NOT NULL REFERENCES document(id) ON DELETE CASCADE,
target_document_id uuid NOT NULL REFERENCES document(id),
relation_type varchar(30) NOT NULL
CHECK (relation_type IN ('RELATED', 'DERIVED_FROM', 'SUPERSEDES')),
display_order integer NOT NULL DEFAULT 0 CHECK (display_order >= 0),
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (source_document_id, target_document_id, relation_type),
CONSTRAINT ck_document_relation_not_self CHECK (source_document_id <> target_document_id)
);
-- ---------------------------------------------------------------------------
-- Open questions
-- ---------------------------------------------------------------------------
CREATE TABLE open_question (
id uuid PRIMARY KEY,
slug varchar(180),
question varchar(300) NOT NULL,
summary varchar(600),
context_markdown text NOT NULL DEFAULT '',
importance_markdown text NOT NULL DEFAULT '',
next_verification text,
question_status varchar(20) NOT NULL DEFAULT 'OPEN'
CHECK (question_status IN ('OPEN', 'INVESTIGATING', 'PAUSED', 'RESOLVED', 'ARCHIVED')),
target_visibility varchar(20) NOT NULL DEFAULT 'PRIVATE'
CHECK (target_visibility IN ('PRIVATE', 'UNLISTED', 'PUBLIC')),
primary_topic_id uuid REFERENCES topic(id),
resolution_type varchar(30)
CHECK (resolution_type IS NULL OR resolution_type IN (
'DECISION_MADE',
'ASSUMPTION_REJECTED',
'QUESTION_REFRAMED',
'NO_LONGER_RELEVANT'
)),
resolution_summary text,
opened_at timestamptz NOT NULL DEFAULT now(),
resolved_at timestamptz,
first_published_at timestamptz,
last_published_at timestamptz,
version bigint NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
created_by varchar(255) NOT NULL,
updated_at timestamptz NOT NULL DEFAULT now(),
updated_by varchar(255) NOT NULL,
CONSTRAINT uq_open_question_slug UNIQUE (slug),
CONSTRAINT ck_question_resolution_consistency CHECK (
(question_status = 'RESOLVED'
AND resolution_type IS NOT NULL
AND resolution_summary IS NOT NULL
AND resolved_at IS NOT NULL)
OR
(question_status <> 'RESOLVED')
)
);
CREATE TABLE question_point (
id uuid PRIMARY KEY,
question_id uuid NOT NULL REFERENCES open_question(id) ON DELETE CASCADE,
point_kind varchar(20) NOT NULL
CHECK (point_kind IN ('FACT', 'ASSUMPTION', 'UNKNOWN', 'CONSTRAINT')),
content text NOT NULL CHECK (length(trim(content)) > 0),
display_order integer NOT NULL CHECK (display_order >= 0),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT uq_question_point_order UNIQUE (question_id, point_kind, display_order)
);
CREATE TABLE question_update (
id uuid PRIMARY KEY,
question_id uuid NOT NULL REFERENCES open_question(id) ON DELETE CASCADE,
update_type varchar(30) NOT NULL
CHECK (update_type IN (
'OBSERVATION',
'EVIDENCE',
'SCOPE_CHANGE',
'BLOCKER',
'NEXT_STEP',
'RESOLUTION',
'RESOLUTION_REOPENED'
)),
title varchar(180) NOT NULL,
body_markdown text NOT NULL DEFAULT '',
update_visibility varchar(20) NOT NULL DEFAULT 'PRIVATE'
CHECK (update_visibility IN ('PRIVATE', 'PUBLIC')),
sequence_no integer NOT NULL CHECK (sequence_no > 0),
occurred_at timestamptz NOT NULL,
version bigint NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
created_by varchar(255) NOT NULL,
updated_at timestamptz NOT NULL DEFAULT now(),
updated_by varchar(255) NOT NULL,
CONSTRAINT uq_question_update_sequence UNIQUE (question_id, sequence_no)
);
CREATE TABLE question_tag (
question_id uuid NOT NULL REFERENCES open_question(id) ON DELETE CASCADE,
tag_id uuid NOT NULL REFERENCES tag(id),
display_order integer NOT NULL CHECK (display_order >= 0),
PRIMARY KEY (question_id, tag_id),
CONSTRAINT uq_question_tag_order UNIQUE (question_id, display_order)
);
CREATE TABLE question_document_link (
question_id uuid NOT NULL REFERENCES open_question(id) ON DELETE CASCADE,
document_id uuid NOT NULL REFERENCES document(id),
relation_type varchar(30) NOT NULL
CHECK (relation_type IN ('RESULT_CASE', 'DERIVED_REFERENCE', 'RELATED')),
display_order integer NOT NULL DEFAULT 0 CHECK (display_order >= 0),
PRIMARY KEY (question_id, document_id, relation_type)
);
CREATE UNIQUE INDEX uq_question_result_case
ON question_document_link(question_id)
WHERE relation_type = 'RESULT_CASE';
-- ---------------------------------------------------------------------------
-- Projects, decisions, activities
-- ---------------------------------------------------------------------------
CREATE TABLE project (
id uuid PRIMARY KEY,
slug varchar(180),
name varchar(180) NOT NULL,
one_line_purpose varchar(600) NOT NULL DEFAULT '',
purpose_markdown text NOT NULL DEFAULT '',
boundary_markdown text NOT NULL DEFAULT '',
system_overview_markdown text NOT NULL DEFAULT '',
phase varchar(30) NOT NULL DEFAULT 'RESEARCH'
CHECK (phase IN (
'RESEARCH',
'DESIGN',
'IMPLEMENTATION',
'VERIFICATION',
'MAINTENANCE',
'PAUSED',
'COMPLETED'
)),
current_objective text,
next_step text,
technology_labels jsonb NOT NULL DEFAULT '[]'::jsonb
CHECK (jsonb_typeof(technology_labels) = 'array'),
workflow_status varchar(20) NOT NULL DEFAULT 'DRAFT'
CHECK (workflow_status IN ('DRAFT', 'PUBLISHED', 'ARCHIVED')),
target_visibility varchar(20) NOT NULL DEFAULT 'PRIVATE'
CHECK (target_visibility IN ('PRIVATE', 'UNLISTED', 'PUBLIC')),
featured_order integer,
first_published_at timestamptz,
last_published_at timestamptz,
version bigint NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
created_by varchar(255) NOT NULL,
updated_at timestamptz NOT NULL DEFAULT now(),
updated_by varchar(255) NOT NULL,
CONSTRAINT uq_project_slug UNIQUE (slug),
CONSTRAINT ck_project_featured_order CHECK (featured_order IS NULL OR featured_order >= 0)
);
CREATE TABLE project_document_link (
project_id uuid NOT NULL REFERENCES project(id) ON DELETE CASCADE,
document_id uuid NOT NULL REFERENCES document(id),
relation_type varchar(20) NOT NULL
CHECK (relation_type IN ('PRIMARY', 'RELATED')),
featured_order integer,
PRIMARY KEY (project_id, document_id),
CONSTRAINT ck_project_document_featured_order CHECK (featured_order IS NULL OR featured_order >= 0)
);
CREATE UNIQUE INDEX uq_document_primary_project
ON project_document_link(document_id)
WHERE relation_type = 'PRIMARY';
CREATE TABLE project_question_link (
project_id uuid NOT NULL REFERENCES project(id) ON DELETE CASCADE,
question_id uuid NOT NULL REFERENCES open_question(id),
relation_type varchar(20) NOT NULL
CHECK (relation_type IN ('PRIMARY', 'RELATED')),
featured_order integer,
PRIMARY KEY (project_id, question_id),
CONSTRAINT ck_project_question_featured_order CHECK (featured_order IS NULL OR featured_order >= 0)
);
CREATE UNIQUE INDEX uq_question_primary_project
ON project_question_link(question_id)
WHERE relation_type = 'PRIMARY';
CREATE TABLE project_decision (
id uuid PRIMARY KEY,
project_id uuid NOT NULL REFERENCES project(id) ON DELETE CASCADE,
statement varchar(1000) NOT NULL,
rationale_markdown text NOT NULL DEFAULT '',
consequences jsonb NOT NULL DEFAULT '[]'::jsonb
CHECK (jsonb_typeof(consequences) = 'array'),
alternatives_markdown text NOT NULL DEFAULT '',
decision_status varchar(20) NOT NULL DEFAULT 'PROPOSED'
CHECK (decision_status IN ('PROPOSED', 'ACCEPTED', 'SUPERSEDED', 'REJECTED')),
target_visibility varchar(20) NOT NULL DEFAULT 'PRIVATE'
CHECK (target_visibility IN ('PRIVATE', 'PUBLIC')),
source_question_id uuid REFERENCES open_question(id),
source_case_id uuid REFERENCES document(id),
superseded_by_id uuid,
is_featured boolean NOT NULL DEFAULT false,
decided_at timestamptz,
version bigint NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
created_by varchar(255) NOT NULL,
updated_at timestamptz NOT NULL DEFAULT now(),
updated_by varchar(255) NOT NULL,
CONSTRAINT fk_project_decision_superseded_by
FOREIGN KEY (superseded_by_id)
REFERENCES project_decision(id),
CONSTRAINT ck_project_decision_not_self_supersede
CHECK (superseded_by_id IS NULL OR superseded_by_id <> id),
CONSTRAINT ck_project_decision_status_fields CHECK (
decision_status NOT IN ('ACCEPTED', 'SUPERSEDED')
OR decided_at IS NOT NULL
),
CONSTRAINT ck_project_decision_supersede_target CHECK (
decision_status <> 'SUPERSEDED'
OR superseded_by_id IS NOT NULL
)
);
CREATE UNIQUE INDEX uq_project_featured_decision
ON project_decision(project_id)
WHERE is_featured = true;
CREATE TABLE project_activity (
id uuid PRIMARY KEY,
project_id uuid NOT NULL REFERENCES project(id) ON DELETE CASCADE,
activity_type varchar(40) NOT NULL
CHECK (activity_type IN (
'PHASE_CHANGED',
'QUESTION_OPENED',
'QUESTION_RESOLVED',
'DECISION_ACCEPTED',
'CASE_PUBLISHED',
'REFERENCE_PUBLISHED',
'MILESTONE_REACHED',
'PROJECT_PAUSED',
'PROJECT_RESUMED'
)),
title varchar(180) NOT NULL,
summary varchar(600),
visibility varchar(20) NOT NULL DEFAULT 'PRIVATE'
CHECK (visibility IN ('PRIVATE', 'PUBLIC')),
origin varchar(20) NOT NULL
CHECK (origin IN ('AUTO', 'MANUAL')),
related_resource_type varchar(30),
related_resource_id uuid,
occurred_at timestamptz NOT NULL,
operation_key varchar(180),
version bigint NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
created_by varchar(255) NOT NULL,
updated_at timestamptz NOT NULL DEFAULT now(),
updated_by varchar(255) NOT NULL,
CONSTRAINT uq_project_activity_operation_key UNIQUE (project_id, operation_key)
);
-- ---------------------------------------------------------------------------
-- Publication and public read model
-- ---------------------------------------------------------------------------
CREATE TABLE public_resource_projection (
resource_type varchar(30) NOT NULL
CHECK (resource_type IN (
'CASE',
'REFERENCE',
'QUESTION',
'PROJECT',
'PROJECT_DECISION',
'PROJECT_ACTIVITY',
'RELEASE',
'PROFILE'
)),
resource_id uuid NOT NULL,
source_version bigint NOT NULL CHECK (source_version >= 0),
publication_state varchar(20) NOT NULL
CHECK (publication_state IN ('ACTIVE', 'WITHDRAWN')),
visibility varchar(20) NOT NULL
CHECK (visibility IN ('PUBLIC', 'UNLISTED')),
title varchar(300) NOT NULL,
summary varchar(600),
state_code varchar(30),
primary_topic_id uuid REFERENCES topic(id),
payload_schema_version smallint NOT NULL CHECK (payload_schema_version > 0),
payload jsonb NOT NULL CHECK (jsonb_typeof(payload) = 'object'),
body_plain_text text NOT NULL DEFAULT '',
search_text text NOT NULL DEFAULT '',
content_hash char(64) NOT NULL,
published_at timestamptz NOT NULL,
updated_at timestamptz NOT NULL,
last_verified_at timestamptz,
latest_index_at timestamptz,
navigation_path varchar(500) NOT NULL,
PRIMARY KEY (resource_type, resource_id)
);
CREATE TABLE public_route (
resource_type varchar(30) NOT NULL,
slug varchar(180) NOT NULL,
resource_id uuid NOT NULL,
route_role varchar(20) NOT NULL
CHECK (route_role IN ('CANONICAL', 'ALIAS')),
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (resource_type, slug),
CONSTRAINT fk_public_route_projection
FOREIGN KEY (resource_type, resource_id)
REFERENCES public_resource_projection(resource_type, resource_id)
ON DELETE CASCADE
);
CREATE UNIQUE INDEX uq_public_route_canonical
ON public_route(resource_type, resource_id)
WHERE route_role = 'CANONICAL';
CREATE TABLE public_resource_tag (
resource_type varchar(30) NOT NULL,
resource_id uuid NOT NULL,
tag_id uuid NOT NULL REFERENCES tag(id),
display_order integer NOT NULL DEFAULT 0 CHECK (display_order >= 0),
PRIMARY KEY (resource_type, resource_id, tag_id),
CONSTRAINT fk_public_resource_tag_projection
FOREIGN KEY (resource_type, resource_id)
REFERENCES public_resource_projection(resource_type, resource_id)
ON DELETE CASCADE,
CONSTRAINT uq_public_resource_tag_order
UNIQUE (resource_type, resource_id, display_order)
);
CREATE TABLE public_resource_project_link (
resource_type varchar(30) NOT NULL,
resource_id uuid NOT NULL,
project_id uuid NOT NULL REFERENCES project(id),
relation_type varchar(20) NOT NULL
CHECK (relation_type IN ('PRIMARY', 'RELATED')),
featured_order integer,
PRIMARY KEY (resource_type, resource_id, project_id),
CONSTRAINT fk_public_resource_project_projection
FOREIGN KEY (resource_type, resource_id)
REFERENCES public_resource_projection(resource_type, resource_id)
ON DELETE CASCADE,
CONSTRAINT ck_public_project_featured_order CHECK (
featured_order IS NULL OR featured_order >= 0
)
);
CREATE UNIQUE INDEX uq_public_primary_project
ON public_resource_project_link(resource_type, resource_id)
WHERE relation_type = 'PRIMARY';
CREATE TABLE asset_reference (
asset_id uuid NOT NULL REFERENCES asset(id),
owner_type varchar(30) NOT NULL
CHECK (owner_type IN (
'DOCUMENT',
'QUESTION',
'QUESTION_UPDATE',
'PROJECT',
'DECISION',
'RELEASE',
'PROFILE',
'SITE'
)),
owner_id uuid NOT NULL,
reference_scope varchar(20) NOT NULL
CHECK (reference_scope IN ('WORKING', 'PUBLISHED')),
reference_role varchar(20) NOT NULL
CHECK (reference_role IN ('BODY', 'COVER', 'AVATAR', 'ATTACHMENT')),
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (asset_id, owner_type, owner_id, reference_scope, reference_role)
);
-- preview_token 테이블은 제거되었다.
-- Capability Token 기반 익명 Preview는 인증된 Preview Artifact(studio_preview)로
-- 대체되었다. contracts/openapi/preview-v1.deprecated.md 참고.
-- ---------------------------------------------------------------------------
-- Studio workflow artifacts
--
-- WorkingCopy는 API projection이므로 범용 working_copy 테이블을 만들지 않는다.
-- 아래 테이블은 편집 대상 자체가 아니라 "편집 흐름이 만들어내는 산출물"을 저장한다.
--
-- source_kind + source_id는 Studio API의 documentId를 가리킨다.
-- documentId는 source aggregate id를 그대로 사용하므로 별도 surrogate id가 없다.
-- ---------------------------------------------------------------------------
-- Validation은 일급 artifact다. 실행하고 버리는 결과가 아니라 특정 version을
-- 검증한 사실을 validation_id로 참조할 수 있어야 한다.
CREATE TABLE studio_validation (
validation_id uuid PRIMARY KEY,
source_kind varchar(30) NOT NULL
CHECK (source_kind IN ('CASE', 'REFERENCE', 'QUESTION', 'PROJECT_DECISION')),
source_id uuid NOT NULL,
validated_version bigint NOT NULL CHECK (validated_version >= 0),
status varchar(20) NOT NULL
CHECK (status IN ('INVALID', 'WARNINGS', 'VALID')),
issues jsonb NOT NULL DEFAULT '[]'::jsonb
CHECK (jsonb_typeof(issues) = 'array'),
-- 검증에 사용한 외부 의존 상태(Topic/Project publishability, relation target,
-- Asset READY/QUARANTINED, slug/route ownership, catalog revision,
-- renderer/content-format version)를 정규화한 hash.
-- Publish 시 다시 계산해 값이 다르면 VALIDATION_STALE로 거절한다.
dependency_revision varchar(200) NOT NULL,
validated_at timestamptz NOT NULL DEFAULT now(),
valid_until timestamptz NOT NULL,
created_by varchar(255) NOT NULL,
CONSTRAINT ck_studio_validation_window CHECK (valid_until > validated_at)
);
-- Preview는 저장된 version + validation + dependency revision을 묶어 만든
-- PublicRenderModel snapshot이다. 인증된 Studio API로만 조회한다.
-- CURRENT/STALE/EXPIRED 상태는 저장하지 않고 조회 시점에 서버가 계산한다.
CREATE TABLE studio_preview (
preview_id uuid PRIMARY KEY,
source_kind varchar(30) NOT NULL
CHECK (source_kind IN ('CASE', 'REFERENCE', 'QUESTION', 'PROJECT_DECISION')),
source_id uuid NOT NULL,
source_version bigint NOT NULL CHECK (source_version >= 0),
validation_id uuid NOT NULL REFERENCES studio_validation(validation_id),
dependency_revision varchar(200) NOT NULL,
render_model jsonb NOT NULL CHECK (jsonb_typeof(render_model) = 'object'),
created_at timestamptz NOT NULL DEFAULT now(),
expires_at timestamptz NOT NULL,
created_by varchar(255) NOT NULL,
CONSTRAINT ck_studio_preview_expiry CHECK (expires_at > created_at)
);
-- ---------------------------------------------------------------------------
-- Publication aggregate, immutable history, immutable snapshot
--
-- 세 개념을 분리한다.
--
-- publication 현재 게시 상태
-- publication_event 게시/재게시/게시 취소 불변 이력
-- publication_snapshot PUBLISHED/REPUBLISHED 시점의 불변 PublicRenderModel
--
-- public_resource_projection은 여전히 "현재 공개 상태"를 담당한다.
-- 과거 Snapshot을 현재 source나 현재 projection에서 재계산하지 않는다.
-- ---------------------------------------------------------------------------
CREATE TABLE publication (
publication_id uuid PRIMARY KEY,
source_kind varchar(30) NOT NULL
CHECK (source_kind IN ('CASE', 'REFERENCE', 'QUESTION', 'PROJECT_DECISION')),
source_id uuid NOT NULL,
status varchar(20) NOT NULL
CHECK (status IN ('PUBLISHED', 'UNPUBLISHED')),
published_version bigint NOT NULL CHECK (published_version >= 0),
-- Publication 자체의 optimistic concurrency 토큰.
-- unpublish는 expectedPublicationRevision으로 이 값을 검증한다.
publication_revision bigint NOT NULL DEFAULT 1 CHECK (publication_revision >= 1),
latest_event_id uuid NOT NULL,
public_path varchar(500) NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT uq_publication_source UNIQUE (source_kind, source_id)
);
CREATE TABLE publication_event (
publication_event_id uuid PRIMARY KEY,
publication_id uuid NOT NULL REFERENCES publication(publication_id),
source_kind varchar(30) NOT NULL
CHECK (source_kind IN ('CASE', 'REFERENCE', 'QUESTION', 'PROJECT_DECISION')),
source_id uuid NOT NULL,
event_type varchar(20) NOT NULL
CHECK (event_type IN ('PUBLISHED', 'REPUBLISHED', 'UNPUBLISHED')),
published_version bigint NOT NULL CHECK (published_version >= 0),
-- UNPUBLISHED Event는 자체 snapshot을 만들지 않고 마지막 공개 Snapshot을 참조한다.
source_published_event_id uuid REFERENCES publication_event(publication_event_id),
occurred_at timestamptz NOT NULL DEFAULT now(),
-- Publish 재시도가 중복 Event를 만들지 않도록 최초 요청의 idempotency key를 남긴다.
idempotency_key varchar(200),
created_by varchar(255) NOT NULL,
CONSTRAINT ck_publication_event_source_ref CHECK (
(event_type = 'UNPUBLISHED' AND source_published_event_id IS NOT NULL)
OR (event_type <> 'UNPUBLISHED' AND source_published_event_id IS NULL)
)
);
-- Event row는 생성 후 수정하지 않는다. UPDATE/DELETE 차단은 권한과 application
-- rule로 강제하며, 필요하면 운영에서 REVOKE UPDATE, DELETE로 보강한다.
-- 첫 게시는 publication(latest_event_id) -> publication_event -> publication UPDATE
-- 순서로 한 transaction 안에서 처리된다. 순환 참조를 허용하기 위해 지연 검사한다.
ALTER TABLE publication
ADD CONSTRAINT fk_publication_latest_event
FOREIGN KEY (latest_event_id)
REFERENCES publication_event(publication_event_id)
DEFERRABLE INITIALLY DEFERRED;
CREATE TABLE publication_snapshot (
publication_event_id uuid PRIMARY KEY
REFERENCES publication_event(publication_event_id),
render_model jsonb NOT NULL CHECK (jsonb_typeof(render_model) = 'object'),
content_format_version varchar(50) NOT NULL,
renderer_contract_version varchar(50) NOT NULL,
-- 게시 시점에 사용된 Asset의 assetKey/delivery path/치수를 고정한다.
-- 이후 Asset이 교체되어도 과거 Snapshot의 표현은 변하지 않는다.
asset_manifest jsonb NOT NULL DEFAULT '[]'::jsonb
CHECK (jsonb_typeof(asset_manifest) = 'array'),
created_at timestamptz NOT NULL DEFAULT now()
);
-- ---------------------------------------------------------------------------
-- Indexes
-- ---------------------------------------------------------------------------
CREATE INDEX idx_document_management
ON document(document_type, workflow_status, updated_at DESC);
CREATE INDEX idx_document_topic
ON document(primary_topic_id, document_type, updated_at DESC);
CREATE INDEX idx_question_status
ON open_question(question_status, updated_at DESC);
CREATE INDEX idx_question_topic
ON open_question(primary_topic_id, question_status, updated_at DESC);
CREATE INDEX idx_question_update_timeline
ON question_update(question_id, occurred_at ASC, sequence_no ASC);
CREATE INDEX idx_project_phase
ON project(phase, updated_at DESC);
CREATE INDEX idx_project_decision
ON project_decision(project_id, decision_status, decided_at DESC);
CREATE INDEX idx_project_activity
ON project_activity(project_id, visibility, occurred_at DESC);
CREATE INDEX idx_asset_status
ON asset(management_status, created_at DESC);
CREATE INDEX idx_asset_checksum
ON asset(checksum_sha256);
CREATE INDEX idx_asset_reference_owner
ON asset_reference(owner_type, owner_id, reference_scope);
CREATE INDEX idx_asset_reference_asset
ON asset_reference(asset_id, reference_scope);
CREATE INDEX idx_public_latest
ON public_resource_projection(latest_index_at DESC, resource_type, resource_id)
WHERE publication_state = 'ACTIVE'
AND visibility = 'PUBLIC'
AND latest_index_at IS NOT NULL;
CREATE INDEX idx_public_topic
ON public_resource_projection(primary_topic_id, resource_type, published_at DESC)
WHERE publication_state = 'ACTIVE'
AND visibility = 'PUBLIC';
CREATE INDEX idx_public_type
ON public_resource_projection(resource_type, visibility, published_at DESC)
WHERE publication_state = 'ACTIVE';
CREATE INDEX idx_public_projection_search_trgm
ON public_resource_projection
USING gin (search_text gin_trgm_ops)
WHERE publication_state = 'ACTIVE'
AND visibility = 'PUBLIC';
CREATE INDEX idx_public_project_link_lookup
ON public_resource_project_link(project_id, relation_type, resource_type);
-- Studio workflow artifacts -------------------------------------------------
-- 특정 version에 대한 최신 Validation 조회 (nextAction 계산의 핵심 경로)
CREATE INDEX idx_studio_validation_source
ON studio_validation(source_kind, source_id, validated_version, validated_at DESC);
-- 특정 version에 대한 최신 Preview 조회
CREATE INDEX idx_studio_preview_source
ON studio_preview(source_kind, source_id, source_version, created_at DESC);
-- 만료 Preview 정리 배치
CREATE INDEX idx_studio_preview_expiry
ON studio_preview(expires_at);
-- Publication history -------------------------------------------------------
-- 한 문서의 게시 이력 (occurredAt DESC, publicationEventId DESC 정렬 계약과 일치)
CREATE INDEX idx_publication_event_publication
ON publication_event(publication_id, occurred_at DESC, publication_event_id DESC);
-- 전체 게시 기록 화면과 source 기준 조회
CREATE INDEX idx_publication_event_source
ON publication_event(source_kind, source_id, occurred_at DESC);
@@ -44,7 +44,7 @@ class PostgreSqlMigrationIntegrationTest {
.migrate();
assertThat(appliedVersions(postgres, "flyway_schema_history"))
.containsExactly("1", "3", "4", "5", "6");
.containsExactly("1", "3", "4", "5", "6", "7");
Flyway coreStream =
Flyway.configure()
@@ -0,0 +1,157 @@
package dev.caskeleton.adapter.outbound.persistence.techlog;
import static org.assertj.core.api.Assertions.assertThat;
import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
import java.sql.Connection;
import java.sql.ResultSet;
import java.util.ArrayList;
import java.util.List;
import javax.sql.DataSource;
import org.flywaydb.core.Flyway;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.testcontainers.DockerClientFactory;
import org.testcontainers.postgresql.PostgreSQLContainer;
/**
* V7이 실제 PostgreSQL에 적용되는지, 그리고 순환 FK가 deferrable로 선언됐는지 본다. H2로는 검증할 수 없다 — deferrable 제약이 벤더 의미이기
* 때문이다.
*
* <p>이 모듈(persistence-jpa)에는 {@code @SpringBootConfiguration}이 없다 — Boot 메인 클래스는 app-bootstrap 모듈에
* 있고, 이 모듈의 postgresqlIntegrationTest 소스셋 classpath에는 포함되지 않는다. 그래서 {@code @SpringBootTest}로 컨텍스트를
* 띄울 수 없고, 이 패키지의 형제인 {@code readiness.PostgreSqlMigrationIntegrationTest}와 같은 방식 — Testcontainers
* 위에서 순수 Flyway API를 직접 구동 — 을 쓴다. 컨테이너는 매번 완전히 빈 상태로 시작하므로, 기존 V1/V3/V4/V5/V6 다음에 V7이 얹히는 전체 체인이
* 클린 DB에 처음부터 적용되는 경로를 그대로 검증한다.
*/
class TechLogSchemaMigrationTest {
private static final String IMAGE =
System.getProperty("jpa.evidence.postgresql.image", "postgres:16-alpine");
private static PostgreSQLContainer postgres;
private static HikariDataSource dataSource;
@BeforeAll
static void migrateFreshDatabase() {
if (!DockerClientFactory.instance().isDockerAvailable()) {
throw new IllegalStateException(
"Docker is required for the Tech Log schema migration test; skipping is forbidden");
}
postgres = new PostgreSQLContainer(IMAGE).withReuse(false);
postgres.start();
HikariConfig config = new HikariConfig();
config.setJdbcUrl(postgres.getJdbcUrl());
config.setUsername(postgres.getUsername());
config.setPassword(postgres.getPassword());
config.setMaximumPoolSize(5);
config.setMinimumIdle(1);
dataSource = new HikariDataSource(config);
// classpath:db/migration/postgresql only — the same location
// PostgreSqlPersistenceConfig's FlywayConfigurationCustomizer pins the application to. Using
// the full "classpath:db/migration" tree here would also pick up the unrelated jpa/* streams
// (each starting their own V1) and collide.
Flyway.configure()
.dataSource(dataSource)
.locations("classpath:db/migration/postgresql")
.table("flyway_schema_history")
.baselineOnMigrate(false)
.outOfOrder(false)
.load()
.migrate();
}
@AfterAll
static void stopPostgreSql() {
if (dataSource != null) {
dataSource.close();
}
if (postgres != null) {
postgres.stop();
}
}
@Test
void createsEveryTechLogTable() throws Exception {
List<String> expected =
List.of(
"topic",
"tag",
"document",
"case_detail",
"reference_detail",
"document_tag",
"document_relation",
"open_question",
"question_point",
"question_update",
"question_tag",
"question_document_link",
"project",
"project_decision",
"project_document_link",
"project_question_link",
"project_activity",
"asset",
"asset_reference",
"studio_validation",
"studio_preview",
"publication",
"publication_event",
"publication_snapshot",
"public_resource_projection",
"public_route",
"public_resource_tag",
"public_resource_project_link");
List<String> actual = new ArrayList<>();
try (Connection connection = dataSource().getConnection();
ResultSet rs =
connection
.createStatement()
.executeQuery(
"SELECT table_name FROM information_schema.tables WHERE table_schema = 'public'")) {
while (rs.next()) {
actual.add(rs.getString(1));
}
}
assertThat(actual).containsAll(expected);
}
@Test
void doesNotCreateAStudioIdempotencyTable() throws Exception {
try (Connection connection = dataSource().getConnection();
ResultSet rs =
connection
.createStatement()
.executeQuery(
"SELECT count(*) FROM information_schema.tables "
+ "WHERE table_schema = 'public' AND table_name = 'studio_idempotency'")) {
rs.next();
assertThat(rs.getInt(1)).isZero();
}
}
@Test
void publicationLatestEventForeignKeyIsDeferrable() throws Exception {
try (Connection connection = dataSource().getConnection();
ResultSet rs =
connection
.createStatement()
.executeQuery(
"SELECT condeferrable, condeferred FROM pg_constraint "
+ "WHERE conname = 'fk_publication_latest_event'")) {
assertThat(rs.next()).as("fk_publication_latest_event 제약이 있어야 한다").isTrue();
assertThat(rs.getBoolean(1)).as("deferrable").isTrue();
assertThat(rs.getBoolean(2)).as("initially deferred").isTrue();
}
}
private static DataSource dataSource() {
return dataSource;
}
}
@@ -0,0 +1,121 @@
package dev.caskeleton.adapter.outbound.persistence.techlog.query;
import static org.assertj.core.api.Assertions.assertThat;
import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
import dev.caskeleton.application.techlog.studio.query.CatalogEntryType;
import dev.caskeleton.application.techlog.studio.query.CatalogPageView;
import org.flywaydb.core.Flyway;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.testcontainers.DockerClientFactory;
import org.testcontainers.postgresql.PostgreSQLContainer;
/**
* 이 모듈(persistence-jpa)에는 {@code @SpringBootConfiguration}이 없다 — Boot 메인 클래스는 app-bootstrap 모듈에 있고,
* 이 모듈의 postgresqlIntegrationTest 소스셋 classpath에는 포함되지 않는다. 그래서 브리프의 {@code @SpringBootTest}로는
* 컨텍스트를 띄울 수 없다({@code TechLogSchemaMigrationTest}가 같은 문제를 겪었다). 이 테스트도 같은 형제 패턴 — Testcontainers
* 위에서 순수 Flyway로 V7까지 적용한 뒤, {@code JdbcClient}와 어댑터를 직접 조립 — 을 쓴다. Spring 컨테이너가 없어도 {@code
* JdbcCatalogQueryAdapter}는 생성자 인자로 {@code JdbcClient} 하나만 받으므로 문제가 없다.
*/
class JdbcCatalogQueryAdapterTest {
private static final String IMAGE =
System.getProperty("jpa.evidence.postgresql.image", "postgres:16-alpine");
private static PostgreSQLContainer postgres;
private static HikariDataSource dataSource;
private static JdbcClient jdbcClient;
private static JdbcCatalogQueryAdapter adapter;
@BeforeAll
static void migrateFreshDatabase() {
if (!DockerClientFactory.instance().isDockerAvailable()) {
throw new IllegalStateException(
"Docker is required for the catalog query adapter test; skipping is forbidden");
}
postgres = new PostgreSQLContainer(IMAGE).withReuse(false);
postgres.start();
HikariConfig config = new HikariConfig();
config.setJdbcUrl(postgres.getJdbcUrl());
config.setUsername(postgres.getUsername());
config.setPassword(postgres.getPassword());
config.setMaximumPoolSize(5);
config.setMinimumIdle(1);
dataSource = new HikariDataSource(config);
Flyway.configure()
.dataSource(dataSource)
.locations("classpath:db/migration/postgresql")
.table("flyway_schema_history")
.baselineOnMigrate(false)
.outOfOrder(false)
.load()
.migrate();
jdbcClient = JdbcClient.create(dataSource);
adapter = new JdbcCatalogQueryAdapter(jdbcClient);
}
@AfterAll
static void stopPostgreSql() {
if (dataSource != null) {
dataSource.close();
}
if (postgres != null) {
postgres.stop();
}
}
@Test
void findsTopicsByPrefix() {
jdbcClient
.sql(
"INSERT INTO topic (id, name, normalized_name, slug, created_by, updated_by) "
+ "VALUES (gen_random_uuid(), 'Kafka', 'kafka', 'kafka', 'test', 'test')")
.update();
CatalogPageView page = adapter.search(CatalogEntryType.TOPIC, "kaf", null, 20);
assertThat(page.items()).hasSize(1);
assertThat(page.items().get(0).label()).isEqualTo("Kafka");
assertThat(page.items().get(0).dependencyRevision()).isNotBlank();
}
@Test
void returnsAnEmptyPageWhenNothingMatches() {
CatalogPageView page = adapter.search(CatalogEntryType.TOPIC, "zzzz-none", null, 20);
assertThat(page.items()).isEmpty();
assertThat(page.nextCursor()).isNull();
}
/**
* Review Important 1: {@code searchProjects} (JdbcCatalogQueryAdapter) had never run against a
* real database — {@code project} has a different column shape than {@code topic} (slug/name/
* workflow_status vs. name/normalized_name/slug/status), so a column typo or bad bind would only
* have surfaced in production. {@code project}'s NOT-NULL-without-default columns are {@code id},
* {@code name}, {@code created_by}, {@code updated_by} (V7__techlog_core.sql CREATE TABLE
* project) — everything else has a DEFAULT or is nullable, so the minimal INSERT below is valid.
*/
@Test
void findsProjectsByPrefix() {
jdbcClient
.sql(
"INSERT INTO project (id, name, created_by, updated_by) "
+ "VALUES (gen_random_uuid(), 'Payments Platform', 'test', 'test')")
.update();
CatalogPageView page = adapter.search(CatalogEntryType.PROJECT, "pay", null, 20);
assertThat(page.items()).hasSize(1);
assertThat(page.items().get(0).id()).isNotNull();
assertThat(page.items().get(0).label()).isEqualTo("Payments Platform");
assertThat(page.items().get(0).kind()).isEqualTo("PROJECT");
assertThat(page.items().get(0).dependencyRevision()).isNotBlank();
}
}
+43 -1
View File
@@ -15,6 +15,12 @@ sourceSets {
functionalTest {
java.srcDir 'src/functionalTest/java'
resources.srcDir 'src/functionalTest/resources'
// feature-techlog-studio-backend Task 10 — StudioContractDriftTest reuses
// RepositoryContractResources (test-sourceSet-owned, see
// dev.caskeleton.bootstrap.contract.support) for fail-closed repo-root resolution instead
// of a hand-rolled relative Path.of(..), matching the sibling contract tests' convention.
compileClasspath += sourceSets.test.output
runtimeClasspath += sourceSets.test.output
}
conditionalTransportTest {
java.srcDir 'src/conditionalTransportTest/java'
@@ -121,6 +127,33 @@ dependencies {
functionalTestImplementation 'org.junit.jupiter:junit-jupiter'
functionalTestImplementation 'org.assertj:assertj-core'
functionalTestRuntimeOnly 'org.junit.platform:junit-platform-launcher'
// feature-techlog-studio-backend Task 10 — StudioContractDriftTest boots a minimal Studio web
// slice (real StudioSessionController/StudioCatalogController, no persistence/messaging/cache)
// to diff springdoc's published /api/v1/studio/** surface against studio-v1.yaml. Only the two
// modules the slice actually needs — deliberately not the full app-bootstrap runtime graph, so
// no DataSource/Flyway/Redis auto-configuration is even on this classpath to exclude.
functionalTestImplementation project(':adapter:inbound:web')
functionalTestImplementation project(':application-core')
// test-only: @SpringBootTest/MockMvc/@AutoConfigureMockMvc — mirrors the root build.gradle
// subprojects{} pair every non-core module already gets on its ordinary `test` sourceSet.
functionalTestImplementation 'org.springframework.boot:spring-boot-starter-test'
functionalTestImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test'
// test-only: classic Jackson (2.x) ObjectMapper/JsonNode to read /v3/api-docs and convert the
// SnakeYaml-parsed contract into a comparable tree. adapter-inbound-web/application-core pull
// this in only as a project-dependency `implementation` (hidden from a consumer's
// compileClasspath by Gradle's api/implementation split), so it must be declared directly here
// — mirrors the existing `testImplementation 'org.springframework.boot:spring-boot-starter-json'`
// pattern below for app-bootstrap's own `test` sourceSet.
functionalTestImplementation 'com.fasterxml.jackson.core:jackson-databind'
// test-only: org.springframework.boot.security.autoconfigure.SecurityAutoConfiguration (excluded
// below) is part of spring-boot-security, only pulled in transitively by spring-boot-starter-security
// — same api/implementation-hiding reason as jackson-databind above. app-bootstrap declares this
// on `implementation` for its own main/test sourceSets, which functionalTest does not extend.
functionalTestImplementation 'org.springframework.boot:spring-boot-starter-security'
// test-only: parses config/openapi/studio-v1.yaml with the same library StudioErrorRegistryTest
// already uses for docs/registries/error-codes.yaml — avoids adding jackson-dataformat-yaml
// (present only on runtimeClasspath repo-wide, transitively via springdoc, not compileClasspath).
functionalTestImplementation 'org.yaml:snakeyaml'
// Explicit qualification-only composition. These projects remain absent from main
// api/implementation/compileOnly/runtimeOnly and therefore from both shipped runtime graphs.
conditionalTransportTestImplementation project(':adapter:inbound:graphql')
@@ -166,13 +199,22 @@ sampleOffQualification.configure {
tasks.register('functionalTest', Test) {
group = 'verification'
description = 'Runs isolated Gradle TestKit contracts for repository build behavior.'
description = 'Runs isolated Gradle TestKit contracts for repository build behavior, plus the ' +
'feature-techlog-studio-backend Studio contract drift gate.'
testClassesDirs = sourceSets.functionalTest.output.classesDirs
classpath = sourceSets.functionalTest.runtimeClasspath
useJUnitPlatform()
failOnNoDiscoveredTests = true
shouldRunAfter tasks.named('test')
jvmArgs '-Duser.timezone=UTC'
// feature-techlog-studio-backend Task 10 — StudioContractDriftTest boots a real (minimal)
// Spring Boot context that needs Logback, on the same classpath as gradleTestKit() (whose own
// SLF4J provider — org.gradle.internal.logging.slf4j.OutputEventListenerBackedLoggerContext —
// wins classpath scanning over the real one). Spring Boot's LogbackLoggingSystem then finds
// Logback's jar present but the bound ILoggerFactory is Gradle's fake context, and fails fast
// with IllegalStateException before the context even starts. LoggingSystem=none skips Boot's
// logging bootstrap entirely — this task doesn't assert on log output, so there is nothing lost.
systemProperty 'org.springframework.boot.logging.LoggingSystem', 'none'
}
def conditionalTransportCompositionQualification = registerStrictQualificationTest(
+108 -108
View File
@@ -2,26 +2,26 @@
# Manual edits can break the build and are not advised.
# This file is expected to be part of source control.
aopalliance:aopalliance:1.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=compileClasspath,sampleOffTestCompileClasspath,testCompileClasspath
ch.qos.logback:logback-classic:1.5.21=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
ch.qos.logback:logback-core:1.5.21=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=compileClasspath,functionalTestCompileClasspath,sampleOffTestCompileClasspath,testCompileClasspath
ch.qos.logback:logback-classic:1.5.21=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
ch.qos.logback:logback-core:1.5.21=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.approvaltests:approvaltests-util:31.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.approvaltests:approvaltests:31.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.ethlo.time:itu:1.14.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
com.fasterxml.jackson.core:jackson-annotations:2.20=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.fasterxml.jackson.core:jackson-core:2.20.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.fasterxml.jackson.core:jackson-databind:2.20.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.20.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.20.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
com.fasterxml.jackson:jackson-bom:2.20.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.fasterxml:classmate:1.7.1=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.fasterxml.jackson.core:jackson-annotations:2.20=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.fasterxml.jackson.core:jackson-core:2.20.1=compileClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.fasterxml.jackson.core:jackson-databind:2.20.1=compileClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.20.1=functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.20.1=functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
com.fasterxml.jackson:jackson-bom:2.20.1=compileClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.fasterxml:classmate:1.7.1=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,conditionalTransportTestAnnotationProcessor,functionalTestAnnotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor
com.github.docker-java:docker-java-api:3.7.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.github.docker-java:docker-java-transport-zerodep:3.7.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.github.docker-java:docker-java-transport:3.7.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,conditionalTransportTestAnnotationProcessor,functionalTestAnnotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor
com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs
com.github.spotbugs:spotbugs-annotations:4.8.6=compileClasspath,sampleOffTestCompileClasspath,testCompileClasspath
com.github.spotbugs:spotbugs-annotations:4.8.6=compileClasspath,functionalTestCompileClasspath,sampleOffTestCompileClasspath,testCompileClasspath
com.github.spotbugs:spotbugs:4.10.2=spotbugs
com.github.stephenc.jcip:jcip-annotations:1.0-1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath
com.google.android:annotations:4.1.1.4=conditionalTransportTestRuntimeClasspath
@@ -29,10 +29,10 @@ com.google.api.grpc:proto-google-common-protos:2.41.0=conditionalTransportTestRu
com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,conditionalTransportTestAnnotationProcessor,functionalTestAnnotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor
com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,conditionalTransportTestAnnotationProcessor,functionalTestAnnotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor
com.google.auto:auto-common:1.2.2=annotationProcessor,conditionalTransportTestAnnotationProcessor,functionalTestAnnotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor
com.google.code.findbugs:jsr305:3.0.2=checkstyle,compileClasspath,conditionalTransportTestRuntimeClasspath,sampleOffTestCompileClasspath,spotbugs,testCompileClasspath
com.google.code.findbugs:jsr305:3.0.2=checkstyle,compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,sampleOffTestCompileClasspath,spotbugs,testCompileClasspath
com.google.code.gson:gson:2.13.2=conditionalTransportTestRuntimeClasspath,spotbugs
com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,conditionalTransportTestAnnotationProcessor,functionalTestAnnotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor
com.google.errorprone:error_prone_annotations:2.38.0=compileClasspath,sampleOffTestCompileClasspath,testCompileClasspath
com.google.errorprone:error_prone_annotations:2.38.0=compileClasspath,functionalTestCompileClasspath,sampleOffTestCompileClasspath,testCompileClasspath
com.google.errorprone:error_prone_annotations:2.41.0=conditionalTransportTestRuntimeClasspath,spotbugs
com.google.errorprone:error_prone_annotations:2.47.0=checkstyle
com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,conditionalTransportTestAnnotationProcessor,functionalTestAnnotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor
@@ -54,11 +54,11 @@ com.graphql-java:graphql-java:25.0=conditionalTransportTestRuntimeClasspath
com.graphql-java:java-dataloader:6.0.0=conditionalTransportTestRuntimeClasspath
com.h2database:h2:2.4.240=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins
com.jayway.jsonpath:json-path:2.9.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.jayway.jsonpath:json-path:2.9.0=functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.networknt:json-schema-validator:3.0.2=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
com.nimbusds:content-type:2.3=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.nimbusds:lang-tag:1.7=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.nimbusds:nimbus-jose-jwt:10.4=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.nimbusds:nimbus-jose-jwt:10.4=compileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.nimbusds:oauth2-oidc-sdk:11.26.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.puppycrawl.tools:checkstyle:13.5.0=checkstyle
com.squareup.okhttp3:okhttp-jvm:5.2.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
@@ -71,14 +71,14 @@ com.tngtech.archunit:archunit-junit5-engine-api:1.3.0=sampleOffTestRuntimeClassp
com.tngtech.archunit:archunit-junit5-engine:1.3.0=sampleOffTestRuntimeClasspath,testRuntimeClasspath
com.tngtech.archunit:archunit-junit5:1.3.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.tngtech.archunit:archunit:1.3.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.vaadin.external.google:android-json:0.0.20131108.vaadin1=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.vaadin.external.google:android-json:0.0.20131108.vaadin1=functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.zaxxer:HikariCP:7.0.2=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
commons-beanutils:commons-beanutils:1.11.0=checkstyle
commons-codec:commons-codec:1.19.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
commons-collections:commons-collections:3.2.2=checkstyle
commons-io:commons-io:2.20.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
commons-io:commons-io:2.21.0=spotbugs
commons-logging:commons-logging:1.3.5=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
commons-logging:commons-logging:1.3.5=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
info.picocli:picocli:4.7.7=checkstyle
io.github.cdimascio:dotenv-java:3.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,conditionalTransportTestAnnotationProcessor,functionalTestAnnotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor
@@ -101,10 +101,10 @@ io.grpc:grpc-stub:1.68.1=conditionalTransportTestRuntimeClasspath
io.grpc:grpc-util:1.68.1=conditionalTransportTestRuntimeClasspath
io.lettuce:lettuce-core:6.8.1.RELEASE=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
io.micrometer:context-propagation:1.2.0=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
io.micrometer:micrometer-commons:1.16.0=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
io.micrometer:micrometer-commons:1.16.0=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
io.micrometer:micrometer-core:1.16.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
io.micrometer:micrometer-jakarta9:1.16.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
io.micrometer:micrometer-observation:1.16.0=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
io.micrometer:micrometer-observation:1.16.0=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
io.micrometer:micrometer-registry-prometheus:1.16.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
io.micrometer:micrometer-tracing-bridge-otel:1.6.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
io.micrometer:micrometer-tracing:1.6.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
@@ -147,39 +147,39 @@ io.opentelemetry:opentelemetry-sdk:1.55.0=compileClasspath,productionRuntimeClas
io.perfmark:perfmark-api:0.27.0=conditionalTransportTestRuntimeClasspath
io.projectreactor.netty:reactor-netty-core:1.3.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
io.projectreactor.netty:reactor-netty-http:1.3.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
io.projectreactor:reactor-core:3.8.0=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
io.projectreactor:reactor-core:3.8.0=conditionalTransportTestRuntimeClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
io.prometheus:prometheus-metrics-config:1.4.3=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
io.prometheus:prometheus-metrics-core:1.4.3=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
io.prometheus:prometheus-metrics-exposition-formats:1.4.3=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
io.prometheus:prometheus-metrics-exposition-textformats:1.4.3=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
io.prometheus:prometheus-metrics-model:1.4.3=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
io.prometheus:prometheus-metrics-tracer-common:1.4.3=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
io.swagger.core.v3:swagger-annotations-jakarta:2.2.38=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
io.swagger.core.v3:swagger-core-jakarta:2.2.38=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
io.swagger.core.v3:swagger-models-jakarta:2.2.38=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
jakarta.activation:jakarta.activation-api:2.1.4=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
jakarta.annotation:jakarta.annotation-api:3.0.0=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
io.swagger.core.v3:swagger-annotations-jakarta:2.2.38=functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
io.swagger.core.v3:swagger-core-jakarta:2.2.38=functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
io.swagger.core.v3:swagger-models-jakarta:2.2.38=functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
jakarta.activation:jakarta.activation-api:2.1.4=functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
jakarta.annotation:jakarta.annotation-api:3.0.0=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
jakarta.inject:jakarta.inject-api:2.0.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
jakarta.persistence:jakarta.persistence-api:3.2.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
jakarta.transaction:jakarta.transaction-api:2.0.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
jakarta.validation:jakarta.validation-api:3.1.1=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
jakarta.validation:jakarta.validation-api:3.1.1=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
jakarta.websocket:jakarta.websocket-api:2.2.0=sampleOffTestCompileClasspath,testCompileClasspath
jakarta.ws.rs:jakarta.ws.rs-api:4.0.0=sampleOffTestCompileClasspath,testCompileClasspath
jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
javax.inject:javax.inject:1=annotationProcessor,conditionalTransportTestAnnotationProcessor,functionalTestAnnotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor
jaxen:jaxen:2.0.0=spotbugs
me.paulschwarz:spring-dotenv:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
net.bytebuddy:byte-buddy-agent:1.17.8=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
net.bytebuddy:byte-buddy-agent:1.17.8=functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
net.bytebuddy:byte-buddy:1.17.8=conditionalTransportTestCompileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
net.java.dev.jna:jna:5.18.1=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
net.logstash.logback:logstash-logback-encoder:8.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
net.minidev:accessors-smart:2.6.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
net.minidev:json-smart:2.6.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
net.minidev:accessors-smart:2.6.0=compileClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
net.minidev:json-smart:2.6.0=compileClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs
org.antlr:antlr4-runtime:4.13.2=checkstyle,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.apache.bcel:bcel:6.12.0=spotbugs
org.apache.commons:commons-compress:1.28.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.apache.commons:commons-lang3:3.20.0=checkstyle,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath
org.apache.commons:commons-lang3:3.20.0=checkstyle,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath
org.apache.commons:commons-text:1.15.0=spotbugs
org.apache.commons:commons-text:1.3=checkstyle
org.apache.httpcomponents.client5:httpclient5:5.5.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
@@ -188,21 +188,21 @@ org.apache.httpcomponents.core5:httpcore5:5.3.6=productionRuntimeClasspath,runti
org.apache.httpcomponents:httpclient:4.5.13=checkstyle
org.apache.httpcomponents:httpcore:4.4.16=checkstyle
org.apache.kafka:kafka-clients:4.1.1=sampleOffTestCompileClasspath,testCompileClasspath
org.apache.logging.log4j:log4j-api:2.25.2=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath
org.apache.logging.log4j:log4j-api:2.25.2=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath
org.apache.logging.log4j:log4j-core:2.25.2=spotbugs
org.apache.logging.log4j:log4j-to-slf4j:2.25.2=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.apache.logging.log4j:log4j-to-slf4j:2.25.2=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.apache.maven.doxia:doxia-core:1.12.0=checkstyle
org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle
org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle
org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle
org.apache.tomcat.embed:tomcat-embed-core:11.0.14=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.apache.tomcat.embed:tomcat-embed-el:11.0.14=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.apache.tomcat.embed:tomcat-embed-core:11.0.14=conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.apache.tomcat.embed:tomcat-embed-el:11.0.14=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.apache.xbean:xbean-reflect:3.7=checkstyle
org.apiguardian:apiguardian-api:1.1.2=conditionalTransportTestCompileClasspath,functionalTestCompileClasspath,sampleOffTestCompileClasspath,testCompileClasspath
org.aspectj:aspectjweaver:1.9.25=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.assertj:assertj-core:3.27.6=conditionalTransportTestCompileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.awaitility:awaitility:4.3.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.awaitility:awaitility:4.3.0=functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.checkerframework:checker-qual:3.42.0=conditionalTransportTestRuntimeClasspath
org.checkerframework:checker-qual:3.49.5=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.codehaus.mojo:animal-sniffer-annotations:1.24=conditionalTransportTestRuntimeClasspath
@@ -224,17 +224,17 @@ org.flywaydb:flyway-database-postgresql:11.14.1=productionRuntimeClasspath,runti
org.glassfish.jaxb:jaxb-core:4.0.6=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.glassfish.jaxb:jaxb-runtime:4.0.6=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.glassfish.jaxb:txw2:4.0.6=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.hamcrest:hamcrest:3.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.hamcrest:hamcrest:3.0=functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.hdrhistogram:HdrHistogram:2.2.2=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.hibernate.models:hibernate-models:1.0.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.hibernate.orm:hibernate-core:7.1.8.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.hibernate.validator:hibernate-validator:9.0.1.Final=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.hibernate.validator:hibernate-validator:9.0.1.Final=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.javassist:javassist:3.28.0-GA=checkstyle
org.jboss.logging:jboss-logging:3.6.1.Final=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.jboss.logging:jboss-logging:3.6.1.Final=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.jetbrains.kotlin:kotlin-stdlib:2.2.21=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.jetbrains:annotations:13.0=productionRuntimeClasspath,runtimeClasspath
org.jetbrains:annotations:17.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,conditionalTransportTestAnnotationProcessor,conditionalTransportTestCompileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestAnnotationProcessor,functionalTestCompileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestAnnotationProcessor,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath
org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,conditionalTransportTestAnnotationProcessor,conditionalTransportTestCompileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestAnnotationProcessor,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestAnnotationProcessor,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter-api:6.0.1=conditionalTransportTestCompileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter-engine:6.0.1=conditionalTransportTestRuntimeClasspath,functionalTestRuntimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter-params:6.0.1=conditionalTransportTestCompileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
@@ -246,35 +246,35 @@ org.junit.platform:junit-platform-testkit:6.0.1=sampleOffTestCompileClasspath,sa
org.junit:junit-bom:6.0.1=conditionalTransportTestCompileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.junit:junit-bom:6.1.0=spotbugs
org.latencyutils:LatencyUtils:2.0.3=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.mockito:mockito-core:5.20.0=mockitoAgent,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.mockito:mockito-junit-jupiter:5.20.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.objenesis:objenesis:3.3=sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.openapitools:jackson-databind-nullable:0.2.6=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.mockito:mockito-core:5.20.0=functionalTestCompileClasspath,functionalTestRuntimeClasspath,mockitoAgent,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.mockito:mockito-junit-jupiter:5.20.0=functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.objenesis:objenesis:3.3=functionalTestRuntimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.openapitools:jackson-databind-nullable:0.2.6=functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.opentest4j:opentest4j:1.3.0=conditionalTransportTestCompileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.osgi:org.osgi.annotation.bundle:2.0.0=compileClasspath,sampleOffTestCompileClasspath,testCompileClasspath
org.osgi:org.osgi.annotation.versioning:1.1.2=compileClasspath,sampleOffTestCompileClasspath,testCompileClasspath
org.osgi:org.osgi.resource:1.0.0=compileClasspath,sampleOffTestCompileClasspath,testCompileClasspath
org.osgi:org.osgi.service.serviceloader:1.0.0=compileClasspath,sampleOffTestCompileClasspath,testCompileClasspath
org.osgi:org.osgi.annotation.bundle:2.0.0=compileClasspath,functionalTestCompileClasspath,sampleOffTestCompileClasspath,testCompileClasspath
org.osgi:org.osgi.annotation.versioning:1.1.2=compileClasspath,functionalTestCompileClasspath,sampleOffTestCompileClasspath,testCompileClasspath
org.osgi:org.osgi.resource:1.0.0=compileClasspath,functionalTestCompileClasspath,sampleOffTestCompileClasspath,testCompileClasspath
org.osgi:org.osgi.service.serviceloader:1.0.0=compileClasspath,functionalTestCompileClasspath,sampleOffTestCompileClasspath,testCompileClasspath
org.ow2.asm:asm-analysis:9.10.1=spotbugs
org.ow2.asm:asm-commons:9.10.1=spotbugs
org.ow2.asm:asm-tree:9.10.1=spotbugs
org.ow2.asm:asm-util:9.10.1=spotbugs
org.ow2.asm:asm:9.10.1=spotbugs
org.ow2.asm:asm:9.7.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.ow2.asm:asm:9.7.1=compileClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.pcollections:pcollections:4.0.1=annotationProcessor,conditionalTransportTestAnnotationProcessor,functionalTestAnnotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor
org.postgresql:postgresql:42.7.8=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.reactivestreams:reactive-streams:1.0.4=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.reactivestreams:reactive-streams:1.0.4=conditionalTransportTestRuntimeClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.reflections:reflections:0.10.2=checkstyle
org.rnorth.duct-tape:duct-tape:1.0.8=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.skyscreamer:jsonassert:1.5.3=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.slf4j:jul-to-slf4j:2.0.17=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.slf4j:slf4j-api:2.0.17=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath
org.skyscreamer:jsonassert:1.5.3=functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.slf4j:jul-to-slf4j:2.0.17=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.slf4j:slf4j-api:2.0.17=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath
org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j
org.springdoc:springdoc-openapi-starter-common:3.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.springdoc:springdoc-openapi-starter-webmvc-api:3.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.springdoc:springdoc-openapi-starter-common:3.0.0=functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.springdoc:springdoc-openapi-starter-webmvc-api:3.0.0=functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-actuator-autoconfigure:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-actuator:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-configuration-processor:4.0.0=annotationProcessor
org.springframework.boot:spring-boot-data-commons:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-data-jpa-test:4.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
@@ -283,9 +283,9 @@ org.springframework.boot:spring-boot-flyway:4.0.0=compileClasspath,productionRun
org.springframework.boot:spring-boot-graphql:4.0.0=conditionalTransportTestRuntimeClasspath
org.springframework.boot:spring-boot-health:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-hibernate:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-http-client:4.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-http-converter:4.0.0=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-jackson:4.0.0=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-http-client:4.0.0=functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-http-converter:4.0.0=conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-jackson:4.0.0=conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-jdbc-test:4.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-jdbc:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-jpa-test:4.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
@@ -293,73 +293,73 @@ org.springframework.boot:spring-boot-jpa:4.0.0=productionRuntimeClasspath,runtim
org.springframework.boot:spring-boot-micrometer-metrics:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-micrometer-observation:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-persistence:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-restclient:4.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-resttestclient:4.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-security-oauth2-resource-server:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-security:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-servlet:4.0.0=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-restclient:4.0.0=functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-resttestclient:4.0.0=functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-security-oauth2-resource-server:4.0.0=functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-security:4.0.0=compileClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-servlet:4.0.0=conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-sql:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-actuator:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-data-jpa:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-flyway:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-graphql:4.0.0=conditionalTransportTestRuntimeClasspath
org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-jackson:4.0.0=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-jackson:4.0.0=conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-jdbc:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-json:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-logging:4.0.0=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-logging:4.0.0=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-micrometer-metrics:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-oauth2-resource-server:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-security:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-test:4.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-tomcat:4.0.0=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-validation:4.0.0=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-web:4.0.0=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-webmvc-test:4.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-webmvc:4.0.0=conditionalTransportTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-oauth2-resource-server:4.0.0=functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-security:4.0.0=compileClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-test:4.0.0=functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-tomcat:4.0.0=conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-validation:4.0.0=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-web:4.0.0=conditionalTransportTestRuntimeClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-webmvc-test:4.0.0=functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-webmvc:4.0.0=conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-websocket:4.0.0=conditionalTransportTestRuntimeClasspath
org.springframework.boot:spring-boot-starter:4.0.0=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-test:4.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-tomcat:4.0.0=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter:4.0.0=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-test:4.0.0=functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-tomcat:4.0.0=conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-transaction:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-validation:4.0.0=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-web-server:4.0.0=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-webmvc-test:4.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-webmvc:4.0.0=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-validation:4.0.0=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-web-server:4.0.0=conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-webmvc-test:4.0.0=functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-webmvc:4.0.0=conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-websocket:4.0.0=conditionalTransportTestRuntimeClasspath
org.springframework.boot:spring-boot:4.0.0=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot:4.0.0=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.cloud:spring-cloud-context:4.1.4=sampleOffTestCompileClasspath,testCompileClasspath
org.springframework.data:spring-data-commons:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.data:spring-data-jpa:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.graphql:spring-graphql:2.0.0=conditionalTransportTestRuntimeClasspath
org.springframework.integration:spring-integration-core:7.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.integration:spring-integration-jdbc:7.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.security:spring-security-config:7.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.security:spring-security-core:7.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.security:spring-security-crypto:7.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.security:spring-security-config:7.0.0=compileClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.security:spring-security-core:7.0.0=compileClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.security:spring-security-crypto:7.0.0=compileClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.security:spring-security-oauth2-client:7.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.security:spring-security-oauth2-core:7.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.security:spring-security-oauth2-jose:7.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.springframework.security:spring-security-oauth2-resource-server:7.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.springframework.security:spring-security-oauth2-core:7.0.0=compileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.security:spring-security-oauth2-jose:7.0.0=functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.springframework.security:spring-security-oauth2-resource-server:7.0.0=functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.springframework.security:spring-security-test:7.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.security:spring-security-web:7.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.session:spring-session-core:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.springframework:spring-aop:7.0.1=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.security:spring-security-web:7.0.0=compileClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.session:spring-session-core:4.0.0=functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.springframework:spring-aop:7.0.1=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-aspects:7.0.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-beans:7.0.1=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-context:7.0.1=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-core:7.0.1=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-expression:7.0.1=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-beans:7.0.1=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-context:7.0.1=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-core:7.0.1=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-expression:7.0.1=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-jdbc:7.0.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-messaging:7.0.1=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-orm:7.0.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-test:7.0.1=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-test:7.0.1=functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-tx:7.0.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-web:7.0.1=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-webflux:7.0.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.springframework:spring-webmvc:7.0.1=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-web:7.0.1=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-webflux:7.0.1=functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.springframework:spring-webmvc:7.0.1=conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-websocket:7.0.1=conditionalTransportTestRuntimeClasspath,sampleOffTestCompileClasspath,testCompileClasspath
org.testcontainers:testcontainers-database-commons:2.0.2=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.testcontainers:testcontainers-jdbc:2.0.2=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
@@ -367,10 +367,10 @@ org.testcontainers:testcontainers-junit-jupiter:2.0.2=sampleOffTestCompileClassp
org.testcontainers:testcontainers-postgresql:2.0.2=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.testcontainers:testcontainers:2.0.2=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs
org.xmlunit:xmlunit-core:2.10.4=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.yaml:snakeyaml:2.5=compileClasspath,conditionalTransportTestCompileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.xmlunit:xmlunit-core:2.10.4=functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.yaml:snakeyaml:2.5=compileClasspath,conditionalTransportTestCompileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
redis.clients.authentication:redis-authx-core:0.1.1-beta2=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
tools.jackson.core:jackson-core:3.0.2=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
tools.jackson.core:jackson-databind:3.0.2=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
tools.jackson:jackson-bom:3.0.2=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
tools.jackson.core:jackson-core:3.0.2=conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
tools.jackson.core:jackson-databind:3.0.2=conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
tools.jackson:jackson-bom:3.0.2=conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
empty=developmentOnly,testAndDevelopmentOnly
@@ -0,0 +1,336 @@
package dev.caskeleton.bootstrap.contract;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import dev.caskeleton.adapter.inbound.web.envelope.EnvelopeBodyAdvice;
import dev.caskeleton.adapter.inbound.web.settings.SecuritySettings;
import dev.caskeleton.adapter.inbound.web.techlog.studio.controller.StudioCatalogController;
import dev.caskeleton.adapter.inbound.web.techlog.studio.controller.StudioSessionController;
import dev.caskeleton.application.techlog.studio.port.out.CatalogQueryPort;
import dev.caskeleton.application.techlog.studio.query.CatalogEntryType;
import dev.caskeleton.application.techlog.studio.query.CatalogPageView;
import dev.caskeleton.application.techlog.studio.service.ListCatalogUseCase;
import dev.caskeleton.application.transaction.TransactionPort;
import dev.caskeleton.bootstrap.contract.support.RepositoryContractResources;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.function.Supplier;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.SpringBootConfiguration;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.security.autoconfigure.SecurityAutoConfiguration;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Import;
import org.springframework.test.web.servlet.MockMvc;
import org.yaml.snakeyaml.Yaml;
/**
* feature-techlog-studio-backend Task 10 — the drift gate spec §5.5 calls for: springdoc's
* published {@code /v3/api-docs} is diffed against the vendored {@code
* config/openapi/studio-v1.yaml} for every {@code /api/v1/studio/**} path it actually exposes.
* Only <em>implemented</em> operations are checked (direction is "published ⊆ contract", never the
* reverse), so this stays green as slices 2-5 add the other 17 operations — <b>on one condition</b>:
* the new controllers must live somewhere under {@code dev.caskeleton.adapter.inbound.web.techlog},
* the package {@link ContractSurface.ContractSurfaceApp} and {@link EnvelopeWrapping.EnvelopeApp}
* {@code @ComponentScan}. A controller placed there is picked up automatically, with no edit to this
* file. A controller placed <em>outside</em> that package tree is invisible to both minimal contexts
* — springdoc never sees it, so this gate stays green even if its path/method/operationId contradicts
* the contract — and the {@code @ComponentScan} base package below must be widened (or the new
* controller moved) before this gate can be trusted again. (An earlier draft of this class named the
* two controllers directly via {@code @Import} instead of scanning; that hardcoded list had exactly
* this blind spot — confirmed by temporarily reintroducing it and observing a controller with an
* out-of-contract mapping pass silently, see task-10-report.md.) This test also fails the moment an
* in-scan controller's method name drifts from its {@code operationId} or ships an endpoint outside
* the contract.
*
* <h2>Why a hand-built minimal context rather than {@code CaSkeletonApplication}</h2>
*
* <p>This repository's own tests never boot the full app under test: {@code
* FileserverRoundTripContractTest} and {@code ActuatorSecurityHttpTest} (both in this module's
* {@code src/test}) spell out why — {@code application.yml} resolves ~50 {@code ${...}} 자리표시자
* from {@code src/.env} (datasource, OIDC issuer, Redis, messaging, ...), so a full boot drags in
* infrastructure a contract-shape test has nothing to say about. This test follows the same
* playbook: a throwaway {@code @SpringBootConfiguration} that {@code @ComponentScan}s the studio web
* package (so real production controllers like {@link StudioSessionController} and {@link
* StudioCatalogController} are picked up the same way the real app's component scan finds them —
* see the class-level "why scan, not @Import" note above), with {@link SecurityAutoConfiguration}
* excluded and MockMvc filters off ({@code addFilters = false}) — the exclude/addFilters combination
* {@code EnvelopeBodyAdviceTest} and {@code NoResourceFoundErrorHandlingTest} (adapter-inbound-web's
* own test sourceSet) already use for this class of test.
*
* <p>Because this functionalTest module depends only on {@code :adapter:inbound:web} and {@code
* :application-core} (not {@code :adapter:outbound:persistence-jpa}, cache, or messaging), none of
* the DataSource/Flyway/Redis auto-configuration classes are even on the classpath for {@code
* @EnableAutoConfiguration} to attempt — there is nothing to exclude for them, unlike {@code
* ActuatorSecurityHttpTest}'s explicit JPA/Flyway exclude list.
*
* <p>{@code @SpringBootTest} (full, unsliced {@code @EnableAutoConfiguration}) is used instead of
* {@code @WebMvcTest}: springdoc's own auto-configuration is a third-party {@code
* AutoConfiguration.imports} entry, not part of Boot's curated {@code @WebMvcTest} slice allowlist,
* so {@code /v3/api-docs} would not be exposed under a sliced test. A full, unsliced context that
* only sees two modules' worth of dependencies keeps the cost bounded to "start web MVC + springdoc"
* without paying for DB/security infrastructure.
*
* <h2>Why two nested contexts instead of one</h2>
*
* <p>The obvious design is one shared {@code @SpringBootTest} context for both tests. That does not
* work here, and the reason is worth recording: springdoc's {@code /v3/api-docs} handler
* ({@code OpenApiWebMvcResource.openapiJson}) returns raw {@code byte[]} — it serializes the OpenAPI
* model itself and hands Spring MVC already-encoded bytes. {@link EnvelopeBodyAdvice#supports}
* returns {@code true} unconditionally (by design — it wraps every controller response in the real
* app, not just Studio's), so if it is on the classpath of *that* request it rewrites the body from
* {@code byte[]} to {@code Envelope<byte[]>} — but Spring MVC picks the {@code HttpMessageConverter}
* from the *original* return type before the advice runs, so {@code ByteArrayHttpMessageConverter}
* (already selected for {@code byte[]}) is then asked to write an {@code Envelope}, and
* {@code writeInternal} throws {@code ClassCastException: Envelope cannot be cast to [B}. This
* reproduced with a full stack trace during this task (see task-10-report.md) — it is a real,
* pre-existing defect in shared skeleton code ({@code EnvelopeBodyAdvice} is not Studio-owned and
* not part of this task's brief), not an artifact of this test's plumbing: any app that boots both
* springdoc and {@code EnvelopeBodyAdvice} together and serves {@code /v3/api-docs} unauthenticated
* would hit the same crash. Fixing that advice is out of scope for a contract-regression test, so
* {@link ContractSurface} boots a context *without* {@link EnvelopeBodyAdvice} (springdoc doesn't
* invoke it for anything test 1 checks anyway — introspection is pure reflection over the mapping),
* and {@link EnvelopeWrapping} boots a separate context *with* it, hitting
* {@link StudioCatalogController} instead, whose {@code CatalogPage} return type is an ordinary POJO
* that the same JSON converter handles before and after wrapping.
*
* <h2>Why {@link ListCatalogUseCase} is real, not mocked</h2>
*
* <p>{@link StudioCatalogController}'s constructor takes the concrete (non-interface) {@code
* ListCatalogUseCase}, so there is no seam to substitute a fake at that boundary. Its own two
* constructor collaborators, {@link CatalogQueryPort} and {@link TransactionPort}, <em>are</em>
* interfaces (application ports), so this test wires trivial in-memory implementations of those
* instead of pulling in a real persistence adapter — springdoc never invokes a controller method to
* build {@code /v3/api-docs} (pure reflection over the mapping/return-type shape), and the envelope
* test only needs the query to return successfully, not to hold meaningful catalog data.
*
* <h2>Second test: envelope wrapping, without real DB/auth infrastructure</h2>
*
* <p>The brief's original template hits the endpoint through {@code TestRestTemplate} against a
* fully DB+auth-backed app; this functionalTest sourceSet has neither (confirmed: before this task
* it declared only {@code gradleTestKit()} + JUnit + AssertJ, no Spring dependency at all). Rather
* than skip the envelope assertion or force real persistence/security infrastructure into a
* contract-shape test, {@link EnvelopeWrapping} proves the same regression the brief wants — {@link
* EnvelopeBodyAdvice} still wraps {@link StudioCatalogController}'s response — against a minimal
* slice. Envelope wrapping is a {@code ResponseBodyAdvice} concern that is orthogonal to persistence
* and authentication, so stubbing those out does not weaken what the assertion proves, and no
* production security surface changes: {@code SECURITY_PUBLIC_PATHS} is untouched, and this slice
* simply never wires a {@code SecurityFilterChain} at all (same as the two adapter-inbound-web
* precedents cited above), rather than widening what unauthenticated callers may reach in the real
* app.
*/
class StudioContractDriftTest {
@Nested
@SpringBootTest(classes = ContractSurface.ContractSurfaceApp.class)
@AutoConfigureMockMvc(addFilters = false)
class ContractSurface {
@Autowired private MockMvc mvc;
/**
* "구현된 것만 검사" 방향 고정: 계약(19개 operation)이 아니라 published(springdoc이 실제로 내놓는 것, 지금은 2개)를
* 순회한다. 슬라이스 2~5가 나머지 17개를 추가해도 이 순회 방향 덕분에 이 테스트는 그대로 통과한다 — 반대로 순회했다면 미구현 operation마다
* 매번 실패했을 것이다.
*/
@Test
void publishedStudioOperationsMatchTheContract() throws Exception {
JsonNode contract = readContract();
JsonNode published = readPublishedApiDocs();
List<String> problems = new ArrayList<>();
JsonNode publishedPaths = published.path("paths");
for (Map.Entry<String, JsonNode> path : publishedPaths.properties()) {
if (!path.getKey().startsWith("/api/v1/studio/")) {
continue;
}
JsonNode contractPath = contract.path("paths").path(path.getKey());
if (contractPath.isMissingNode()) {
problems.add("계약에 없는 path: " + path.getKey());
continue;
}
for (Map.Entry<String, JsonNode> method : path.getValue().properties()) {
JsonNode contractOp = contractPath.path(method.getKey());
if (contractOp.isMissingNode()) {
problems.add("계약에 없는 method: " + method.getKey() + " " + path.getKey());
continue;
}
String publishedId = method.getValue().path("operationId").asText("");
String contractId = contractOp.path("operationId").asText("");
if (!publishedId.equals(contractId)) {
problems.add(
"operationId 불일치 "
+ method.getKey()
+ " "
+ path.getKey()
+ ": published="
+ publishedId
+ " contract="
+ contractId);
}
}
}
assertThat(problems).isEmpty();
}
/**
* SnakeYaml(이미 {@code StudioErrorRegistryTest}가 error-codes.yaml에 쓰는 라이브러리)로 읽은 뒤 {@code
* ObjectMapper#valueToTree}로 {@link JsonNode}로 옮긴다 — {@code jackson-dataformat-yaml}을 새 컴파일
* 의존으로 끌어오지 않고 기존 라이브러리 조합만으로 브리프 템플릿과 같은 {@code JsonNode} 기반 대조 로직을 쓸 수 있다({@code
* jackson-dataformat-yaml}은 이 모듈의 {@code compileClasspath}가 아니라 {@code runtimeClasspath}에만
* 전이적으로 있었다 — springdoc이 YAML 응답을 만들 때만 필요해서다).
*/
private static JsonNode readContract() throws Exception {
Path contractFile =
RepositoryContractResources.fromSystemProperty()
.requireTrackedFile("src/config/openapi/studio-v1.yaml");
Map<String, Object> contractYaml;
try (InputStream in = Files.newInputStream(contractFile)) {
contractYaml = new Yaml().load(in);
}
return new ObjectMapper().valueToTree(contractYaml);
}
private JsonNode readPublishedApiDocs() throws Exception {
String body =
mvc.perform(get("/v3/api-docs"))
.andExpect(status().isOk())
.andReturn()
.getResponse()
.getContentAsString();
return new ObjectMapper().readTree(body);
}
/**
* {@code @Import}로 컨트롤러를 하나씩 나열하지 않고 {@code @ComponentScan}으로 studio web 패키지 전체를 스캔한다.
* 나열 방식은 슬라이스 2~5가 새 컨트롤러를 추가해도 이 파일을 고치지 않는 한 published 표면에 안 잡히는 채로 green을 유지하는
* 함정이 있었다 — 드리프트가 "없는" 게 아니라 게이트가 "못 보는" 상태였다. 리뷰에서 이 패키지 아래에 계약 밖 매핑을 가진 임시
* 컨트롤러를 하나 추가해(어떤 {@code @Import}/{@code @ComponentScan} 목록에도 안 넣고) 실측으로 확인했다 — 옛 {@code
* @Import} 목록으로는 이 테스트가 통과, 이 {@code @ComponentScan}으로는 실패. 재현 절차와 두 결과 모두
* task-10-report.md의 "자동 포함 성질 RED 검증" 절에 남아 있다. {@code EnvelopeBodyAdvice}는 이 패키지 밖({@code
* ...web.envelope})이라 스캔에 안 걸린다 — 일부러 두지 않는다(클래스 javadoc "Why two nested contexts" 참조).
* springdoc은 리플렉션만 하므로 이 test1엔 애초에 관여하지 않는다.
*/
@SpringBootConfiguration
@EnableAutoConfiguration(exclude = SecurityAutoConfiguration.class)
@ComponentScan("dev.caskeleton.adapter.inbound.web.techlog")
static class ContractSurfaceApp {
@Bean
SecuritySettings securitySettings() {
return StudioContractDriftTest.securitySettingsForTest();
}
@Bean
ListCatalogUseCase listCatalogUseCase() {
return StudioContractDriftTest.listCatalogUseCaseForTest();
}
}
}
@Nested
@SpringBootTest(classes = EnvelopeWrapping.EnvelopeApp.class)
@AutoConfigureMockMvc(addFilters = false)
class EnvelopeWrapping {
@Autowired private MockMvc mvc;
@Test
void everyStudioResponseIsWrappedInTheEnvelope() throws Exception {
String body =
mvc.perform(get("/api/v1/studio/catalog").param("type", "TOPIC"))
.andExpect(status().isOk())
.andReturn()
.getResponse()
.getContentAsString();
assertThat(body).contains("\"success\"").contains("\"data\"").contains("\"meta\"");
assertThat(body).doesNotContain("\"data\":{\"success\"");
}
/**
* {@code adapter-inbound-web}에 실제 애플리케이션이 없어({@code CaSkeletonApplication}은 app-bootstrap
* 소유) {@code @SpringBootTest}가 부트스트랩할 {@code @SpringBootConfiguration}이 필요하다 — {@code
* StudioSessionEnvelopeTest}(adapter-inbound-web 자체 테스트)가 쓰는 것과 같은 이유의 같은 패턴. {@link
* ContractSurface.ContractSurfaceApp}과 마찬가지로 {@code @ComponentScan}으로 studio web 패키지를 스캔하고,
* {@link EnvelopeBodyAdvice}만 별도로 {@code @Import}한다(스캔 범위 밖 패키지라서) — 이 컨텍스트는 {@code
* /v3/api-docs}를 두드리지 않으므로 클래스 javadoc이 설명하는 {@code byte[]} 크래시를 겪지 않는다.
*/
@SpringBootConfiguration
@EnableAutoConfiguration(exclude = SecurityAutoConfiguration.class)
@ComponentScan("dev.caskeleton.adapter.inbound.web.techlog")
@Import(EnvelopeBodyAdvice.class)
static class EnvelopeApp {
@Bean
SecuritySettings securitySettings() {
return StudioContractDriftTest.securitySettingsForTest();
}
@Bean
ListCatalogUseCase listCatalogUseCase() {
return StudioContractDriftTest.listCatalogUseCaseForTest();
}
}
}
/** {@code csrfHeaderName}이 계약 const {@code X-CSRF-TOKEN}과 다르면 {@link StudioSessionController}의 생성자가 즉시 실패한다. */
private static SecuritySettings securitySettingsForTest() {
SecuritySettings.SessionCookieSettings session =
new SecuritySettings.SessionCookieSettings(
null, null, null, null, null, null, "X-CSRF-TOKEN");
return new SecuritySettings(
SecuritySettings.AuthenticationMode.JWT, "https://issuer.example", null, List.of(), session);
}
/**
* 실제 영속성 어댑터 대신 최소 stub 포트로 구성한 진짜 {@link ListCatalogUseCase}. springdoc은 컨트롤러 메서드를 호출하지 않고
* 리플렉션만 하므로 첫 번째 테스트에는 아예 관여하지 않고, 두 번째 테스트(봉투 확인)는 결과 내용이 아니라 감싸는 모양만 보므로 빈 목록으로 충분하다.
*/
private static ListCatalogUseCase listCatalogUseCaseForTest() {
return new ListCatalogUseCase(new StubCatalogQueryPort(), new PassThroughTransactionPort());
}
private static final class StubCatalogQueryPort implements CatalogQueryPort {
@Override
public CatalogPageView search(CatalogEntryType type, String query, String cursor, int limit) {
return new CatalogPageView(List.of(), null);
}
}
/** 실제 트랜잭션 관리자 없이 액션을 곧장 실행한다 — 이 슬라이스에는 커밋/롤백할 트랜잭션 리소스가 없다. */
private static final class PassThroughTransactionPort implements TransactionPort {
@Override
public <T> T inWrite(Supplier<T> action) {
return action.get();
}
@Override
public <T> T inRootWrite(Supplier<T> action) {
return action.get();
}
@Override
public <T> T inRead(Supplier<T> action) {
return action.get();
}
@Override
public <T> T inNew(Supplier<T> action) {
return action.get();
}
}
}
@@ -0,0 +1,18 @@
package dev.caskeleton.bootstrap.techlog;
import dev.caskeleton.application.techlog.studio.port.out.CatalogQueryPort;
import dev.caskeleton.application.techlog.studio.service.ListCatalogUseCase;
import dev.caskeleton.application.transaction.TransactionPort;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/** Tech Log Studio 조립. application-core는 Spring을 보지 않으므로 여기서 배선한다. */
@Configuration
public class TechLogStudioConfig {
@Bean
ListCatalogUseCase listCatalogUseCase(
CatalogQueryPort catalogQueryPort, TransactionPort transactionPort) {
return new ListCatalogUseCase(catalogQueryPort, transactionPort);
}
}
@@ -6,9 +6,11 @@
# a value this file can know. What belongs here is the shape dev must have whatever the operator
# sets: the vendor and the schema owner.
#
# Both keys below restate the repository default rather than change it, so adding this file moves
# no behaviour. That is the point — the moment dev and prod diverge from local, the difference has
# a declared home instead of being implied by whatever the environment happened to inject.
# The flyway/persistence keys below restate the repository default rather than change it, so they
# move no behaviour on their own. That is the point — the moment dev and prod diverge from local,
# the difference has a declared home instead of being implied by whatever the environment happened
# to inject. The security.session key further down is the one exception: it genuinely overrides the
# template default for Tech Log Studio's contract. See the comment there for why.
# =============================================================================
spring:
@@ -20,3 +22,18 @@ spring:
ca-skeleton:
persistence:
vendor: postgresql
security:
# Studio's contract (studio-v1.yaml StudioSession.csrfHeaderName) fixes this header name as a
# `const`. The template default is X-XSRF-TOKEN; Studio needs X-CSRF-TOKEN to match.
#
# auth-mode intentionally stays at the repository default (jwt) here. Task 8's brief proposed
# switching it to redis-session, but src/app-bootstrap's AuthenticationModeCompositionConfig
# requires a complete Redis session repository/filter pair (`redisVersionedSessionRepository`,
# `springSessionRepositoryFilter`) once that mode is active, and neither bean exists in this
# branch yet — the Redis session infrastructure is out of this task's scope (its design package
# was removed from the working tree ahead of this task; see AGENTS.md / task-8 report). Setting
# auth-mode: redis-session here would make a real `--spring.profiles.active=dev` boot fail the
# composition validator with "Redis Session repository/filter is incomplete". This csrf-header-
# name override is independent of auth-mode and safe on its own.
session:
csrf-header-name: X-CSRF-TOKEN
@@ -143,6 +143,15 @@ ca-skeleton:
issuer-uri: http://localhost:8081/realms/ca-skeleton
audience: ca-skeleton-api
public-paths: /api/healthcheck
# Studio's contract (studio-v1.yaml StudioSession.csrfHeaderName) fixes this header name as a
# `const`. The template default is X-XSRF-TOKEN (application.yml:498, restated verbatim by
# src/.env:125, the profile src/.env:8 activates) — StudioSessionController's constructor
# rejects any other value with IllegalStateException, and because that controller is an
# unconditional @RestController bean, the failure is a BeanCreationException that fails context
# refresh and kills the whole process, not just Studio. See application-dev.yml's identical
# override for the full rationale.
session:
csrf-header-name: X-CSRF-TOKEN
cors:
enabled: true
allowed-origins: http://localhost:3000
@@ -26,3 +26,13 @@ spring:
ca-skeleton:
persistence:
vendor: postgresql
security:
# Studio's contract (studio-v1.yaml StudioSession.csrfHeaderName) fixes this header name as a
# `const`. The template default is X-XSRF-TOKEN; Studio needs X-CSRF-TOKEN to match.
# StudioSessionController's constructor rejects any other configured value with
# IllegalStateException, and because that controller is an unconditional @RestController bean,
# the failure is a BeanCreationException that fails context refresh and kills the whole
# process on this profile, not just Studio. See application-dev.yml's identical override for
# the full rationale.
session:
csrf-header-name: X-CSRF-TOKEN
@@ -0,0 +1,208 @@
package dev.caskeleton.bootstrap.architecture;
import static org.assertj.core.api.Assertions.assertThat;
import dev.caskeleton.adapter.inbound.web.techlog.StudioClientSafeMessages;
import dev.caskeleton.application.techlog.error.StudioError;
import dev.caskeleton.bootstrap.contract.support.RepositoryContractResources;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.yaml.snakeyaml.Yaml;
/**
* feature-techlog-studio-backend — pins {@link StudioError} against {@code
* docs/registries/error-codes.yaml} (the SSOT the contract and log tooling read) on three axes:
*
* <ol>
* <li>every {@link StudioError} constant has a matching registry row (code presence);
* <li>that row's {@code category}/{@code http_status}/{@code retryable} match the enum's
* declared values exactly — a standing drift gate. Nothing else in the suite checks this for
* {@link StudioError}: {@code ErrorCodeRegistryMappingTest} (a different branch's template)
* only walks {@code OperationalError}. Its absence let the {@code VALIDATION_FAILED} ↔
* {@code OperationalError.VALIDATION_FAILED} code-name collision ship once already (see
* task-5-report.md Fix Round 1) — this closes that gap for good;
* <li>{@link StudioClientSafeMessages#forError(StudioError)}'s text matches the row's {@code
* client_safe_message} exactly — the single source of truth for {@code error.message} is the
* registry, and code drifting from it must fail here rather than silently changing what
* clients see.
* </ol>
*
* <p>Uses {@link RepositoryContractResources} (the same repository-root resolver the sibling
* registry contract tests in {@code dev.caskeleton.bootstrap.contract} use) rather than a
* hand-rolled relative {@code Path.of("..", "..", ...)}, since Gradle's test working directory is
* not guaranteed to be the module directory the brief's naive relative path assumed. Parses the
* registry with SnakeYaml — the same library/pattern {@code ErrorCodeRegistryMappingTest} and
* {@code RunbookCoverageContractTest} already use in this suite — rather than line-scanning, since
* this test needs structured field access (category/http_status/retryable/client_safe_message),
* not just the {@code code:} key.
*/
class StudioErrorRegistryTest {
private static Map<String, Map<String, Object>> registryRowsByCode;
@BeforeAll
@SuppressWarnings("unchecked")
static void loadRegistry() throws Exception {
Path registry =
RepositoryContractResources.fromSystemProperty()
.requireTrackedFile("docs/registries/error-codes.yaml");
registryRowsByCode = new LinkedHashMap<>();
try (InputStream in = Files.newInputStream(registry)) {
Map<String, Object> root = new Yaml().load(in);
List<Map<String, Object>> errors = (List<Map<String, Object>>) root.get("errors");
for (Map<String, Object> row : errors) {
registryRowsByCode.put((String) row.get("code"), row);
}
}
}
@Test
void everyStudioErrorHasARegistryRow() {
Set<String> declared =
Arrays.stream(StudioError.values()).map(StudioError::code).collect(Collectors.toSet());
assertThat(registryRowsByCode.keySet()).containsAll(declared);
}
/**
* The value-drift gate: every {@link StudioError}'s registry row must carry the exact same
* category/http_status/retryable the enum declares. {@code PAYLOAD_TOO_LARGE}/{@code
* UNSUPPORTED_MEDIA_TYPE} reuse a pre-existing {@code feature-api-contract-baseline} row instead
* of a Studio-owned one (task-5-report.md §5) — this still holds them to the same standard.
*/
@Test
void everyStudioErrorRowMatchesCategoryHttpStatusAndRetryable() {
for (StudioError error : StudioError.values()) {
Map<String, Object> row = registryRowsByCode.get(error.code());
assertThat(row).as("registry row for %s", error.code()).isNotNull();
assertThat(row.get("category"))
.as("category for %s", error.code())
.isEqualTo(error.category().name());
assertThat(((Number) row.get("http_status")).intValue())
.as("http_status for %s", error.code())
.isEqualTo(error.httpStatus());
assertThat(row.get("retryable"))
.as("retryable for %s", error.code())
.isEqualTo(error.retryable());
}
}
/**
* {@code StudioClientSafeMessages} must never drift from the registry's {@code
* client_safe_message} — that column is the single source of truth for what {@code
* error.message} clients see (task-5-report.md Important 1).
*/
@Test
void everyStudioErrorClientSafeMessageMatchesRegistry() {
for (StudioError error : StudioError.values()) {
Map<String, Object> row = registryRowsByCode.get(error.code());
assertThat(row).as("registry row for %s", error.code()).isNotNull();
assertThat(StudioClientSafeMessages.forError(error))
.as("client_safe_message for %s", error.code())
.isEqualTo(row.get("client_safe_message"));
}
}
/**
* final whole-branch review B3: {@code StudioErrorTest.declaresExactlyTheTwentyThreeContractCodes}
* only counts ({@code hasSize(23)}) — it never reads the contract, so a rename or a 1:1 code
* substitution on either side (enum or {@code studio-v1.yaml}) leaves the count at 23 and passes.
* This is the gate that reads {@code src/config/openapi/studio-v1.yaml}'s {@code
* components.schemas.ApiError.properties.code.enum} and requires the two sets to be identical in
* both directions — a code present only in the contract, or only in the enum, fails here. This
* drift already happened once for real (Task 5's vendor copy carrying stale names) and a human
* caught it, not a gate; this closes that gap.
*/
@Test
void enumMatchesContractCodeSetExactly() throws Exception {
Path contract =
RepositoryContractResources.fromSystemProperty()
.requireTrackedFile("src/config/openapi/studio-v1.yaml");
Set<String> contractCodes = contractApiErrorCodes(contract);
Set<String> enumCodes =
Arrays.stream(StudioError.values()).map(StudioError::code).collect(Collectors.toSet());
assertThat(enumCodes)
.as("StudioError enum vs studio-v1.yaml ApiError.code enum")
.containsExactlyInAnyOrderElementsOf(contractCodes);
}
@SuppressWarnings("unchecked")
private static Set<String> contractApiErrorCodes(Path contract) throws IOException {
try (InputStream in = Files.newInputStream(contract)) {
Map<String, Object> root = new Yaml().load(in);
Map<String, Object> components = (Map<String, Object>) root.get("components");
Map<String, Object> schemas = (Map<String, Object>) components.get("schemas");
Map<String, Object> apiError = (Map<String, Object>) schemas.get("ApiError");
Map<String, Object> properties = (Map<String, Object>) apiError.get("properties");
Map<String, Object> code = (Map<String, Object>) properties.get("code");
List<String> enumValues = (List<String>) code.get("enum");
return Set.copyOf(enumValues);
}
}
/**
* final whole-branch review B3: {@code src/config/openapi/studio-v1.yaml} is a vendored copy of
* the design package's contract (MANIFEST.sha256's {@code # source:} line records where from) and
* had zero consumers before this test — nothing detected a local edit to the vendor copy drifting
* from the hash the manifest recorded at vendoring time. This makes {@code MANIFEST.sha256} an
* actual tamper/drift gate rather than a file nobody reads.
*/
@Test
void vendoredContractMatchesTheRecordedManifestHash() throws Exception {
RepositoryContractResources resources = RepositoryContractResources.fromSystemProperty();
Path contract = resources.requireTrackedFile("src/config/openapi/studio-v1.yaml");
Path manifest = resources.requireTrackedFile("src/config/openapi/MANIFEST.sha256");
String recordedHash = recordedSha256(manifest, "studio-v1.yaml");
String actualHash = sha256Hex(contract);
assertThat(actualHash)
.as(
"src/config/openapi/studio-v1.yaml sha256 must match the value MANIFEST.sha256 recorded"
+ " for it (local edit or vendoring drift)")
.isEqualTo(recordedHash);
}
/**
* Parses lines shaped {@code <hex sha256> <filename>}, skipping {@code #}-prefixed comment
* lines such as MANIFEST.sha256's {@code # source: ...} provenance line.
*/
private static String recordedSha256(Path manifest, String filename) throws IOException {
return Files.readAllLines(manifest).stream()
.map(String::strip)
.filter(line -> !line.isEmpty() && !line.startsWith("#"))
.filter(line -> line.endsWith(filename))
.map(line -> line.substring(0, line.indexOf(' ')).strip())
.findFirst()
.orElseThrow(
() ->
new IllegalStateException(
"MANIFEST.sha256 has no hash row for " + filename + ": " + manifest));
}
private static String sha256Hex(Path file) throws IOException, NoSuchAlgorithmException {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hash = digest.digest(Files.readAllBytes(file));
StringBuilder hex = new StringBuilder(hash.length * 2);
for (byte b : hash) {
hex.append(String.format("%02x", b));
}
return hex.toString();
}
}
@@ -0,0 +1,141 @@
package dev.caskeleton.bootstrap.architecture;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;
import com.tngtech.archunit.junit.AnalyzeClasses;
import com.tngtech.archunit.junit.ArchTest;
import com.tngtech.archunit.lang.ArchRule;
/**
* 설계 08장의 bounded context 경계를 빌드로 강제한다. Gradle leaf를 늘리지 않고
* 패키지로 나눴으므로(spec D1/D2) 경계는 이 규칙이 유일한 방어선이다.
*/
@AnalyzeClasses(packages = "dev.caskeleton", importOptions = ProductionClassImportOption.class)
class TechLogBoundaryArchTest {
// 새 techlog context를 추가할 때 손봐야 할 지점 (fix round 1에서 asset/publication 규칙이
// 계획에서 통째로 빠졌던 것이 바로 이 체크리스트를 세워두지 않아서였다 — spec §4.3이 규칙
// 개수·내용의 원본이고, 이 클래스는 그것의 실행 가능한 사본일 뿐이다):
// (a) 그 context 전용 형제 비의존 규칙(XXX_DOES_NOT_DEPEND_ON_SIBLING_CONTEXTS)을 새로
// 추가한다.
// (b) 기존 형제 규칙들(CONTENT/INQUIRY/PROJECT/ASSET_DOES_NOT_DEPEND_ON_SIBLING_CONTEXTS)의
// dependOnClassesThat().resideInAnyPackage(...) 금지 목록에 새 context를 추가한다.
// (c) STUDIO_FACADE_ONLY_TOUCHES_INBOUND_PORTS의 금지 목록(service/port.out)에 새
// context의 service·port.out 패키지를 추가한다.
// (d) NO_CONTEXT_DEPENDS_ON_STUDIO_FACADE의 대상 목록(that().resideInAnyPackage(...))에
// 새 context를 추가한다.
// 새 context가 도메인 엔터티를 갖고 다른 context가 그것을 직접 변조하면 안 되는 경우
// (publication과 같은 성격) NO_CONTEXT_DEPENDS_ON_PUBLICATION_DOMAIN과 같은 모양의
// 전용 규칙도 검토한다.
@ArchTest
static final ArchRule CONTENT_DOES_NOT_DEPEND_ON_SIBLING_CONTEXTS =
noClasses()
.that()
.resideInAPackage("..techlog.content..")
.should()
.dependOnClassesThat()
.resideInAnyPackage("..techlog.inquiry..", "..techlog.project..", "..techlog.asset..")
.as("techlog.content는 형제 context에 의존하지 않는다")
.allowEmptyShould(true);
@ArchTest
static final ArchRule INQUIRY_DOES_NOT_DEPEND_ON_SIBLING_CONTEXTS =
noClasses()
.that()
.resideInAPackage("..techlog.inquiry..")
.should()
.dependOnClassesThat()
.resideInAnyPackage("..techlog.content..", "..techlog.project..", "..techlog.asset..")
.as("techlog.inquiry는 형제 context에 의존하지 않는다")
.allowEmptyShould(true);
@ArchTest
static final ArchRule PROJECT_DOES_NOT_DEPEND_ON_SIBLING_CONTEXTS =
noClasses()
.that()
.resideInAPackage("..techlog.project..")
.should()
.dependOnClassesThat()
.resideInAnyPackage("..techlog.content..", "..techlog.inquiry..", "..techlog.asset..")
.as("techlog.project는 형제 context에 의존하지 않는다")
.allowEmptyShould(true);
@ArchTest
static final ArchRule ASSET_DOES_NOT_DEPEND_ON_SIBLING_CONTEXTS =
noClasses()
.that()
.resideInAPackage("..techlog.asset..")
.should()
.dependOnClassesThat()
.resideInAnyPackage("..techlog.content..", "..techlog.inquiry..", "..techlog.project..")
.as(
"spec §4.3 규칙1 ASSET_DOES_NOT_DEPEND_ON_SIBLING_CONTEXTS: techlog.asset는 "
+ "형제 context(content/inquiry/project)에 의존하지 않는다 — fix round 1: "
+ "content/inquiry/project 세 방향은 이미 asset을 형제 금지 목록에 넣어 막고 "
+ "있었지만 asset 자신이 형제를 참조하는 반대 방향이 계획에서 빠져 있었다")
.allowEmptyShould(true);
@ArchTest
static final ArchRule STUDIO_FACADE_ONLY_TOUCHES_INBOUND_PORTS =
noClasses()
.that()
.resideInAPackage("..application.techlog.studio..")
.should()
.dependOnClassesThat()
.resideInAnyPackage(
"..application.techlog.content.service..",
"..application.techlog.content.port.out..",
"..application.techlog.inquiry.service..",
"..application.techlog.inquiry.port.out..",
"..application.techlog.project.service..",
"..application.techlog.project.port.out..",
"..application.techlog.asset.service..",
"..application.techlog.asset.port.out..",
"..application.techlog.publication.service..",
"..application.techlog.publication.port.out..",
"..domain.techlog..")
.as("studio facade는 타 context의 port.in만 호출한다 (domain·service·port.out 직접 접근 금지)")
.allowEmptyShould(true);
@ArchTest
static final ArchRule NO_CONTEXT_DEPENDS_ON_STUDIO_FACADE =
noClasses()
.that()
.resideInAnyPackage(
"..techlog.content..",
"..techlog.inquiry..",
"..techlog.project..",
"..techlog.asset..",
"..techlog.publication..")
.should()
.dependOnClassesThat()
.resideInAPackage("..application.techlog.studio..")
.as("도메인 context는 studio facade에 역방향 의존하지 않는다")
.allowEmptyShould(true);
@ArchTest
static final ArchRule NO_CONTEXT_DEPENDS_ON_PUBLICATION_DOMAIN =
noClasses()
.that()
.resideInAnyPackage(
"..domain.techlog.content..",
"..domain.techlog.inquiry..",
"..domain.techlog.project..",
"..domain.techlog.asset..",
"..domain.techlog.identity..")
.should()
.dependOnClassesThat()
.resideInAPackage("..domain.techlog.publication..")
.as(
"spec §4.3 규칙4 NO_CONTEXT_DEPENDS_ON_PUBLICATION_DOMAIN: "
+ "domain.techlog.publication을 제외한 어떤 domain 패키지도 Publication을 "
+ "직접 변경하지 않는다. ArchUnit은 '직접 변경'이라는 동작을 정적으로 표현할 "
+ "수 없으므로, 타 domain context가 publication 도메인 패키지에 의존하는 것 "
+ "자체를 금지하는 보수적 근사로 대신한다 — 위 형제 규칙들(techlog.content/"
+ "inquiry/project/asset)도 전면 금지이므로 일관된 강도다. PublicationStatus "
+ "같은 타입을 타 context가 읽어야 하는 정당한 필요가 생기면 그 타입을 공유 "
+ "위치로 옮기거나 port로 노출하는 것이 옳은 해법이지 이 경계를 뚫는 것이 "
+ "아니다")
.allowEmptyShould(true);
}
@@ -0,0 +1,96 @@
package dev.caskeleton.bootstrap.contract;
import static org.assertj.core.api.Assertions.assertThat;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Map;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.yaml.snakeyaml.LoaderOptions;
import org.yaml.snakeyaml.Yaml;
import org.yaml.snakeyaml.constructor.SafeConstructor;
/**
* final whole-branch review B1: pins {@code ca-skeleton.security.session.csrf-header-name} to the
* contract {@code const} ({@code studio-v1.yaml StudioSession.csrfHeaderName}, config/openapi/
* studio-v1.yaml:832) for every profile that can actually boot the process — {@code local}, {@code
* dev}, {@code prod}.
*
* <p>{@code StudioSessionController}'s constructor throws {@code IllegalStateException} when the
* configured header name disagrees with the contract const. That controller is an unconditional
* {@code @RestController} bean, so the constructor failure becomes a {@code BeanCreationException}
* during context refresh — the process never starts, taking healthcheck/actuator/fileserver down
* with it. Only {@code application-dev.yml} declared the override before this fix;
* {@code application-local.yml} (the profile {@code src/.env:8} actually activates) and
* {@code application-prod.yml} both resolved to the template default {@code X-XSRF-TOKEN}
* (application.yml:498's inline default, restated verbatim by {@code src/.env:125}), so both
* profiles could not boot.
*
* <p>File-assertion contract test rather than a booted context, for the same reason {@link
* ProfileSeparationContractTest} gives: booting the real composition root is not possible in this
* source set (see that class's javadoc). This test follows its pattern and location.
*/
class StudioSessionCsrfHeaderProfileContractTest {
private static final Path REPOSITORY_ROOT = repositoryRoot();
/** studio-v1.yaml {@code StudioSession.csrfHeaderName} — config/openapi/studio-v1.yaml:832. */
private static final String CONTRACT_CSRF_HEADER_NAME = "X-CSRF-TOKEN";
@ParameterizedTest
@ValueSource(strings = {"local", "dev", "prod"})
void everyBootableProfileResolvesTheContractCsrfHeaderName(String profile) throws IOException {
assertThat(csrfHeaderNameOf(profile(profile)))
.as(
"application-%s.yml must set ca-skeleton.security.session.csrf-header-name to \"%s\""
+ " (studio-v1.yaml StudioSession.csrfHeaderName const) — otherwise"
+ " StudioSessionController's constructor throws IllegalStateException and the"
+ " whole process fails to boot on this profile",
profile, CONTRACT_CSRF_HEADER_NAME)
.isEqualTo(CONTRACT_CSRF_HEADER_NAME);
}
private static String csrfHeaderNameOf(Map<?, ?> configuration) {
Map<?, ?> session =
child(child(child(configuration, "ca-skeleton"), "security"), "session");
Object value = session == null ? null : session.get("csrf-header-name");
return value == null ? null : value.toString();
}
private static Map<?, ?> child(Map<?, ?> owner, String key) {
if (owner == null) {
return null;
}
Object value = owner.get(key);
return value instanceof Map<?, ?> map ? map : null;
}
private static Map<?, ?> profile(String profile) throws IOException {
return yaml("application-" + profile + ".yml");
}
private static Map<?, ?> yaml(String resource) throws IOException {
String source =
Files.readString(
REPOSITORY_ROOT.resolve("src/app-bootstrap/src/main/resources").resolve(resource));
LoaderOptions options = new LoaderOptions();
options.setAllowDuplicateKeys(false);
Object loaded = new Yaml(new SafeConstructor(options)).load(source);
assertThat(loaded).as("%s must parse as a YAML mapping", resource).isInstanceOf(Map.class);
return (Map<?, ?>) loaded;
}
private static Path repositoryRoot() {
for (Path path = Paths.get("").toAbsolutePath(); path != null; path = path.getParent()) {
if (Files.isRegularFile(path.resolve("AGENTS.md"))
&& Files.isRegularFile(path.resolve("src/settings.gradle"))) {
return path;
}
}
throw new IllegalStateException(
"repository root not found from " + Paths.get("").toAbsolutePath());
}
}
@@ -0,0 +1,64 @@
package dev.caskeleton.application.techlog.error;
import dev.caskeleton.shared.error.ApiErrorCode;
import dev.caskeleton.shared.error.Category;
/**
* Studio 계약(`studio-v1.yaml`)의 `ApiError.code` enum 23종. 계약과 1:1이며 여기서 코드를 늘리거나 줄이면 계약과
* `docs/registries/error-codes.yaml`을 함께 고쳐야 한다.
*/
public enum StudioError implements ApiErrorCode {
AUTHENTICATION_REQUIRED(Category.AUTH, 401, false),
STUDIO_ACCESS_DENIED(Category.AUTHZ, 403, false),
DOCUMENT_NOT_FOUND(Category.NOT_FOUND, 404, false),
VERSION_CONFLICT(Category.CONFLICT, 409, false),
REQUEST_VALIDATION_FAILED(Category.VALIDATION, 422, false),
DOCUMENT_VALIDATION_FAILED(Category.VALIDATION, 422, false),
VALIDATION_STALE(Category.CONFLICT, 409, false),
PREVIEW_NOT_FOUND(Category.NOT_FOUND, 404, false),
PREVIEW_STALE(Category.CONFLICT, 409, false),
PREVIEW_EXPIRED(Category.CONFLICT, 409, false),
PUBLICATION_NOT_FOUND(Category.NOT_FOUND, 404, false),
PUBLICATION_CONFLICT(Category.CONFLICT, 409, false),
PUBLICATION_EVENT_NOT_FOUND(Category.NOT_FOUND, 404, false),
PUBLICATION_SNAPSHOT_NOT_FOUND(Category.NOT_FOUND, 404, false),
WARNING_ACKNOWLEDGEMENT_REQUIRED(Category.VALIDATION, 422, false),
IDEMPOTENCY_KEY_REUSED(Category.CONFLICT, 409, false),
ASSET_NOT_FOUND(Category.NOT_FOUND, 404, false),
ASSET_NOT_READY(Category.CONFLICT, 409, false),
ASSET_IN_USE(Category.CONFLICT, 409, false),
ASSET_QUARANTINED(Category.DATA_INTEGRITY, 409, false),
PAYLOAD_TOO_LARGE(Category.VALIDATION, 413, false),
UNSUPPORTED_MEDIA_TYPE(Category.VALIDATION, 415, false),
STUDIO_UNAVAILABLE(Category.TRANSIENT_DEPENDENCY, 503, true);
private final Category category;
private final int httpStatus;
private final boolean retryable;
StudioError(Category category, int httpStatus, boolean retryable) {
this.category = category;
this.httpStatus = httpStatus;
this.retryable = retryable;
}
@Override
public String code() {
return name();
}
@Override
public Category category() {
return category;
}
@Override
public int httpStatus() {
return httpStatus;
}
@Override
public boolean retryable() {
return retryable;
}
}
@@ -0,0 +1,44 @@
package dev.caskeleton.application.techlog.error;
import dev.caskeleton.shared.error.ApiErrorCarrier;
import dev.caskeleton.shared.error.ApiErrorCode;
/**
* Studio use case와 facade가 던지는 유일한 실패 표현. 전송 계층은 {@link ApiErrorCarrier}만 보고 봉투로 옮기므로 application이
* HTTP를 알 필요가 없다.
*
* <p>{@code details}는 계약의 {@code ApiError.details}에 그대로 실린다 — {@code VERSION_CONFLICT}면 최신 문서,
* {@code PUBLICATION_CONFLICT}면 최신 Publication.
*/
public final class StudioException extends RuntimeException implements ApiErrorCarrier {
private final transient StudioError error;
private final transient Object details;
private StudioException(StudioError error, String message, Object details) {
super(message);
this.error = error;
this.details = details;
}
public static StudioException of(StudioError error, String message) {
return new StudioException(error, message, null);
}
public static StudioException withDetails(StudioError error, String message, Object details) {
return new StudioException(error, message, details);
}
@Override
public ApiErrorCode errorCode() {
return error;
}
public StudioError studioError() {
return error;
}
public Object details() {
return details;
}
}
@@ -0,0 +1,11 @@
package dev.caskeleton.application.techlog.studio.port.out;
import dev.caskeleton.application.techlog.studio.query.CatalogEntryType;
import dev.caskeleton.application.techlog.studio.query.CatalogPageView;
/** Studio catalog는 도메인 Aggregate를 재구성하지 않는다. 전용 read 포트로 union query를 돌린다 (설계 08장 §4). */
@FunctionalInterface
public interface CatalogQueryPort {
CatalogPageView search(CatalogEntryType type, String query, String cursor, int limit);
}
@@ -0,0 +1,9 @@
package dev.caskeleton.application.techlog.studio.query;
/** 계약 `CatalogEntryType`과 1:1. API enum이 그대로 application 용어다. */
public enum CatalogEntryType {
TOPIC,
PROJECT,
RELATION,
EVIDENCE
}
@@ -0,0 +1,14 @@
package dev.caskeleton.application.techlog.studio.query;
import java.util.UUID;
/**
* 계약 `CatalogEntry`의 application 표현. {@code kind}와 {@code publicPath}는 TOPIC/PROJECT에는 없으므로 null이다.
*/
public record CatalogEntryView(
UUID id,
CatalogEntryType type,
String label,
String kind,
String publicPath,
String dependencyRevision) {}
@@ -0,0 +1,10 @@
package dev.caskeleton.application.techlog.studio.query;
import java.util.List;
public record CatalogPageView(List<CatalogEntryView> items, String nextCursor) {
public CatalogPageView {
items = List.copyOf(items);
}
}
@@ -0,0 +1,6 @@
package dev.caskeleton.application.techlog.studio.query;
import dev.caskeleton.application.query.Query;
public record ListCatalogQuery(CatalogEntryType type, String query, String cursor, int limit)
implements Query {}
@@ -0,0 +1,86 @@
package dev.caskeleton.application.techlog.studio.service;
import dev.caskeleton.application.capability.Idempotency;
import dev.caskeleton.application.capability.RepositoryAccess;
import dev.caskeleton.application.capability.UseCaseCapability;
import dev.caskeleton.application.techlog.error.StudioError;
import dev.caskeleton.application.techlog.error.StudioException;
import dev.caskeleton.application.techlog.studio.port.out.CatalogQueryPort;
import dev.caskeleton.application.techlog.studio.query.CatalogPageView;
import dev.caskeleton.application.techlog.studio.query.ListCatalogQuery;
import dev.caskeleton.application.transaction.TransactionMode;
import dev.caskeleton.application.transaction.TransactionPort;
import dev.caskeleton.application.usecase.QueryUseCase;
/**
* Studio catalog 조회. 도메인 상태를 바꾸지 않으므로 read-only다.
*
* <p>브리프 원안은 {@code TransactionPort}를 배선하지 않았지만, {@code
* CleanArchitectureTest.USE_CASE_CAPABILITY_MATCHES_TRANSACTION_PORT_BOUNDARY}(feature
* -domain-feature-onboarding-contract D4)가 READ_REPOSITORY+READ_ONLY 조합에 {@code
* TransactionPort.inRead(...)}를 직접 호출하도록 정적으로 강제한다. {@link
* dev.caskeleton.application.notification.NotificationOperationsSnapshotUseCase}와 같은 패턴이다.
*/
@UseCaseCapability(
transactionMode = TransactionMode.READ_ONLY,
idempotency = Idempotency.IDEMPOTENT,
repositoryAccess = RepositoryAccess.READ_REPOSITORY)
public final class ListCatalogUseCase implements QueryUseCase<ListCatalogQuery, CatalogPageView> {
private static final int MAX_LIMIT = 100;
/**
* studio-v1.yaml {@code components.parameters.Query.schema.maxLength}
* (config/openapi/studio-v1.yaml:579).
*/
private static final int MAX_QUERY_LENGTH = 100;
/**
* studio-v1.yaml {@code components.parameters.Cursor.schema.minLength}
* (config/openapi/studio-v1.yaml:580).
*/
private static final int MIN_CURSOR_LENGTH = 1;
/**
* studio-v1.yaml {@code components.parameters.Cursor.schema.maxLength}
* (config/openapi/studio-v1.yaml:580).
*/
private static final int MAX_CURSOR_LENGTH = 2000;
private final CatalogQueryPort catalogQueryPort;
private final TransactionPort transactions;
public ListCatalogUseCase(CatalogQueryPort catalogQueryPort, TransactionPort transactions) {
this.catalogQueryPort = catalogQueryPort;
this.transactions = transactions;
}
@Override
public CatalogPageView handle(ListCatalogQuery input) {
if (input.type() == null) {
throw StudioException.of(StudioError.REQUEST_VALIDATION_FAILED, "type is required");
}
if (input.limit() < 1 || input.limit() > MAX_LIMIT) {
throw StudioException.of(
StudioError.REQUEST_VALIDATION_FAILED, "limit must be between 1 and " + MAX_LIMIT);
}
if (input.query() != null && input.query().length() > MAX_QUERY_LENGTH) {
throw StudioException.of(
StudioError.REQUEST_VALIDATION_FAILED,
"q must be at most " + MAX_QUERY_LENGTH + " characters");
}
if (input.cursor() != null
&& (input.cursor().length() < MIN_CURSOR_LENGTH
|| input.cursor().length() > MAX_CURSOR_LENGTH)) {
throw StudioException.of(
StudioError.REQUEST_VALIDATION_FAILED,
"cursor must be between "
+ MIN_CURSOR_LENGTH
+ " and "
+ MAX_CURSOR_LENGTH
+ " characters");
}
return transactions.inRead(
() -> catalogQueryPort.search(input.type(), input.query(), input.cursor(), input.limit()));
}
}
@@ -0,0 +1,48 @@
package dev.caskeleton.application.techlog.error;
import static org.assertj.core.api.Assertions.assertThat;
import dev.caskeleton.shared.error.Category;
import java.util.Arrays;
import org.junit.jupiter.api.Test;
class StudioErrorTest {
/**
* final whole-branch review B3: renamed from {@code declaresExactlyTheTwentyThreeContractCodes} —
* this test never reads {@code studio-v1.yaml}, only counts the enum, so "contract codes" was a
* false claim (a rename or 1:1 substitution on either side leaves the count at 23 and this still
* passes). The actual contract-vs-enum set-equality gate is {@code
* StudioErrorRegistryTest#enumMatchesContractCodeSetExactly}; this test stays as a cheap "did the
* count change" tripwire.
*/
@Test
void declaresExactlyTwentyThreeCodes() {
assertThat(StudioError.values()).hasSize(23);
}
@Test
void everyCodeCarriesACategoryAndAClientFacingStatus() {
Arrays.stream(StudioError.values())
.forEach(
error -> {
assertThat(error.code()).matches("[A-Z][A-Z0-9_]*");
assertThat(error.category()).isNotNull();
assertThat(error.httpStatus()).isBetween(400, 599);
});
}
@Test
void versionConflictIsAFourZeroNineConflict() {
assertThat(StudioError.VERSION_CONFLICT.httpStatus()).isEqualTo(409);
assertThat(StudioError.VERSION_CONFLICT.category()).isEqualTo(Category.CONFLICT);
assertThat(StudioError.VERSION_CONFLICT.retryable()).isFalse();
}
@Test
void studioUnavailableIsRetryable() {
assertThat(StudioError.STUDIO_UNAVAILABLE.httpStatus()).isEqualTo(503);
assertThat(StudioError.STUDIO_UNAVAILABLE.category()).isEqualTo(Category.TRANSIENT_DEPENDENCY);
assertThat(StudioError.STUDIO_UNAVAILABLE.retryable()).isTrue();
}
}
@@ -0,0 +1,138 @@
package dev.caskeleton.application.techlog.studio.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import dev.caskeleton.application.techlog.error.StudioException;
import dev.caskeleton.application.techlog.studio.port.out.CatalogQueryPort;
import dev.caskeleton.application.techlog.studio.query.CatalogEntryType;
import dev.caskeleton.application.techlog.studio.query.CatalogEntryView;
import dev.caskeleton.application.techlog.studio.query.CatalogPageView;
import dev.caskeleton.application.techlog.studio.query.ListCatalogQuery;
import dev.caskeleton.application.transaction.TransactionPort;
import java.util.List;
import java.util.UUID;
import java.util.function.Supplier;
import org.junit.jupiter.api.Test;
class ListCatalogUseCaseTest {
private static CatalogQueryPort portReturning(CatalogPageView page) {
return (type, query, cursor, limit) -> page;
}
@Test
void returnsWhateverThePortFound() {
CatalogEntryView entry =
new CatalogEntryView(
UUID.randomUUID(), CatalogEntryType.TOPIC, "Kafka", null, null, "rev-1");
ListCatalogUseCase useCase =
new ListCatalogUseCase(
portReturning(new CatalogPageView(List.of(entry), null)), new DirectTransactions());
CatalogPageView page =
useCase.handle(new ListCatalogQuery(CatalogEntryType.TOPIC, "ka", null, 20));
assertThat(page.items()).containsExactly(entry);
assertThat(page.nextCursor()).isNull();
}
@Test
void rejectsALimitAboveTheContractCeiling() {
ListCatalogUseCase useCase =
new ListCatalogUseCase(
portReturning(new CatalogPageView(List.of(), null)), new DirectTransactions());
assertThatThrownBy(
() -> useCase.handle(new ListCatalogQuery(CatalogEntryType.TOPIC, null, null, 101)))
.isInstanceOf(StudioException.class)
.hasMessageContaining("limit");
}
@Test
void rejectsAMissingType() {
ListCatalogUseCase useCase =
new ListCatalogUseCase(
portReturning(new CatalogPageView(List.of(), null)), new DirectTransactions());
assertThatThrownBy(() -> useCase.handle(new ListCatalogQuery(null, null, null, 20)))
.isInstanceOf(StudioException.class);
}
/**
* studio-v1.yaml {@code components.parameters.Query} — {@code schema: { type: string, maxLength:
* 100 } } (src/config/openapi/studio-v1.yaml:579).
*/
@Test
void rejectsAQueryLongerThanTheContractCeiling() {
ListCatalogUseCase useCase =
new ListCatalogUseCase(
portReturning(new CatalogPageView(List.of(), null)), new DirectTransactions());
String tooLong = "q".repeat(101);
assertThatThrownBy(
() -> useCase.handle(new ListCatalogQuery(CatalogEntryType.TOPIC, tooLong, null, 20)))
.isInstanceOf(StudioException.class)
.hasMessageContaining("q");
}
/**
* studio-v1.yaml {@code components.parameters.Cursor} — {@code schema: { type: string, minLength:
* 1, maxLength: 2000 } } (src/config/openapi/studio-v1.yaml:580).
*/
@Test
void rejectsAnEmptyCursor() {
ListCatalogUseCase useCase =
new ListCatalogUseCase(
portReturning(new CatalogPageView(List.of(), null)), new DirectTransactions());
assertThatThrownBy(
() -> useCase.handle(new ListCatalogQuery(CatalogEntryType.TOPIC, null, "", 20)))
.isInstanceOf(StudioException.class)
.hasMessageContaining("cursor");
}
/** Same contract field as {@link #rejectsAnEmptyCursor()}; upper bound instead of lower. */
@Test
void rejectsACursorLongerThanTheContractCeiling() {
ListCatalogUseCase useCase =
new ListCatalogUseCase(
portReturning(new CatalogPageView(List.of(), null)), new DirectTransactions());
String tooLong = "c".repeat(2001);
assertThatThrownBy(
() -> useCase.handle(new ListCatalogQuery(CatalogEntryType.TOPIC, null, tooLong, 20)))
.isInstanceOf(StudioException.class)
.hasMessageContaining("cursor");
}
/**
* {@code CleanArchitectureTest.USE_CASE_CAPABILITY_MATCHES_TRANSACTION_PORT_BOUNDARY}가
* READ_REPOSITORY+READ_ONLY use case에 {@code TransactionPort.inRead(...)} 직접 호출을 요구하므로, {@code
* ListCatalogUseCase}는 생성자에 {@link TransactionPort}를 받는다. 같은 모양의 fake는 {@code
* NotificationOperationsSnapshotUseCaseTest.TrackingTransactions}를 참고했다 — 여기서는 검증 없이 그대로 통과시키기만
* 하면 된다.
*/
private static final class DirectTransactions implements TransactionPort {
@Override
public <T> T inWrite(Supplier<T> action) {
return action.get();
}
@Override
public <T> T inRootWrite(Supplier<T> action) {
return action.get();
}
@Override
public <T> T inRead(Supplier<T> action) {
return action.get();
}
@Override
public <T> T inNew(Supplier<T> action) {
return action.get();
}
}
}
+2
View File
@@ -16,6 +16,8 @@ plugins {
id 'com.diffplug.spotless' version '8.6.0' apply false // D1 formatter (google-java-format)
id 'com.github.spotbugs' version '6.5.6' apply false // D3 bytecode bug finder (+ D4 FindSecBugs)
id 'net.ltgt.errorprone' version '5.1.0' apply false // D5 compile-time checker
// Task 4 — Studio 계약(studio-v1.yaml)에서 DTO만 생성한다(ADR-004/ADR-006).
id 'org.openapi.generator' version '7.18.0' apply false
}
// feature-build-release-supply-chain-contract D1/D9 — every archive carries an exact SemVer
+2
View File
@@ -0,0 +1,2 @@
# source: tech-log-design-package contracts/openapi/studio-v1.yaml @ b20d7a2 (feature/response-envelope-adr-006)
6cae9924403d0761f401643a022980b8e04183eea0d890c143c9fbbbbc7431e4 studio-v1.yaml
File diff suppressed because it is too large Load Diff
+24
View File
@@ -16,6 +16,14 @@
<Source name="~.*[\\/]generated[\\/].*"/>
</Match>
<!-- Task 4 — Studio 계약(studio-v1.yaml)에서 openapi-generator가 만드는 DTO는
손으로 고치지 않으므로 SpotBugs 대상이 아니다. 위 소스-경로 기반 제외
(build/generated/**)가 이미 넓게 걸리지만, 패키지 기준으로도 명시해 어떤
이유로 이 패키지를 뺐는지 분명히 남긴다. -->
<Match>
<Package name="~dev\.caskeleton\.adapter\.inbound\.web\.techlog\.studio\.api\.model.*"/>
</Match>
<!-- SPRING_CSRF_PROTECTION_DISABLED on the SecurityConfig classes is intentional: this is a
stateless JWT bearer-token API (no session cookies / no ambient cookie auth), so CSRF
protection is deliberately disabled per the security baseline. FindSecBugs flags it for
@@ -43,4 +51,20 @@
<Source name="DefaultTypingFixture.java"/>
</Match>
<!-- Task 9 — StudioSessionCsrfDisabledTest$SecurityTestConfig reproduces the JWT auth-mode's
csrf(csrf -> csrf.disable()) exactly as SecurityConfig.filterChain's JWT branch does, in
order to pin a real regression: CsrfTokenArgumentResolver (Spring Security 7.0.0) is
registered unconditionally by @EnableWebSecurity and casts the CsrfToken request
attribute without a null check, so when CSRF protection is off (no CsrfFilter, no
attribute) the controller's CsrfToken parameter is always null and
StudioSessionController.getStudioSession must report STUDIO_UNAVAILABLE rather than
crash. Removing csrf.disable() here would stop exercising that regression and defeat the
test's purpose. Scoped to the exact nested fixture class only (narrower than the outer
test class, since that is precisely where the finding is reported), same shape as the
GraphqlHttpBoundaryQualificationTest exception above. -->
<Match>
<Bug pattern="SPRING_CSRF_PROTECTION_DISABLED"/>
<Class name="dev.caskeleton.adapter.inbound.web.techlog.studio.controller.StudioSessionCsrfDisabledTest$SecurityTestConfig"/>
</Match>
</FindBugsFilter>