diff --git a/docs/registries/error-codes.yaml b/docs/registries/error-codes.yaml index 06c865d..fcd6ff7 100644 --- a/docs/registries/error-codes.yaml +++ b/docs/registries/error-codes.yaml @@ -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 diff --git a/docs/runbooks/auth-token-missing.md b/docs/runbooks/auth-token-missing.md index 78a74da..84099eb 100644 --- a/docs/runbooks/auth-token-missing.md +++ b/docs/runbooks/auth-token-missing.md @@ -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 diff --git a/docs/runbooks/authz-insufficient-permission.md b/docs/runbooks/authz-insufficient-permission.md index 5199ccb..0d4201c 100644 --- a/docs/runbooks/authz-insufficient-permission.md +++ b/docs/runbooks/authz-insufficient-permission.md @@ -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 diff --git a/docs/runbooks/studio-unavailable.md b/docs/runbooks/studio-unavailable.md new file mode 100644 index 0000000..ed765f8 --- /dev/null +++ b/docs/runbooks/studio-unavailable.md @@ -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]] diff --git a/docs/superpowers/plans/2026-08-18-techlog-studio-backend-01-foundation.md b/docs/superpowers/plans/2026-08-18-techlog-studio-backend-01-foundation.md new file mode 100644 index 0000000..05153ab --- /dev/null +++ b/docs/superpowers/plans/2026-08-18-techlog-studio-backend-01-foundation.md @@ -0,0 +1,2594 @@ +# Tech Log Studio Backend — Plan 01: Foundation & First Vertical + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Studio 계약을 응답 봉투 형태로 확정하고 세 저장소를 정합시킨 뒤, `getStudioSession`과 `listStudioCatalog`가 실제 백엔드에서 동작하게 만든다. + +**Architecture:** 계약(`studio-v1.yaml`)이 세 저장소의 SSOT다. 계약을 먼저 봉투로 재정의하고 → 프론트가 재생성·언랩하고 → 백엔드가 payload DTO만 생성해 얇은 controller로 서빙한다. 백엔드 코드는 새 Gradle leaf 없이 기존 18 leaf 안의 `techlog` 하위 패키지에 들어간다. 응답 봉투는 기존 `EnvelopeBodyAdvice`가 그대로 씌운다. + +**Tech Stack:** Java 21, Spring Boot 4.0.0, Gradle (dependency lock), Flyway, PostgreSQL, JPA + JdbcClient, openapi-generator (models only), ArchUnit, JUnit 5 / AssertJ · TypeScript, Vite, Vitest, openapi-typescript 7.9.1 · Python 3 (설계 패키지 검증 스크립트) + +**Spec:** `docs/superpowers/specs/2026-08-18-techlog-studio-backend-design.md` + +## Global Constraints + +- **저장소 3개.** 작업 순서는 `DP → FE → BE`로 고정한다. 계약이 SSOT이기 때문이다. + - `DP` = `/home/donghyeon/workspace/tech-log-design-package` (branch `master`) + - `FE` = `/home/donghyeon/workspace/desktop-server-git/tech-log-frontend` + - `BE` = `/home/donghyeon/workspace/desktop-server-git/tech-log-backend` (branch `feature/techlog-studio-backend`) +- **BE는 커밋하지 않는다.** `AGENTS.md:64` — commit 정책 `human-only`. BE 태스크의 마지막 스텝은 커밋이 아니라 "변경 파일 목록 보고"다. DP·FE는 커밋한다. +- **BE 패키지 루트를 바꾸지 않는다.** 신규 코드는 `dev.caskeleton.domain.techlog.*`, `dev.caskeleton.application.techlog.*`, `dev.caskeleton.adapter.outbound.persistence.techlog.*`, `dev.caskeleton.adapter.inbound.web.techlog.*`에 넣는다. `dev.caskeleton.techlog.*`처럼 루트를 벗어나면 ArchUnit 규칙(`CleanArchitectureTest.java:221`)이 미적용된다. +- **`src/config/architecture/modules.json`과 `src/settings.gradle`을 수정하지 않는다.** leaf 18개는 fail-closed 불변식이다. +- **`application-core`에 외부 의존을 추가하지 않는다.** `verifyApplicationCoreDependencyPurity`가 Spring·slf4j·logback·micrometer를 클래스패스에서 금지한다. 트랜잭션은 `@Transactional`이 아니라 `dev.caskeleton.application.transaction.TransactionPort`를 쓴다. +- **`CommandUseCase`/`QueryUseCase` 구현은 이름이 `UseCase`로 끝나야 하고 `@UseCaseCapability`를 선언해야 한다.** enum 값은 `TransactionMode.{WRITE,READ_ONLY}`, `Idempotency.{IDEMPOTENT,KEYED,NOT_IDEMPOTENT}`, `RepositoryAccess.{NONE,READ_REPOSITORY,WRITE_REPOSITORY}`. +- **템플릿 파일을 고치지 않는다.** 특히 `EnvelopeBodyAdvice.java`, `GlobalExceptionHandler.java`, `SecurityConfig.java`, `ErrorResponseFactory.java`. 확장은 새 클래스로 한다. +- **Flyway 마이그레이션은 `src/adapter/outbound/persistence-jpa/src/main/resources/db/migration/postgresql/` 에 `V7`부터 추가한다.** 기존 최대는 `V6`이고 `out-of-order: false`다. 적용된 마이그레이션은 절대 수정하지 않는다. +- **CSRF 헤더 이름은 `X-CSRF-TOKEN`이다.** 계약이 `const`로 고정한다. 템플릿 기본값은 `X-XSRF-TOKEN`이므로 설정으로 바꾼다. +- **계약 오류 코드 23개는 그대로 쓴다.** 새 코드를 발명하지 않는다. + +--- + +## File Structure + +### DP — 계약 (SSOT) + +| 파일 | 책임 | +| --- | --- | +| `contracts/openapi/studio-v1.yaml` | Studio HTTP 계약. 이번에 봉투 형태로 재정의 | +| `decisions/ADR-006-response-envelope.md` | 봉투 채택과 RFC 7807 미채택 근거 (신규) | +| `docs/specs/06-api-contract-design.md` | 7장 오류 계약을 봉투로 재작성 | +| `contracts/openapi/public-v1.yaml` | 배너만 추가 (변환은 구현 착수 시) | +| `contracts/openapi/studio-management-v1.yaml` | 배너만 추가 | +| `scripts/check-consistency.py` | `ProblemDetails.code` → `ApiError.code` 참조 변경 | +| `scripts/check-contract-parity.py` | 동일 | + +### FE — 소비자 + +| 파일 | 책임 | +| --- | --- | +| `src/features/tech-log/contracts/studio/{studio-api.openapi.yaml,generated.ts,canonical-source.json}` | 생성물. `pnpm generate:tech-log-contract`가 만든다 | +| `src/features/tech-log/contracts/tech-log-studio-contract-contribution.ts` | 봉투 언랩 validator 2개 | +| `src/features/tech-log/adapters/http/studio-error-mapping.ts` | `ApiError` → `StudioGatewayError` | + +### BE — 구현 + +| 파일 | 책임 | +| --- | --- | +| `src/config/openapi/studio-v1.yaml` | 생성 입력 (DP에서 vendor) | +| `src/adapter/inbound/web/build.gradle` | openapi-generator 배선 | +| `src/application-core/.../application/techlog/error/StudioError.java` | 23개 코드 enum (`ApiErrorCode` 구현) | +| `src/application-core/.../application/techlog/error/StudioException.java` | `ApiErrorCarrier` 예외 | +| `src/adapter/inbound/web/.../web/techlog/StudioExceptionHandler.java` | `StudioException` → 봉투 | +| `src/adapter/inbound/web/.../web/techlog/studio/controller/*.java` | 얇은 controller | +| `src/adapter/outbound/persistence-jpa/.../db/migration/postgresql/V7__techlog_core.sql` | Tech Log 스키마 | +| `src/app-bootstrap/src/test/.../architecture/TechLogBoundaryArchTest.java` | bounded context 경계 규칙 | +| `docs/registries/error-codes.yaml` | 23개 row 추가 | + +--- + +## Task 1: 계약을 봉투 형태로 재정의 (DP) + +**Files:** +- Create: `DP/decisions/ADR-006-response-envelope.md` +- Modify: `DP/contracts/openapi/studio-v1.yaml` + +**Interfaces:** +- Produces: `ErrorEnvelope`, `ApiError`, `ResponseMeta`, `ValidationErrorDetails`, `VersionConflictDetails`, `PublicationConflictDetails` 스키마와 15개 `Envelope` 래퍼. Task 3(FE)과 Task 4(BE)가 이 계약에서 타입을 생성한다. +- Removes: `ProblemDetails` 스키마. Task 2가 이를 참조하는 스크립트를 고친다. + +- [ ] **Step 1: 현재 상태를 기록해 둔다** + +```bash +cd /home/donghyeon/workspace/tech-log-design-package +git status --short +grep -c 'ProblemDetails' contracts/openapi/studio-v1.yaml +``` + +Expected: 워킹트리 clean, `ProblemDetails` 참조 17개 (스키마 정의 1 + 응답 16). + +- [ ] **Step 2: ADR-006을 쓴다** + +`DP/decisions/ADR-006-response-envelope.md`: + +```markdown +# ADR-006: 응답 봉투를 wire format으로 채택한다 + +- 상태: 채택 +- 날짜: 2026-08-18 +- 관련: ADR-004 (Contract First OpenAPI) + +## 맥락 + +Backend 구현 저장소 `tech-log-backend`는 `clean-architecture-backend-template` +스냅샷이며, 모든 JSON 응답을 봉투로 감싸는 것이 그 템플릿의 문서화된 결정이다. + +> `shared-contract/README.md` — "RFC 7807 ProblemDetail 을 대체한다(boundary D5/D6)", +> "D5 가 RFC 7807 ProblemDetail 을 거부하고, D10 이 `category`를 1급 필드로 추가했다" + +반면 이 설계 패키지의 `studio-v1.yaml`은 bare payload + RFC 7807 `ProblemDetails`를 +쓰고 있었다. 두 문서화된 결정이 충돌한다. + +## 결정 + +**Studio 계약의 wire format을 봉투로 통일한다.** + +```jsonc +// 성공 +{ "success": true, "data": { ... }, "meta": { "requestId": "...", "traceId": "...", "correlationId": null, "page": null } } +// 실패 (HTTP status는 그대로 의미를 갖는다) +{ "success": false, "error": { "code": "VERSION_CONFLICT", "category": "CONFLICT", + "message": "...", "retryable": false, "details": { ... } }, + "meta": { ... } } +``` + +미디어 타입은 성공·실패 모두 `application/json`이다. `application/problem+json`은 +쓰지 않는다. + +## 근거 + +1. **정보 손실이 없다.** `ProblemDetails`가 담던 것을 전부 옮길 수 있고, + `error.category`(10-value enum)가 덤으로 붙는다. `type`/`title`은 버리되 + Frontend가 이미 `code`에서 합성하고 있다. +2. **적응 코드의 위치.** 봉투를 벗기려면 Backend 템플릿 파일 + `EnvelopeBodyAdvice`를 고쳐야 하는데, 그 저장소는 tracked-snapshot이라 + 이후 모든 template sync의 충돌 지점이 된다. 봉투를 유지하면 적응이 Frontend + 제품 코드 안에서 끝나고 Frontend 플랫폼도 무변경이다. +3. **두 규약 모두 실무 표준이다.** RFC 9457은 IETF 표준이고 봉투는 1st-party SPA + 조합에서 널리 쓰인다. 어느 쪽도 틀리지 않으므로 변경 표면이 작은 쪽을 고른다. + +## 대가 + +- OpenAPI 계약이 봉투를 기술하게 되어 payload 스키마가 한 겹 안으로 들어간다. +- Backend는 생성 API interface를 쓸 수 없다. 생성 interface가 봉투 wrapper 타입을 + 반환하면 `EnvelopeBodyAdvice`가 한 번 더 감싸 이중 래핑이 된다. 따라서 Backend는 + **model만 생성하고 controller를 손으로 쓴다.** + +## 범위 + +이번 개정은 `studio-v1.yaml`에만 적용한다. `public-v1.yaml`과 +`studio-management-v1.yaml`은 소비자가 없으므로 배너만 붙이고 구현 착수 시 변환한다. +``` + +- [ ] **Step 3: 계약 버전을 올린다** + +봉투 도입은 breaking change다. + +```bash +cd /home/donghyeon/workspace/tech-log-design-package +sed -i 's/^ version: 2\.0\.0$/ version: 3.0.0/' contracts/openapi/studio-v1.yaml +grep -n '^ version:' contracts/openapi/studio-v1.yaml +``` + +Expected: `version: 3.0.0` + +- [ ] **Step 4: 오류 응답 16개를 봉투로 바꾼다** + +flow-style이라 한 줄 치환으로 끝난다. + +```bash +cd /home/donghyeon/workspace/tech-log-design-package +sed -i 's|application/problem+json: { schema: { \$ref: "#/components/schemas/ProblemDetails" } }|application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } }|g' contracts/openapi/studio-v1.yaml +grep -c 'ErrorEnvelope' contracts/openapi/studio-v1.yaml +grep -c 'application/problem+json' contracts/openapi/studio-v1.yaml +``` + +Expected: `ErrorEnvelope` 16개, `application/problem+json` 0개. + +- [ ] **Step 5: 성공 응답 18개를 래퍼로 바꾼다** + +`paths` 구간(1행 ~ `components:` 직전)에서, `requestBody`가 없는 줄의 payload +`$ref`만 `Envelope`로 바꾼다. requestBody는 봉투로 감싸지 않는다. + +```bash +cd /home/donghyeon/workspace/tech-log-design-package +python3 - <<'PY' +PATH_ = "contracts/openapi/studio-v1.yaml" +PAYLOADS = ["StudioSession","StudioDashboard","DocumentPage","WorkingCopyDetail","WorkingCopy", + "ValidationReport","PreviewDetail","PublicPreview","PublishResult","PublicationPage", + "PublicationSnapshot","CatalogPage","AssetPage","AssetDetail","Asset"] +lines = open(PATH_, encoding="utf-8").read().split("\n") +end = next(i for i, l in enumerate(lines) if l.startswith("components:")) +changed = 0 +for i in range(end): + line = lines[i] + if "requestBody" in line or "application/json" not in line: + continue + for p in PAYLOADS: # 긴 이름부터 매칭돼야 Asset이 AssetPage를 먹지 않는다 + old = f'"#/components/schemas/{p}"' + if old in line: + lines[i] = line.replace(old, f'"#/components/schemas/{p}Envelope"') + changed += 1 + break +open(PATH_, "w", encoding="utf-8").write("\n".join(lines)) +print("wrapped:", changed) +PY +``` + +Expected: `wrapped: 18` + +- [ ] **Step 6: 치환 결과를 눈으로 확인한다** + +```bash +cd /home/donghyeon/workspace/tech-log-design-package +grep -nE 'Envelope"' contracts/openapi/studio-v1.yaml | head -20 +grep -n 'requestBody' contracts/openapi/studio-v1.yaml | grep Envelope +``` + +Expected: 첫 명령은 18줄, 두 번째 명령은 **출력 없음**(requestBody가 감싸이지 않았다). + +- [ ] **Step 7: 봉투 스키마를 추가하고 `ProblemDetails`를 제거한다** + +`components.schemas`의 맨 앞(`StudioSession:` 바로 위)에 아래를 넣는다. + +```yaml + # ------------------------------------------------------------- envelope + # wire format은 봉투다 (ADR-006). payload 스키마는 그대로 두고 + # 응답만 Envelope으로 감싼다. + ResponseMeta: + type: object + additionalProperties: false + required: [requestId, traceId] + properties: + requestId: { type: string, minLength: 1, maxLength: 200 } + traceId: { type: string, minLength: 1, maxLength: 200 } + correlationId: { type: [string, "null"], maxLength: 200 } + page: { type: "null", description: Studio는 body 안 cursor 페이지네이션을 쓰므로 항상 null이다 } + ApiError: + type: object + additionalProperties: false + required: [code, category, message, retryable] + properties: + code: + type: string + enum: [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] + category: + type: string + enum: [VALIDATION, AUTH, AUTHZ, NOT_FOUND, CONFLICT, RATE_LIMIT, + TRANSIENT_DEPENDENCY, PERMANENT_DEPENDENCY, DATA_INTEGRITY, INTERNAL] + message: { type: string, minLength: 1, maxLength: 5000 } + retryable: { type: boolean } + details: + oneOf: + - $ref: "#/components/schemas/ValidationErrorDetails" + - $ref: "#/components/schemas/VersionConflictDetails" + - $ref: "#/components/schemas/PublicationConflictDetails" + - type: "null" + ErrorEnvelope: + type: object + additionalProperties: false + required: [success, error, meta] + properties: + success: { type: boolean, const: false } + error: { $ref: "#/components/schemas/ApiError" } + meta: { $ref: "#/components/schemas/ResponseMeta" } + ValidationErrorDetails: + type: object + additionalProperties: false + required: [fieldErrors] + properties: + fieldErrors: { type: array, maxItems: 200, items: { $ref: "#/components/schemas/FieldError" } } + VersionConflictDetails: + type: object + additionalProperties: false + required: [latestDocument] + properties: + latestDocument: { $ref: "#/components/schemas/WorkingCopyDetail" } + conflictingFields: + type: array + uniqueItems: true + maxItems: 200 + items: { type: string, pattern: "^(?:/(?:[^~/]|~0|~1)*)*$" } + PublicationConflictDetails: + type: object + additionalProperties: false + required: [latestPublication] + properties: + latestPublication: { $ref: "#/components/schemas/PublicationAggregate" } +``` + +이어서 15개 래퍼를 같은 위치에 넣는다. ``는 Step 5의 `PAYLOADS` 목록과 같다. + +```yaml + StudioSessionEnvelope: + type: object + additionalProperties: false + required: [success, data, meta] + properties: + success: { type: boolean, const: true } + data: { $ref: "#/components/schemas/StudioSession" } + meta: { $ref: "#/components/schemas/ResponseMeta" } +``` + +나머지 14개(`StudioDashboardEnvelope`, `DocumentPageEnvelope`, `WorkingCopyEnvelope`, +`WorkingCopyDetailEnvelope`, `ValidationReportEnvelope`, `PreviewDetailEnvelope`, +`PublicPreviewEnvelope`, `PublishResultEnvelope`, `PublicationPageEnvelope`, +`PublicationSnapshotEnvelope`, `CatalogPageEnvelope`, `AssetPageEnvelope`, +`AssetEnvelope`, `AssetDetailEnvelope`)도 `data`의 `$ref`만 바꿔 동일하게 쓴다. + +그리고 기존 `ProblemDetails:` 스키마 블록 전체를 삭제한다. + +- [ ] **Step 8: 계약이 파싱되고 참조가 다 풀리는지 확인한다** + +```bash +cd /home/donghyeon/workspace/tech-log-design-package +./scripts/check-openapi.py contracts/openapi/studio-v1.yaml +``` + +Expected: PASS. `$ref` 미해결이나 operationId 중복이 없어야 한다. 실패하면 오타난 +래퍼 이름을 고친다. + +- [ ] **Step 9: 봉투 구조를 프로그램으로 검산한다** + +```bash +cd /home/donghyeon/workspace/tech-log-design-package +python3 - <<'PY' +import yaml +d = yaml.safe_load(open("contracts/openapi/studio-v1.yaml", encoding="utf-8")) +s, M = d["components"]["schemas"], {"get","put","post","delete","patch"} +assert "ProblemDetails" not in s, "ProblemDetails가 남아 있다" +assert len(s["ApiError"]["properties"]["code"]["enum"]) == 23 +bad = [] +for p, item in d["paths"].items(): + for m, op in item.items(): + if m not in M: continue + for code, r in op["responses"].items(): + ct = r.get("content") + if not ct: continue + ref = ct["application/json"]["schema"]["$ref"].rsplit("/", 1)[-1] + if not ref.endswith("Envelope"): bad.append(f"{op['operationId']} {code} {ref}") +for name, r in d["components"]["responses"].items(): + ref = r["content"]["application/json"]["schema"]["$ref"].rsplit("/", 1)[-1] + if ref != "ErrorEnvelope": bad.append(f"responses.{name} {ref}") +print("FAIL:", bad) if bad else print("OK: 모든 응답이 봉투다") +PY +``` + +Expected: `OK: 모든 응답이 봉투다` + +- [ ] **Step 10: 커밋** + +```bash +cd /home/donghyeon/workspace/tech-log-design-package +git add contracts/openapi/studio-v1.yaml decisions/ADR-006-response-envelope.md +git commit -m "contract: Studio 응답을 봉투 형태로 재정의하고 ADR-006 기록" +``` + +--- + +## Task 2: 설계 패키지 정합 복구 (DP) + +Task 1이 `ProblemDetails`를 제거했으므로 이를 참조하는 스크립트와 문서가 깨져 있다. + +**Files:** +- Modify: `DP/scripts/check-consistency.py:179`, `:262` +- Modify: `DP/scripts/check-contract-parity.py` (오류 코드 비교 블록) +- Modify: `DP/docs/specs/06-api-contract-design.md` (7장) +- Modify: `DP/contracts/openapi/public-v1.yaml`, `DP/contracts/openapi/studio-management-v1.yaml` (배너) +- Modify: `DP/TECH_LOG_MASTER_SPEC.md`, `DP/MANIFEST.sha256` (재생성) + +**Interfaces:** +- Consumes: Task 1의 `ApiError.code` enum +- Produces: 통과하는 검증 스크립트 4종. Task 3(FE)이 `check-contract-parity.py`로 자기 변경을 검증한다. + +- [ ] **Step 1: 깨진 것을 먼저 확인한다** + +```bash +cd /home/donghyeon/workspace/tech-log-design-package +./scripts/check-consistency.py; echo "exit=$?" +./scripts/check-contract-parity.py; echo "exit=$?" +``` + +Expected: 둘 다 `KeyError: 'ProblemDetails'` 또는 non-zero exit. + +- [ ] **Step 2: `check-consistency.py`의 참조를 옮긴다** + +`179`행 부근: + +```python +codes = set(S["ProblemDetails"]["properties"]["code"]["enum"]) +``` + +를 + +```python +codes = set(S["ApiError"]["properties"]["code"]["enum"]) +``` + +로 바꾸고, `262`행 부근의 + +```python +fe_codes = set(fe["components"]["schemas"]["ProblemDetails"]["properties"]["code"]["enum"]) +``` + +를 + +```python +fe_codes = set(fe["components"]["schemas"]["ApiError"]["properties"]["code"]["enum"]) +``` + +로 바꾼다. + +- [ ] **Step 3: `check-contract-parity.py`의 참조를 옮긴다** + +`ProblemDetails`를 읽는 두 줄(FE/BE 오류 코드 비교)을 `ApiError`로 바꾼다. + +```bash +cd /home/donghyeon/workspace/tech-log-design-package +sed -i 's/\["ProblemDetails"\]/["ApiError"]/g' scripts/check-contract-parity.py scripts/check-consistency.py +grep -n 'ApiError' scripts/check-contract-parity.py scripts/check-consistency.py +``` + +Expected: 각 파일에서 치환된 줄이 보이고 `ProblemDetails` 잔재가 없다. + +- [ ] **Step 4: `check-consistency.py`가 통과하는지 본다** + +```bash +cd /home/donghyeon/workspace/tech-log-design-package +./scripts/check-consistency.py; echo "exit=$?" +``` + +Expected: `exit=0`. `check-contract-parity.py`는 FE가 아직 재생성 전이라 이 시점에 +실패할 수 있다 — Task 3에서 통과시킨다. + +- [ ] **Step 5: 06장 오류 계약을 봉투로 재작성한다** + +`DP/docs/specs/06-api-contract-design.md`의 `## 7. 오류 계약` 절 본문을 아래로 바꾼다. +7.1~7.4 하위 절의 내용(HTTP 상태, 코드 목록, 혼동 금지, 충돌 부가 정보)은 유지하되 +표현 매체만 봉투로 옮긴다. + +```markdown +## 7. 오류 계약 + +wire format은 봉투다 (ADR-006). `application/problem+json`과 RFC 7807은 쓰지 않는다. + +```jsonc +{ + "success": false, + "error": { + "code": "VERSION_CONFLICT", + "category": "CONFLICT", + "message": "저장된 version이 더 최신입니다", + "retryable": false, + "details": { "latestDocument": { }, "conflictingFields": ["/title"] } + }, + "meta": { "requestId": "...", "traceId": "...", "correlationId": null, "page": null } +} +``` + +HTTP status는 그대로 의미를 갖는다. `success: false`는 status를 대체하지 않고 중복 +표기한다. + +`error.category`는 10개 값이며 클라이언트가 23개 코드를 전부 열거하지 않고도 굵게 +분기할 수 있게 한다. + +```text +VALIDATION AUTH AUTHZ NOT_FOUND CONFLICT RATE_LIMIT +TRANSIENT_DEPENDENCY PERMANENT_DEPENDENCY DATA_INTEGRITY INTERNAL +``` + +`error.details`는 code별 polymorphic이며 계약에서 `oneOf`로 선언한다. + +```text +REQUEST_VALIDATION_FAILED / VALIDATION_FAILED → ValidationErrorDetails fieldErrors[] +VERSION_CONFLICT → VersionConflictDetails latestDocument, conflictingFields[] +PUBLICATION_CONFLICT → PublicationConflictDetails latestPublication +그 외 → null +``` + +`traceId`는 `meta.traceId`에 있으며 응답에서 절대 null이 아니다. +``` + +- [ ] **Step 6: 나머지 두 계약에 배너를 붙인다** + +`public-v1.yaml`과 `studio-management-v1.yaml`의 `info.description` 맨 앞에 넣는다. + +```text +⛔ 봉투 결정(ADR-006) 반영 대기 — 이 계약은 아직 bare payload + ProblemDetails다. +소비자가 없어 변환을 미뤘다. 구현에 착수할 때 studio-v1.yaml과 같은 방식으로 +ErrorEnvelope / Envelope으로 변환한다. +``` + +- [ ] **Step 7: MASTER_SPEC과 MANIFEST를 재생성한다** + +```bash +cd /home/donghyeon/workspace/tech-log-design-package +./scripts/build-master-spec.sh +./scripts/update-manifest.sh +./scripts/build-master-spec.sh --check; echo "master=$?" +./scripts/update-manifest.sh --check; echo "manifest=$?" +``` + +Expected: 둘 다 `=0`. + +- [ ] **Step 8: 커밋** + +```bash +cd /home/donghyeon/workspace/tech-log-design-package +git add scripts/ docs/specs/06-api-contract-design.md contracts/openapi/ TECH_LOG_MASTER_SPEC.md MANIFEST.sha256 +git commit -m "spec/scripts: 봉투 결정에 맞춰 오류 계약과 검증 스크립트 정합" +``` + +--- + +## Task 3: 프론트 계약 재생성과 봉투 언랩 (FE) + +**Files:** +- Modify: `FE/src/features/tech-log/contracts/studio/studio-api.openapi.yaml` (생성물) +- Modify: `FE/src/features/tech-log/contracts/studio/generated.ts` (생성물) +- Modify: `FE/src/features/tech-log/contracts/studio/canonical-source.json` (생성물) +- Modify: `FE/src/features/tech-log/contracts/tech-log-studio-contract-contribution.ts` +- Modify: `FE/src/features/tech-log/adapters/http/studio-error-mapping.ts` + +**Interfaces:** +- Consumes: Task 1의 `studio-v1.yaml` v3.0.0 +- Produces: 봉투를 언랩하는 `envelopeData` / `envelopeError` validator. 앱·도메인 계층은 기존과 같은 payload 타입을 계속 받는다 — `StudioGateway` 포트 시그니처는 바뀌지 않는다. + +- [ ] **Step 1: 작업 브랜치를 만든다** + +현재 브랜치 `fix/techlog-alignment-followups`가 최신 작업 상태이므로 그 위에서 딴다. + +```bash +cd /home/donghyeon/workspace/desktop-server-git/tech-log-frontend +git status --short +git checkout -b feature/studio-response-envelope +``` + +Expected: 새 브랜치로 전환. 워킹트리가 더러우면 먼저 사용자에게 보고하고 멈춘다. + +- [ ] **Step 2: 드리프트 게이트가 지금은 통과하는지 확인한다 (기준선)** + +```bash +cd /home/donghyeon/workspace/desktop-server-git/tech-log-frontend +corepack pnpm check:tech-log-contract; echo "exit=$?" +``` + +Expected: `exit=0` (아직 vendor된 구 계약과 digest가 맞는다). + +- [ ] **Step 3: 계약을 재생성한다** + +생성기는 `$TECH_LOG_DESIGN_PACKAGE/contracts/openapi/studio-v1.yaml`을 읽고 +`pnpm dlx openapi-typescript@7.9.1`로 타입을 만든다. + +```bash +cd /home/donghyeon/workspace/desktop-server-git/tech-log-frontend +corepack pnpm generate:tech-log-contract +git diff --stat src/features/tech-log/contracts/studio/ +``` + +Expected: 세 파일 모두 변경. `pnpm dlx`가 네트워크를 쓰므로 실패하면 오프라인이 +원인이다 — 그 경우 사용자에게 보고하고 멈춘다(수기 편집으로 우회하지 않는다. +`--check`가 digest로 잡아낸다). + +- [ ] **Step 4: 생성 타입에 봉투가 들어왔는지 확인한다** + +```bash +cd /home/donghyeon/workspace/desktop-server-git/tech-log-frontend +grep -c 'Envelope' src/features/tech-log/contracts/studio/generated.ts +grep -c 'ProblemDetails' src/features/tech-log/contracts/studio/generated.ts +``` + +Expected: `Envelope` 다수, `ProblemDetails` 0. + +- [ ] **Step 5: 언랩 validator의 실패 테스트를 쓴다** + +`FE/tests/features/tech-log/studio-envelope-unwrap.test.ts`: + +```ts +import { describe, expect, it } from "vitest"; +import { envelopeData, envelopeError } from "../../../src/features/tech-log/contracts/tech-log-studio-contract-contribution.ts"; + +describe("studio 봉투 언랩", () => { + it("성공 봉투에서 data를 꺼낸다", () => { + const result = envelopeData("getStudioSessionOutput").safeParse({ + success: true, + data: { authenticated: true, displayName: "d", roles: [], csrfToken: "t", csrfHeaderName: "X-CSRF-TOKEN" }, + meta: { requestId: "r", traceId: "t", correlationId: null, page: null }, + }); + expect(result.success).toBe(true); + if (result.success) expect(result.data).toMatchObject({ displayName: "d" }); + }); + + it("봉투가 아닌 본문을 거절한다", () => { + const result = envelopeData("getStudioSessionOutput").safeParse({ displayName: "d" }); + expect(result.success).toBe(false); + }); + + it("오류 봉투를 ProblemDetails 형태로 옮긴다", () => { + const result = envelopeError().safeParse({ + success: false, + error: { code: "VERSION_CONFLICT", category: "CONFLICT", message: "conflict", retryable: false, details: null }, + meta: { requestId: "r", traceId: "tr", correlationId: null, page: null }, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.code).toBe("VERSION_CONFLICT"); + expect(result.data.status).toBe(0); + expect(result.data.title).toBe("VERSION_CONFLICT"); + } + }); + + it("계약 밖 코드를 거절한다", () => { + const result = envelopeError().safeParse({ + success: false, + error: { code: "NOT_A_STUDIO_CODE", category: "INTERNAL", message: "x", retryable: false, details: null }, + meta: { requestId: "r", traceId: "tr", correlationId: null, page: null }, + }); + expect(result.success).toBe(false); + }); +}); +``` + +- [ ] **Step 6: 테스트를 돌려 실패를 확인한다** + +```bash +cd /home/donghyeon/workspace/desktop-server-git/tech-log-frontend +corepack pnpm vitest run tests/features/tech-log/studio-envelope-unwrap.test.ts +``` + +Expected: FAIL — `envelopeData`/`envelopeError` export가 없다. + +- [ ] **Step 7: validator를 구현한다** + +`FE/src/features/tech-log/contracts/tech-log-studio-contract-contribution.ts`에서 +`passthrough`/`problemSchema`/`PROBLEM` 정의부를 아래로 교체한다. + +```ts +/** + * wire format은 봉투다 (ADR-006). 전송 계층은 봉투 뼈대만 검증하고 payload는 + * 통과시킨다 — generated 타입이 컴파일 시점 계약이고, 런타임 재검증은 계약 갱신 + * 때마다 두 곳을 고치게 만든다. 다만 봉투 자체는 반드시 검증한다: 여기서 통과시키면 + * 잘못된 모양이 앱 계층까지 조용히 흘러간다. + */ +const metaSchema = z + .object({ requestId: z.string().min(1), traceId: z.string().min(1) }) + .loose(); + +export const envelopeData = (schemaId: string): RuntimeValidator => + zodValidator( + schemaId, + z + .object({ success: z.literal(true), data: z.unknown(), meta: metaSchema }) + .loose() + .transform((envelope) => envelope.data as T) as unknown as z.ZodType, + ); + +const apiErrorSchema = z + .object({ + code: z.enum(STUDIO_ERROR_CODES as unknown as [string, ...string[]]), + category: z.string().min(1), + message: z.string().min(1).max(5000), + retryable: z.boolean(), + }) + .loose(); + +/** + * 봉투 오류를 기존 ProblemDetails 형태로 옮긴다. 앱 계층(`StudioGatewayError`)은 + * 그 모양을 계속 쓰므로 매핑을 여기서 끝내면 아래 계층이 무변경이다. + * `status`는 봉투에 없다 — 전송 계층이 실제 HTTP status를 따로 들고 있으므로 + * 0으로 두고 `toStudioGatewayError`가 outcome의 status로 덮는다. + */ +export const envelopeError = (): RuntimeValidator => + zodValidator( + "StudioErrorEnvelope", + z + .object({ success: z.literal(false), error: apiErrorSchema, meta: metaSchema }) + .loose() + .transform((envelope) => ({ + type: `https://techlog.local/problems/${envelope.error.code.toLowerCase().replaceAll("_", "-")}`, + title: envelope.error.code, + status: 0, + detail: envelope.error.message, + code: envelope.error.code, + retryable: envelope.error.retryable, + category: envelope.error.category, + details: (envelope.error as { details?: unknown }).details ?? null, + })) as unknown as z.ZodType, + ); + +export type StudioProblemShape = Readonly<{ + type: string; + title: string; + status: number; + detail: string; + code: string; + retryable: boolean; + category: string; + details: unknown; +}>; + +const PROBLEM = envelopeError(); +``` + +그리고 `safeOperation`과 mutating operation 정의의 + +```ts + outputValidator: passthrough(`${operationId}Output`), +``` + +을 전부 + +```ts + outputValidator: envelopeData(`${operationId}Output`), +``` + +로 바꾼다. `inputValidator`는 요청 본문이라 그대로 `passthrough`를 쓴다. + +- [ ] **Step 8: `status`를 실제 HTTP status로 덮는다** + +`FE/src/features/tech-log/adapters/http/studio-error-mapping.ts`의 `PROBLEM` 분기에서 +`outcome.problem`의 `status`가 0이면 전송 계층이 아는 status로 채운다. + +```ts + case "PROBLEM": { + const problem = outcome.problem as ProblemDetails; + const status = problem.status === 0 + ? (outcome.metadata?.httpStatus ?? 0) + : problem.status; + if (!CODES.has(problem.code)) { + return synthetic("STUDIO_UNAVAILABLE", status, problem.detail, true); + } + return new StudioGatewayError({ ...problem, status }); + } +``` + +`outcome.metadata`의 status 필드명이 다르면 `src/adapters/http/http-execution-v3.ts`의 +`SafeResponseMetadata` 정의를 읽어 맞춘다. + +- [ ] **Step 9: 테스트를 돌려 통과를 확인한다** + +```bash +cd /home/donghyeon/workspace/desktop-server-git/tech-log-frontend +corepack pnpm vitest run tests/features/tech-log/studio-envelope-unwrap.test.ts +``` + +Expected: 4개 PASS. + +- [ ] **Step 10: tech-log 전체 테스트와 드리프트 게이트를 돌린다** + +```bash +cd /home/donghyeon/workspace/desktop-server-git/tech-log-frontend +corepack pnpm check:tech-log-contract; echo "contract=$?" +corepack pnpm test:tech-log; echo "tests=$?" +corepack pnpm exec tsc -p tsconfig.app.json --noEmit; echo "types=$?" +``` + +Expected: 셋 다 `=0`. mock 게이트웨이는 전송 경계 위에 있어 영향받지 않아야 한다. +깨지면 그 테스트가 HTTP 본문을 직접 만들고 있는 것이므로 봉투로 감싸 고친다. + +- [ ] **Step 11: 계약 parity를 확인한다** + +```bash +cd /home/donghyeon/workspace/tech-log-design-package +./scripts/check-contract-parity.py; echo "exit=$?" +``` + +Expected: `exit=0`, operation 19/19 일치, 오류 코드 23/23 보존. + +- [ ] **Step 12: 커밋** + +```bash +cd /home/donghyeon/workspace/desktop-server-git/tech-log-frontend +git add src/features/tech-log tests/features/tech-log/studio-envelope-unwrap.test.ts +git commit -m "feat: Studio 응답 봉투를 전송 경계에서 언랩한다" +``` + +--- + +## Task 4: 백엔드 계약 vendor와 model 생성 배선 (BE) + +**Files:** +- Create: `BE/src/config/openapi/studio-v1.yaml` +- Create: `BE/src/config/openapi/MANIFEST.sha256` +- Modify: `BE/src/adapter/inbound/web/build.gradle` +- Modify: `BE/src/adapter/inbound/web/gradle.lockfile` (재생성) + +**Interfaces:** +- Produces: `dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.*` 패키지의 생성 DTO. Task 8·9의 controller가 이 타입을 반환한다. + +- [ ] **Step 1: 브랜치와 워킹트리를 확인한다** + +```bash +cd /home/donghyeon/workspace/desktop-server-git/tech-log-backend +git branch --show-current +git status --short | grep -v '^ D ' | head +``` + +Expected: `feature/techlog-studio-backend`. 미커밋 삭제분(`*-superpowers-package/`, +`scripts/verify-httpclient-docs.py`)은 이미 있던 것이므로 건드리지 않는다. + +- [ ] **Step 2: 계약을 vendor하고 해시를 기록한다** + +```bash +cd /home/donghyeon/workspace/desktop-server-git/tech-log-backend +mkdir -p src/config/openapi +cp /home/donghyeon/workspace/tech-log-design-package/contracts/openapi/studio-v1.yaml src/config/openapi/studio-v1.yaml +( cd src/config/openapi && sha256sum studio-v1.yaml > MANIFEST.sha256 ) +cat src/config/openapi/MANIFEST.sha256 +``` + +Expected: ` studio-v1.yaml` 한 줄. + +> **2026-08-18 실측 — 이 스파이크는 실패했고 계약을 고쳐 해소했다.** +> +> 1. `generateApis` / `generateModels`는 openapi-generator-gradle-plugin 7.18.0에 +> **존재하지 않는 속성**이다. 설정하면 Gradle 평가에서 +> `Could not set unknown property 'generateApis'`로 죽는다. 실제 API는 +> `globalProperties`이고 `['models': '']`가 "model만 생성"을 뜻한다. +> 2. 올바른 설정으로도 생성이 NPE로 죽는다 — +> `DefaultCodegen.setEnumDiscriminatorDefaultValue`에서 +> `"var.allowableValues" is null` (model `WorkingCopyInput` 처리 중). +> 계약의 5개 discriminator `oneOf` union이 하위 타입의 discriminator 필드를 +> OpenAPI 3.1 관용구인 `const`로 좁히기 때문이다. +> `legacyDiscriminatorBehavior: false`로도 동일하게 실패한다. 네트워크 문제 아님. +> 3. **해소:** discriminator로 쓰이는 필드의 `const: X`를 의미가 같은 단일값 +> `enum: [X]`로 바꾼다. `discriminator` 키워드는 3.1의 `const`보다 앞서 만들어졌고 +> 생태계 관용구가 단일값 `enum`이라, 우회가 아니라 계약을 표준 관용구에 맞추는 것이다. +> 봉투 래퍼의 `success: { const: true|false }` 16개와 `csrfHeaderName`의 `const`는 +> discriminator가 아니므로 **그대로 둔다.** +> 4. spec §5.2의 "1회 생성 후 커밋" 폴백은 **쓸 수 없다** — 생성이 한 번은 성공한다는 +> 전제인데 생성 자체가 실패하기 때문이다. + +- [ ] **Step 3: 생성기 조합을 폐기용 스파이크로 검증한다** + +openapi-generator 7.x × Spring Boot 4.0.0 조합은 이 저장소에서 검증된 적이 없다. +본 배선 전에 별도 디렉터리에서 먼저 돌려본다. + +```bash +cd /tmp/claude-1000/-home-donghyeon-workspace-tech-log-design-package/e90b7626-9073-4d69-acc3-41bfc22d3e83/scratchpad +mkdir -p genspike && cd genspike +cat > build.gradle <<'EOF' +plugins { id 'java'; id 'org.openapi.generator' version '7.18.0' } +repositories { mavenCentral() } +openApiGenerate { + generatorName = 'spring' + inputSpec = '/home/donghyeon/workspace/desktop-server-git/tech-log-backend/src/config/openapi/studio-v1.yaml' + outputDir = "$projectDir/out".toString() + apiPackage = 'spike.api' + modelPackage = 'spike.model' + globalProperties.set(['models': '']) // model만 생성 (generateApis/generateModels는 존재하지 않는 속성) + configOptions = [useSpringBoot3: 'true', useJakartaEe: 'true', openApiNullable: 'true'] +} +EOF +cat > settings.gradle <<'EOF' +rootProject.name = 'genspike' +EOF +/home/donghyeon/workspace/desktop-server-git/tech-log-backend/src/gradlew --project-dir . openApiGenerate --console=plain 2>&1 | tail -20 +ls out/src/main/java/spike/model | head +ls out/src/main/java/spike/model | wc -l +``` + +Expected: BUILD SUCCESSFUL, `spike/model`에 100개 안팎의 `.java`. 실패하면 +**여기서 멈추고** 보고한다. 위 실측 노트가 이미 원인과 해소를 담고 있다. +이 디렉터리는 폐기물이며 저장소에 남기지 않는다. + +- [ ] **Step 4: web 모듈에 생성기를 배선한다** + +`BE/src/adapter/inbound/web/build.gradle` 맨 위 `plugins` 블록이 없으면 파일 첫 줄에 추가하고, +파일 끝에 아래를 붙인다. + +```groovy +// --------------------------------------------------------------------------- +// Studio 계약 DTO 생성 (ADR-004 / ADR-006). +// generateApis=false: 계약이 봉투를 기술하므로 생성 API interface는 봉투 wrapper +// 타입을 반환하게 되고, 그 타입은 dev.caskeleton.shared.response.Envelope가 아니라서 +// EnvelopeBodyAdvice가 한 번 더 감싼다(이중 래핑). controller는 손으로 쓴다. +// --------------------------------------------------------------------------- +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': '']) // model만 생성 (generateApis/generateModels는 존재하지 않는 속성) + generateModelTests = false + generateModelDocumentation = false + generateSupportingFiles = false + configOptions = [useSpringBoot3: 'true', useJakartaEe: 'true', openApiNullable: 'true'] +} + +sourceSets.main.java.srcDir( + layout.buildDirectory.dir('generated/openapi/src/main/java')) + +tasks.named('compileJava') { dependsOn 'openApiGenerate' } + +// 생성 코드는 품질 게이트 대상이 아니다. 단 기존 web 코드의 게이트는 유지한다 — +// SpotBugs를 모듈 전체에서 끄면 손으로 쓴 controller도 검사받지 않는다. +tasks.matching { it.name.startsWith('spotless') }.configureEach { + dependsOn 'openApiGenerate' +} +spotless { java { targetExclude('build/generated/**') } } +tasks.withType(com.github.spotbugs.snom.SpotBugsTask).configureEach { + dependsOn 'openApiGenerate' + excludeFilter = file("${rootDir}/config/spotbugs/generated-openapi-exclude.xml") +} +``` + +`BE/src/config/spotbugs/generated-openapi-exclude.xml`: + +```xml + + + + + + + +``` + +기존 `config/spotbugs/`에 이미 exclude filter가 있으면 새 파일을 만들지 말고 그 파일에 +위 `` 블록만 추가한다. + +```bash +ls /home/donghyeon/workspace/desktop-server-git/tech-log-backend/src/config/spotbugs/ +``` + +플러그인 선언은 `BE/src/build.gradle`의 루트 `plugins` 블록에 추가한다. + +```groovy + id 'org.openapi.generator' version '7.18.0' apply false +``` + +그리고 `BE/src/adapter/inbound/web/build.gradle` 첫 줄에 + +```groovy +plugins { id 'org.openapi.generator' } +``` + +- [ ] **Step 5: 생성과 컴파일을 확인한다** + +```bash +cd /home/donghyeon/workspace/desktop-server-git/tech-log-backend/src +./gradlew :adapter:inbound:web:openApiGenerate --console=plain +ls build/../adapter/inbound/web/build/generated/openapi/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/api/model | wc -l +./gradlew :adapter:inbound:web:compileJava --console=plain +``` + +Expected: 생성 파일 100개 안팎, `BUILD SUCCESSFUL`. + +기존 web 코드의 품질 게이트가 살아 있는지 확인한다. 아래가 통과해야 exclude filter가 +생성 패키지만 좁게 뺀 것이다. + +```bash +./gradlew :adapter:inbound:web:check --console=plain 2>&1 | tail -20 +``` + +- [ ] **Step 6: 의존 lock을 재생성한다** + +```bash +cd /home/donghyeon/workspace/desktop-server-git/tech-log-backend/src +./gradlew :adapter:inbound:web:dependencies --write-locks --console=plain > /dev/null +./gradlew :adapter:inbound:web:verifyDependencyLocks --console=plain +git diff --stat adapter/inbound/web/gradle.lockfile +``` + +Expected: `verifyDependencyLocks` 통과, lockfile 변경 있음. + +- [ ] **Step 7: 아키텍처 검증이 여전히 통과하는지 본다** + +```bash +cd /home/donghyeon/workspace/desktop-server-git/tech-log-backend/src +./gradlew verifyCleanArchitectureDependencies --console=plain +./gradlew :app-bootstrap:test --tests '*CleanArchitectureTest' --console=plain +``` + +Expected: 둘 다 PASS. + +- [ ] **Step 8: 변경 파일을 보고한다 (커밋하지 않는다)** + +```bash +cd /home/donghyeon/workspace/desktop-server-git/tech-log-backend +git status --short | grep -v '^ D ' +``` + +`AGENTS.md:64`에 따라 stage/commit/push하지 않는다. 목록만 사용자에게 보고한다. + +--- + +## Task 5: Studio 오류 코드와 예외 매핑 (BE) + +**Files:** +- Create: `BE/src/application-core/src/main/java/dev/caskeleton/application/techlog/error/StudioError.java` +- Create: `BE/src/application-core/src/main/java/dev/caskeleton/application/techlog/error/StudioException.java` +- Create: `BE/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/StudioExceptionHandler.java` +- Test: `BE/src/application-core/src/test/java/dev/caskeleton/application/techlog/error/StudioErrorTest.java` +- Test: `BE/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/techlog/StudioExceptionHandlerTest.java` +- Modify: `BE/docs/registries/error-codes.yaml` + +**Interfaces:** +- Produces: `StudioError`(enum, `ApiErrorCode` 구현) — Task 8·9와 이후 모든 슬라이스가 이 코드로 실패를 표현한다. `StudioException.of(StudioError, String)` / `withDetails(StudioError, String, Object)`. + +- [ ] **Step 1: `StudioError` 실패 테스트를 쓴다** + +`StudioErrorTest.java`: + +```java +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 { + + @Test + void declaresExactlyTheTwentyThreeContractCodes() { + 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(); + } +} +``` + +- [ ] **Step 2: 테스트를 돌려 실패를 확인한다** + +```bash +cd /home/donghyeon/workspace/desktop-server-git/tech-log-backend/src +./gradlew :application-core:test --tests '*StudioErrorTest' --console=plain +``` + +Expected: 컴파일 실패 — `StudioError` 없음. + +- [ ] **Step 3: `StudioError`를 구현한다** + +```java +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), + 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; + } +} +``` + +- [ ] **Step 4: 테스트를 돌려 통과를 확인한다** + +```bash +cd /home/donghyeon/workspace/desktop-server-git/tech-log-backend/src +./gradlew :application-core:test --tests '*StudioErrorTest' --console=plain +``` + +Expected: 4개 PASS. + +- [ ] **Step 5: `StudioException`을 만든다** + +```java +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를 알 필요가 없다. + * + *

{@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; + } +} +``` + +- [ ] **Step 6: 전송 매핑 실패 테스트를 쓴다** + +`StudioExceptionHandlerTest.java`: + +```java +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> response = + handler.handleStudio(StudioException.of(StudioError.DOCUMENT_NOT_FOUND, "없음")); + + assertThat(response.getStatusCode().value()).isEqualTo(404); + Envelope 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(); + } + + @Test + void carriesDetailsForConflicts() { + ResponseEntity> 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(); + } +} +``` + +- [ ] **Step 7: 테스트를 돌려 실패를 확인한다** + +```bash +cd /home/donghyeon/workspace/desktop-server-git/tech-log-backend/src +./gradlew :adapter:inbound:web:test --tests '*StudioExceptionHandlerTest' --console=plain +``` + +Expected: 컴파일 실패 — `StudioExceptionHandler` 없음. + +- [ ] **Step 8: 핸들러를 구현한다** + +`GlobalExceptionHandler`를 고치지 않고 별도 advice로 붙인다. Spring은 예외 타입이 +더 구체적인 핸들러를 고르므로 `StudioException`은 이쪽으로 온다. + +```java +package dev.caskeleton.adapter.inbound.web.techlog; + +import dev.caskeleton.adapter.inbound.web.error.ErrorResponseFactory; +import dev.caskeleton.application.techlog.error.StudioException; +import dev.caskeleton.shared.response.Envelope; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +/** + * Studio 실패를 스켈레톤 봉투로 옮긴다. 템플릿의 {@code GlobalExceptionHandler}를 + * 수정하지 않기 위해 별도 advice로 둔다 — 그 파일은 template sync 대상이다. + */ +@Order(Ordered.HIGHEST_PRECEDENCE) +@RestControllerAdvice +public class StudioExceptionHandler { + + @ExceptionHandler(StudioException.class) + public ResponseEntity> handleStudio(StudioException ex) { + return ErrorResponseFactory.envelope(ex.studioError(), ex.getMessage(), ex.details()); + } +} +``` + +- [ ] **Step 9: 테스트를 돌려 통과를 확인한다** + +```bash +cd /home/donghyeon/workspace/desktop-server-git/tech-log-backend/src +./gradlew :adapter:inbound:web:test --tests '*StudioExceptionHandlerTest' --console=plain +``` + +Expected: 2개 PASS. + +- [ ] **Step 10: 오류 코드 레지스트리에 23개를 추가한다** + +`BE/docs/registries/error-codes.yaml`의 `errors:` 목록 끝에 아래 형식으로 23개를 넣는다. +`StudioError`의 category/status/retryable과 **정확히 같아야 한다**. + +```yaml + # ============================================================ + # TECH LOG STUDIO (studio-v1.yaml ApiError.code — 23종) + # ============================================================ + + - 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/auth-token-missing + compatibility_impact: additive + required_test: StudioErrorTest + + - 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 +``` + +runbook 정책상 `runbook_link`가 필수인 것은 세 개다 — `AUTHENTICATION_REQUIRED`(AUTH), +`STUDIO_ACCESS_DENIED`(AUTHZ), `STUDIO_UNAVAILABLE`(TRANSIENT_DEPENDENCY, retryable). +앞의 둘은 기존 `runbook://auth/*`, `runbook://authz/*` 문서를 재사용하고, +`STUDIO_UNAVAILABLE`은 `BE/docs/runbooks/studio-unavailable.md`를 새로 쓴다. +나머지 20개는 client-error라 `runbook_link: null`이다. + +- [ ] **Step 11: 레지스트리와 enum이 일치하는지 테스트로 고정한다** + +`BE/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/StudioErrorRegistryTest.java`: + +```java +package dev.caskeleton.bootstrap.architecture; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.application.techlog.error.StudioError; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; +import org.junit.jupiter.api.Test; + +class StudioErrorRegistryTest { + + @Test + void everyStudioErrorHasARegistryRow() throws Exception { + Path registry = Path.of("..", "..", "docs", "registries", "error-codes.yaml").normalize(); + List lines = Files.readAllLines(registry); + Set registered = + lines.stream() + .map(String::strip) + .filter(line -> line.startsWith("- code:")) + .map(line -> line.substring("- code:".length()).strip()) + .collect(Collectors.toSet()); + + Set declared = + Arrays.stream(StudioError.values()).map(StudioError::code).collect(Collectors.toSet()); + + assertThat(registered).containsAll(declared); + } +} +``` + +경로가 맞지 않으면 `./gradlew :app-bootstrap:test`를 돌려 나오는 실제 작업 디렉터리로 +`Path.of(...)`를 조정한다. + +- [ ] **Step 12: 테스트를 돌린다** + +```bash +cd /home/donghyeon/workspace/desktop-server-git/tech-log-backend/src +./gradlew :app-bootstrap:test --tests '*StudioErrorRegistryTest' --console=plain +``` + +Expected: PASS. + +- [ ] **Step 13: 변경 파일을 보고한다 (커밋하지 않는다)** + +--- + +## Task 6: bounded context 경계 규칙 (BE) + +**Files:** +- Create: `BE/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/TechLogBoundaryArchTest.java` + +**Interfaces:** +- Produces: 이후 모든 슬라이스가 이 규칙 아래에서 코드를 놓는다. 규칙을 어기면 빌드가 깨진다. + +> **정정 (2026-08-19):** 아래 Step 1의 코드는 규칙 **5개**만 담고 있는데, spec §4.3은 **7개**를 +> 요구한다. 누락 2건은 계획 결함이며 fix round에서 보완했다. +> +> - spec §4.3 규칙 1은 `content`/`inquiry`/`project`/`asset` **네 방향 모두**를 요구한다. +> Step 1에는 앞의 셋만 있어 `asset`이 형제를 자유롭게 참조할 수 있었다. +> - spec §4.3 규칙 4(`domain.techlog.publication` 외의 domain 패키지가 `Publication`을 직접 +> 변경하지 않는다)가 Step 1에 아예 없다. ArchUnit으로 "변경"을 정적 표현할 수 없으므로 +> **타 domain context가 `..domain.techlog.publication..`에 의존하는 것 자체를 금지**하는 더 +> 엄격한 근사로 구현한다(형제 규칙들도 전면 금지이므로 일관된다). +> +> 교훈: ArchUnit 규칙은 `allowEmptyShould(true)` 때문에 **없는 규칙과 통과하는 규칙이 구분되지 +> 않는다.** 규칙 목록을 쓸 때는 spec의 항목 수와 대조하고, 각 규칙을 RED로 검증해야 한다. + +- [ ] **Step 1: 규칙 테스트를 쓴다** + +지금은 `techlog` 패키지에 클래스가 거의 없으므로 `allowEmptyShould(true)`로 두어 +빈 상태에서도 통과하게 한다. 코드가 늘면 자동으로 효력이 생긴다. + +```java +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 { + + @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 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); +} +``` + +- [ ] **Step 2: 테스트를 돌린다** + +```bash +cd /home/donghyeon/workspace/desktop-server-git/tech-log-backend/src +./gradlew :app-bootstrap:test --tests '*TechLogBoundaryArchTest' --console=plain +``` + +Expected: 5개 규칙 PASS (아직 대상 클래스가 없어 vacuously true). + +`ProductionClassImportOption`을 import할 수 없으면 `CleanArchitectureTest.java`가 +쓰는 정확한 패키지 경로를 확인해 맞춘다. + +- [ ] **Step 3: 규칙이 실제로 잡는지 확인한다 (일회성 검증)** + +`application-core`에 위반 클래스를 임시로 만들어 테스트가 실패하는지 본다. + +```bash +cd /home/donghyeon/workspace/desktop-server-git/tech-log-backend/src +mkdir -p application-core/src/main/java/dev/caskeleton/application/techlog/studio +mkdir -p application-core/src/main/java/dev/caskeleton/application/techlog/content/port/out +cat > application-core/src/main/java/dev/caskeleton/application/techlog/content/port/out/TempPort.java <<'EOF' +package dev.caskeleton.application.techlog.content.port.out; +public interface TempPort {} +EOF +cat > application-core/src/main/java/dev/caskeleton/application/techlog/studio/TempViolation.java <<'EOF' +package dev.caskeleton.application.techlog.studio; +import dev.caskeleton.application.techlog.content.port.out.TempPort; +public final class TempViolation { TempPort port; } +EOF +./gradlew :app-bootstrap:test --tests '*TechLogBoundaryArchTest' --console=plain 2>&1 | tail -15 +``` + +Expected: `STUDIO_FACADE_ONLY_TOUCHES_INBOUND_PORTS` 실패. + +- [ ] **Step 4: 임시 파일을 지우고 다시 통과시킨다** + +```bash +cd /home/donghyeon/workspace/desktop-server-git/tech-log-backend/src +rm application-core/src/main/java/dev/caskeleton/application/techlog/studio/TempViolation.java +rm application-core/src/main/java/dev/caskeleton/application/techlog/content/port/out/TempPort.java +./gradlew :app-bootstrap:test --tests '*TechLogBoundaryArchTest' --console=plain +``` + +Expected: PASS. + +- [ ] **Step 5: 변경 파일을 보고한다 (커밋하지 않는다)** + +--- + +## Task 7: Tech Log 코어 스키마 마이그레이션 (BE) + +**Files:** +- Create: `BE/src/adapter/outbound/persistence-jpa/src/main/resources/db/migration/postgresql/V7__techlog_core.sql` +- Test: `BE/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/techlog/TechLogSchemaMigrationTest.java` + +**Interfaces:** +- Produces: `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`. Task 9(catalog)와 이후 슬라이스가 읽고 쓴다. + +- [ ] **Step 1: 원본 DDL을 가져와 차이를 확인한다** + +```bash +cd /home/donghyeon/workspace/desktop-server-git/tech-log-backend +grep -nE "^CREATE TABLE" /home/donghyeon/workspace/tech-log-design-package/database/V1__init.sql | sed 's/ (.*//' +grep -nE "^CREATE TABLE" src/adapter/outbound/persistence-jpa/src/main/resources/db/migration/postgresql/*.sql | sed 's/ (.*//' +``` + +Expected: 설계 DDL 테이블 목록과 기존 테이블 목록. **이름이 겹치는 것이 없어야 한다.** +겹치면 여기서 멈추고 보고한다. + +- [ ] **Step 2: V7을 만든다** + +설계의 `database/V1__init.sql`을 기반으로 하되 세 가지를 바꾼다. + +1. `studio_idempotency` 테이블과 그 인덱스(`idx_studio_idempotency_expiry`)를 **제외한다.** + 기존 `idempotency_record`를 쓴다 (spec D5). +2. `release`, `site_config`, `profile_page`, `home_focus_config`, `topic_featured_document`, + `project_topic` 테이블을 **제외한다.** 이번 범위 밖이다 (spec §2.2). +3. 나머지는 그대로 옮긴다. `publication.latest_event_id`의 순환 FK는 + `DEFERRABLE INITIALLY DEFERRED`를 반드시 유지한다 — 즉시 검사로 바꾸면 첫 게시가 불가능하다. + +```bash +cd /home/donghyeon/workspace/desktop-server-git/tech-log-backend +DEST=src/adapter/outbound/persistence-jpa/src/main/resources/db/migration/postgresql/V7__techlog_core.sql +{ + echo "-- Tech Log 코어 스키마." + echo "-- 원본: tech-log-design-package/database/V1__init.sql" + echo "-- 제외: studio_idempotency (기존 idempotency_record 재사용, spec D5)," + echo "-- release / site_config / profile_page / home_focus_config /" + echo "-- topic_featured_document / project_topic (spec §2.2 범위 밖)." + echo + cat /home/donghyeon/workspace/tech-log-design-package/database/V1__init.sql +} > "$DEST" +wc -l "$DEST" +``` + +그다음 편집기로 위 1·2에 해당하는 블록을 지운다. 지운 뒤 남은 참조가 없는지 확인한다. + +```bash +cd /home/donghyeon/workspace/desktop-server-git/tech-log-backend +DEST=src/adapter/outbound/persistence-jpa/src/main/resources/db/migration/postgresql/V7__techlog_core.sql +for t in studio_idempotency release site_config profile_page home_focus_config topic_featured_document project_topic; do + echo "$t: $(grep -c "$t" "$DEST")" +done +grep -n "DEFERRABLE INITIALLY DEFERRED" "$DEST" +``` + +Expected: 7개 이름 모두 `0`, `DEFERRABLE INITIALLY DEFERRED` 1줄 이상. + +- [ ] **Step 3: 마이그레이션 적용 테스트를 쓴다** + +`TechLogSchemaMigrationTest.java`: + +```java +package dev.caskeleton.adapter.outbound.persistence.techlog; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.sql.Connection; +import java.sql.ResultSet; +import java.util.ArrayList; +import java.util.List; +import javax.sql.DataSource; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +/** + * V7이 실제 PostgreSQL에 적용되는지, 그리고 순환 FK가 deferrable로 선언됐는지 본다. + * H2로는 검증할 수 없다 — deferrable 제약이 벤더 의미이기 때문이다. + */ +@SpringBootTest +class TechLogSchemaMigrationTest { + + @Autowired private DataSource dataSource; + + @Test + void createsEveryTechLogTable() throws Exception { + List 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 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(); + } + } +} +``` + +제약 이름이 설계 DDL과 다르면 `grep -n "fk_publication_latest_event" $DEST`로 확인해 맞춘다. + +- [ ] **Step 4: 테스트를 돌린다** + +```bash +cd /home/donghyeon/workspace/desktop-server-git/tech-log-backend/src +./gradlew :adapter:outbound:persistence-jpa:postgresqlIntegrationTest --tests '*TechLogSchemaMigrationTest' --console=plain +``` + +Expected: 3개 PASS. Testcontainers가 Docker를 요구하므로 실패하면 Docker 데몬을 먼저 확인한다. +소스셋 이름이 다르면 `./gradlew :adapter:outbound:persistence-jpa:tasks --all | grep -i test`로 확인한다. + +- [ ] **Step 5: 변경 파일을 보고한다 (커밋하지 않는다)** + +--- + +## Task 8: `getStudioSession` (BE) + +**Files:** +- Create: `BE/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/controller/StudioSessionController.java` +- Test: `BE/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/techlog/studio/controller/StudioSessionControllerTest.java` +- Modify: `BE/src/app-bootstrap/src/main/resources/application-dev.yml` + +**Interfaces:** +- Consumes: Task 4의 생성 DTO `dev.caskeleton...techlog.studio.api.model.StudioSession` +- Produces: `GET /api/v1/studio/session` → 봉투에 담긴 `StudioSession` + +세션은 순수 전송 상태(principal + CSRF 토큰)이므로 application use case를 만들지 않는다. +`application-core`는 Spring을 볼 수 없어 `SecurityContext`에 접근할 수 없고, 여기에 +use case를 끼우면 아무 도메인 규칙도 없는 통과 계층이 하나 늘 뿐이다. + +- [ ] **Step 1: 실패 테스트를 쓴다** + +```java +package dev.caskeleton.adapter.inbound.web.techlog.studio.controller; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.inbound.web.auth.AuthenticatedPrincipal; +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.StudioSession; +import java.util.Set; +import org.junit.jupiter.api.Test; +import org.springframework.security.web.csrf.DefaultCsrfToken; + +class StudioSessionControllerTest { + + private final StudioSessionController controller = new StudioSessionController(); + + @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"); + } +} +``` + +접근자는 JavaBean 스타일(`getAuthenticated()`)이며 `roles`는 `Set`이다 — 위 실측 블록 참조. + +- [ ] **Step 2: 테스트를 돌려 실패를 확인한다** + +```bash +cd /home/donghyeon/workspace/desktop-server-git/tech-log-backend/src +./gradlew :adapter:inbound:web:test --tests '*StudioSessionControllerTest' --console=plain +``` + +Expected: 컴파일 실패 — `StudioSessionController` 없음. + +- [ ] **Step 3: controller를 구현한다** + +```java +package dev.caskeleton.adapter.inbound.web.techlog.studio.controller; + +import dev.caskeleton.adapter.inbound.web.auth.AuthenticatedPrincipal; +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.StudioSession; +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에 접근할 수도 없다. + * + *

반환값을 {@code Envelope}로 감싸지 않는다. {@code EnvelopeBodyAdvice}가 감싼다. + */ +@RestController +public class StudioSessionController { + + @GetMapping("/api/v1/studio/session") + public StudioSession getStudioSession( + @AuthenticationPrincipal AuthenticatedPrincipal principal, CsrfToken csrfToken) { + StudioSession session = new StudioSession(); + session.setAuthenticated(true); + session.setDisplayName(displayNameOf(principal)); + session.setRoles(Set.copyOf(principal.roles())); + session.setCsrfToken(csrfToken.getToken()); + session.setCsrfHeaderName("X-CSRF-TOKEN"); + return session; + } + + /** + * `displayName`은 계약상 1자 이상이다. profile capability(identity 모듈)가 들어오기 + * 전까지 email을 쓰고, 없으면 IdP subject로 대체한다. + */ + private static String displayNameOf(AuthenticatedPrincipal principal) { + String email = principal.email(); + return (email == null || email.isBlank()) ? principal.idpUserId() : email; + } +} +``` + +> **실측 (2026-08-19):** 생성된 `StudioSession`의 접근자는 다음과 같다. 추측하지 말 것. +> +> ```java +> Boolean getAuthenticated() / setAuthenticated(Boolean) +> String getDisplayName() / setDisplayName(String) +> Set getRoles() / setRoles(Set) +> String getCsrfToken() / setCsrfToken(String) +> String getCsrfHeaderName() / setCsrfHeaderName(String) ← enum 아님 +> ``` +> +> 즉 `CsrfHeaderNameEnum`은 **존재하지 않고** `roles`는 `List`가 아니라 `Set`이다. +> 계약의 `const: X-CSRF-TOKEN`은 생성기가 `String` + `@Schema`로 냈다. + +- [ ] **Step 4: 테스트를 돌려 통과를 확인한다** + +```bash +cd /home/donghyeon/workspace/desktop-server-git/tech-log-backend/src +./gradlew :adapter:inbound:web:test --tests '*StudioSessionControllerTest' --console=plain +``` + +Expected: 2개 PASS. + +- [ ] **Step 5: 세션 인증 모드와 CSRF 헤더 이름을 설정한다** + +계약은 `csrfHeaderName`을 `X-CSRF-TOKEN`으로 고정하는데 템플릿 기본값은 +`X-XSRF-TOKEN`이다. `application-dev.yml`에 추가한다. + +```yaml +ca-skeleton: + security: + # Studio는 브라우저 세션 기반이다. jwt 모드는 CSRF를 끄고 stateless로 간다. + auth-mode: redis-session + session: + # 계약(studio-v1.yaml StudioSession.csrfHeaderName)이 const로 고정한 값. + csrf-header-name: X-CSRF-TOKEN +``` + +- [ ] **Step 6: 봉투와 상태 코드를 슬라이스 테스트로 고정한다** + +`BE/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/techlog/studio/controller/StudioSessionEnvelopeTest.java`: + +```java +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.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.web.servlet.MockMvc; + +/** 응답이 봉투로 정확히 한 번 감싸이는지 고정한다. 두 번 감싸이면 프론트가 조용히 깨진다. */ +@WebMvcTest(controllers = StudioSessionController.class) +class StudioSessionEnvelopeTest { + + @Autowired private MockMvc mvc; + + @Test + @WithMockUser + void wrapsTheSessionPayloadExactlyOnce() throws Exception { + mvc.perform(get("/api/v1/studio/session")) + .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()); + } +} +``` + +`@WebMvcTest` 슬라이스에 `AuthenticatedPrincipal`과 `CsrfToken`을 넣는 방법은 +기존 web 테스트(`adapter/inbound/web/src/test`)의 선례를 따른다. 선례가 없으면 +`@WithMockUser` 대신 `SecurityMockMvcRequestPostProcessors.csrf()`와 커스텀 +`authentication(...)`을 쓴다. + +- [ ] **Step 7: 테스트를 돌린다** + +```bash +cd /home/donghyeon/workspace/desktop-server-git/tech-log-backend/src +./gradlew :adapter:inbound:web:test --tests '*StudioSession*' --console=plain +``` + +Expected: 전부 PASS. + +- [ ] **Step 8: 변경 파일을 보고한다 (커밋하지 않는다)** + +--- + +## Task 9: `listStudioCatalog` (BE) + +**Files:** +- Create: `BE/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/query/ListCatalogQuery.java` +- Create: `BE/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/query/CatalogEntryView.java` +- Create: `BE/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/query/CatalogPageView.java` +- Create: `BE/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/port/out/CatalogQueryPort.java` +- Create: `BE/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/ListCatalogUseCase.java` +- Create: `BE/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/query/JdbcCatalogQueryAdapter.java` +- Create: `BE/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/controller/StudioCatalogController.java` +- Test: `BE/src/application-core/src/test/java/dev/caskeleton/application/techlog/studio/service/ListCatalogUseCaseTest.java` +- Test: `BE/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/techlog/query/JdbcCatalogQueryAdapterTest.java` + +**Interfaces:** +- Consumes: Task 7의 `topic` / `project` / `document` / `open_question` / `project_decision` / `asset` 테이블, Task 5의 `StudioError` +- Produces: `CatalogQueryPort.search(CatalogEntryType, String query, String cursor, int limit)` → `CatalogPageView`. 이후 슬라이스의 relation picker가 같은 포트를 쓴다. + +- [ ] **Step 1: use case 실패 테스트를 쓴다** + +```java +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 java.util.List; +import java.util.UUID; +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))); + + 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))); + + 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))); + + assertThatThrownBy(() -> useCase.handle(new ListCatalogQuery(null, null, null, 20))) + .isInstanceOf(StudioException.class); + } +} +``` + +- [ ] **Step 2: 테스트를 돌려 실패를 확인한다** + +```bash +cd /home/donghyeon/workspace/desktop-server-git/tech-log-backend/src +./gradlew :application-core:test --tests '*ListCatalogUseCaseTest' --console=plain +``` + +Expected: 컴파일 실패. + +- [ ] **Step 3: application 타입과 포트를 만든다** + +```java +// query/CatalogEntryType.java +package dev.caskeleton.application.techlog.studio.query; + +/** 계약 `CatalogEntryType`과 1:1. API enum이 그대로 application 용어다. */ +public enum CatalogEntryType { + TOPIC, + PROJECT, + RELATION, + EVIDENCE +} +``` + +```java +// query/CatalogEntryView.java +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) {} +``` + +```java +// query/CatalogPageView.java +package dev.caskeleton.application.techlog.studio.query; + +import java.util.List; + +public record CatalogPageView(List items, String nextCursor) { + + public CatalogPageView { + items = List.copyOf(items); + } +} +``` + +```java +// query/ListCatalogQuery.java +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 {} +``` + +```java +// port/out/CatalogQueryPort.java +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); +} +``` + +`dev.caskeleton.application.query.Query`의 실제 시그니처를 먼저 확인하고 맞춘다. + +```bash +cd /home/donghyeon/workspace/desktop-server-git/tech-log-backend/src +cat application-core/src/main/java/dev/caskeleton/application/query/Query.java +``` + +- [ ] **Step 4: use case를 구현한다** + +```java +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.usecase.QueryUseCase; + +/** Studio catalog 조회. 도메인 상태를 바꾸지 않으므로 read-only다. */ +@UseCaseCapability( + transactionMode = TransactionMode.READ_ONLY, + idempotency = Idempotency.IDEMPOTENT, + repositoryAccess = RepositoryAccess.READ_REPOSITORY) +public final class ListCatalogUseCase implements QueryUseCase { + + private static final int MAX_LIMIT = 100; + + private final CatalogQueryPort catalogQueryPort; + + public ListCatalogUseCase(CatalogQueryPort catalogQueryPort) { + this.catalogQueryPort = catalogQueryPort; + } + + @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); + } + return catalogQueryPort.search(input.type(), input.query(), input.cursor(), input.limit()); + } +} +``` + +- [ ] **Step 5: 테스트를 돌려 통과를 확인한다** + +```bash +cd /home/donghyeon/workspace/desktop-server-git/tech-log-backend/src +./gradlew :application-core:test --tests '*ListCatalogUseCaseTest' --console=plain +./gradlew :application-core:check --console=plain 2>&1 | tail -10 +``` + +Expected: 3개 PASS, `verifyApplicationCoreDependencyPurity` 통과. + +- [ ] **Step 6: 영속 어댑터 실패 테스트를 쓴다** + +`JdbcCatalogQueryAdapterTest.java`: + +```java +package dev.caskeleton.adapter.outbound.persistence.techlog.query; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.application.techlog.studio.query.CatalogEntryType; +import dev.caskeleton.application.techlog.studio.query.CatalogPageView; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.simple.JdbcClient; + +@SpringBootTest +class JdbcCatalogQueryAdapterTest { + + @Autowired private JdbcClient jdbcClient; + @Autowired private JdbcCatalogQueryAdapter adapter; + + @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(); + } +} +``` + +**컬럼 이름은 사전 스캔에서 확인했다 — 추측하지 말 것.** 설계 DDL 기준: + +```text +topic id(uuid PK), name, normalized_name, slug, description, scope, + status('ACTIVE'|'ARCHIVED'), version, created_at, created_by, updated_at, updated_by +project id(uuid PK), slug, name, one_line_purpose, ..., phase, workflow_status, + target_visibility, version, created_at, created_by, updated_at, updated_by +``` + +`topic_id` / `project_id` / `title` 컬럼은 **존재하지 않는다.** `created_by`와 +`updated_by`는 NOT NULL이고 기본값이 없으므로 INSERT에 반드시 넣는다. +Task 7 산출물과 어긋나면 아래로 재확인한다. + +```bash +cd /home/donghyeon/workspace/desktop-server-git/tech-log-backend +sed -n '/^CREATE TABLE topic (/,/^);/p' src/adapter/outbound/persistence-jpa/src/main/resources/db/migration/postgresql/V7__techlog_core.sql +``` + +- [ ] **Step 7: 테스트를 돌려 실패를 확인한다** + +```bash +cd /home/donghyeon/workspace/desktop-server-git/tech-log-backend/src +./gradlew :adapter:outbound:persistence-jpa:postgresqlIntegrationTest --tests '*JdbcCatalogQueryAdapterTest' --console=plain +``` + +Expected: 컴파일 실패 — `JdbcCatalogQueryAdapter` 없음. + +- [ ] **Step 8: 영속 어댑터를 구현한다** + +이번 슬라이스에서는 `TOPIC`과 `PROJECT`만 실제 조회하고, `RELATION`/`EVIDENCE`는 +빈 페이지를 반환한다. 두 종류는 document/question/decision/asset 데이터가 들어오는 +슬라이스 2·5에서 채운다 — **빈 페이지는 계약상 유효한 응답이며 화면이 깨지지 않는다.** + +```java +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.UUID; +import org.springframework.jdbc.core.simple.JdbcClient; +import org.springframework.stereotype.Repository; + +/** + * catalog는 도메인 repository를 거치지 않고 전용 union query를 쓴다 (설계 08장 §4). + * + *

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() + "%"; + List 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 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 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(); + } +} +``` + +`dependencyRevision`은 이번 슬라이스에서 "해당 행의 updated_at"으로 둔다. 정식 +계산(설계 09장 §18A의 dependency set 해시)은 슬라이스 3에서 도입하고, 그때 이 +어댑터도 같이 고친다. 계약상 `dependencyRevision`은 1..200자 문자열이면 되므로 +지금 값도 유효하다. + +- [ ] **Step 9: 테스트를 돌려 통과를 확인한다** + +```bash +cd /home/donghyeon/workspace/desktop-server-git/tech-log-backend/src +./gradlew :adapter:outbound:persistence-jpa:postgresqlIntegrationTest --tests '*JdbcCatalogQueryAdapterTest' --console=plain +``` + +Expected: 2개 PASS. + +- [ ] **Step 10: controller를 만든다** + +```java +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.CatalogEntryType; +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 java.util.List; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** 반환값을 Envelope로 감싸지 않는다 — EnvelopeBodyAdvice가 감싼다. */ +@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, // application enum. 웹 계층 enum과 이름이 같으니 import 주의 + @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(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; + } +} +``` + +> **실측 (2026-08-19):** 생성 DTO의 실제 모양이다. 추측하지 말 것. +> +> ```java +> CatalogPage List getItems()/setItems(...) String getNextCursor()/setNextCursor(...) +> CatalogEntry UUID getId()/setId(UUID) ← String 아님 +> CatalogEntryType getType()/setType(...) ← 최상위 별도 enum 클래스 +> String getLabel()/setLabel(...) +> @Nullable KindEnum getKind()/setKind(...) ← CatalogEntry 안의 중첩 enum +> String getPublicPath(), String getDependencyRevision() +> ``` +> +> 즉 **`CatalogEntry.TypeEnum`은 존재하지 않는다** — `type`은 최상위 +> `CatalogEntryType`(값 TOPIC/PROJECT/RELATION/EVIDENCE, `fromValue(String)` 있음)이고, +> `kind`만 중첩 `CatalogEntry.KindEnum`(CASE/REFERENCE/QUESTION/PROJECT/PROJECT_DECISION, +> `fromValue(String)` 있음)이다. `id`는 `UUID`라 변환이 필요 없다. +> +> `dev.caskeleton.application.query.Query`는 빈 마커 인터페이스다 — 메서드가 없다. + +- [ ] **Step 11: use case 빈을 등록한다** + +`application-core`는 Spring을 모르므로 `ListCatalogUseCase`는 bootstrap에서 조립한다. + +`BE/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/techlog/TechLogStudioConfig.java`: + +```java +package dev.caskeleton.bootstrap.techlog; + +import dev.caskeleton.application.techlog.studio.port.out.CatalogQueryPort; +import dev.caskeleton.application.techlog.studio.service.ListCatalogUseCase; +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) { + return new ListCatalogUseCase(catalogQueryPort); + } +} +``` + +- [ ] **Step 12: 전체 검증을 돌린다** + +```bash +cd /home/donghyeon/workspace/desktop-server-git/tech-log-backend/src +./gradlew :application-core:test :adapter:inbound:web:test --console=plain +./gradlew verifyCleanArchitectureDependencies --console=plain +./gradlew :app-bootstrap:test --tests '*CleanArchitectureTest' --tests '*TechLogBoundaryArchTest' --tests '*StudioErrorRegistryTest' --console=plain +``` + +Expected: 전부 PASS. + +- [ ] **Step 13: 변경 파일을 보고한다 (커밋하지 않는다)** + +```bash +cd /home/donghyeon/workspace/desktop-server-git/tech-log-backend +git status --short | grep -v '^ D ' +``` + +--- + +## Task 10: 계약 회귀 테스트 (BE) + +spec §5.5. springdoc이 노출하는 실제 계약을 vendor된 `studio-v1.yaml`과 대조한다. +구현된 operation만 검사하므로 슬라이스가 늘어도 그대로 쓸 수 있고, 계약에 없는 +엔드포인트가 새로 생기면 즉시 실패한다. + +**Files:** +- Create: `BE/src/app-bootstrap/src/functionalTest/java/dev/caskeleton/bootstrap/contract/StudioContractDriftTest.java` + +**Interfaces:** +- Consumes: Task 4의 `src/config/openapi/studio-v1.yaml`, Task 8·9의 controller +- Produces: 이후 모든 슬라이스가 새 controller를 추가할 때 자동으로 걸리는 드리프트 게이트 + +**규약:** controller 메서드 이름은 계약의 `operationId`와 **같게 짓는다.** springdoc이 +메서드 이름에서 operationId를 만들기 때문이며, 이 테스트가 그 규약을 강제한다. +Task 8의 `getStudioSession`, Task 9의 `listStudioCatalog`가 이미 그렇다. + +- [ ] **Step 1: 드리프트 테스트를 쓴다** + +```java +package dev.caskeleton.bootstrap.contract; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; + +/** + * 구현된 Studio operation이 계약과 어긋나면 실패한다. 계약에 없는 `/api/v1/studio/**` + * 엔드포인트가 생겨도 실패한다 — 계약 밖 표면이 조용히 늘어나는 것을 막는다. + */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class StudioContractDriftTest { + + @Autowired private TestRestTemplate restTemplate; + + @Test + void publishedStudioOperationsMatchTheContract() throws Exception { + JsonNode contract = + new ObjectMapper(new YAMLFactory()) + .readTree(Path.of("..", "config", "openapi", "studio-v1.yaml").normalize().toFile()); + JsonNode published = new ObjectMapper().readTree(restTemplate.getForObject("/v3/api-docs", String.class)); + + List problems = new ArrayList<>(); + JsonNode publishedPaths = published.path("paths"); + Iterator> paths = publishedPaths.fields(); + while (paths.hasNext()) { + Map.Entry path = paths.next(); + 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; + } + Iterator> methods = path.getValue().fields(); + while (methods.hasNext()) { + Map.Entry method = methods.next(); + 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(); + } + + @Test + void everyStudioResponseIsWrappedInTheEnvelope() { + String body = restTemplate.getForObject("/api/v1/studio/catalog?type=TOPIC", String.class); + + assertThat(body).contains("\"success\"").contains("\"data\"").contains("\"meta\""); + assertThat(body).doesNotContain("\"data\":{\"success\""); + } +} +``` + +`jackson-dataformat-yaml`이 `functionalTest` 클래스패스에 없으면 +`app-bootstrap/build.gradle`의 `functionalTestImplementation`에 추가하고 lockfile을 +재생성한다. `Path.of("..", "config", ...)`가 맞지 않으면 실패 메시지에 찍히는 실제 +작업 디렉터리로 조정한다. + +`/api/v1/studio/catalog`는 인증이 필요하므로 두 번째 테스트가 401을 받으면 +`SECURITY_PUBLIC_PATHS`에 넣지 말고(보안 표면을 넓히면 안 된다) 기존 functional test가 +쓰는 인증 헬퍼를 따라 인증된 요청으로 바꾼다. + +```bash +ls /home/donghyeon/workspace/desktop-server-git/tech-log-backend/src/app-bootstrap/src/functionalTest/java/dev/caskeleton/bootstrap/contract/ +``` + +- [ ] **Step 2: 테스트를 돌려 실패를 확인한다** + +```bash +cd /home/donghyeon/workspace/desktop-server-git/tech-log-backend/src +./gradlew :app-bootstrap:functionalTest --tests '*StudioContractDriftTest' --console=plain +``` + +Expected: 컴파일 실패 또는 FAIL. 태스크 이름이 다르면 +`./gradlew :app-bootstrap:tasks --all | grep -i test`로 확인한다. + +- [ ] **Step 3: 드리프트가 있으면 계약이 아니라 코드를 고친다** + +계약이 SSOT다. `operationId` 불일치가 나오면 controller 메서드 이름을 계약에 맞춘다. +계약에 없는 path가 나오면 그 엔드포인트를 지운다. + +- [ ] **Step 4: 테스트를 돌려 통과를 확인한다** + +```bash +cd /home/donghyeon/workspace/desktop-server-git/tech-log-backend/src +./gradlew :app-bootstrap:functionalTest --tests '*StudioContractDriftTest' --console=plain +``` + +Expected: 2개 PASS. + +- [ ] **Step 5: 변경 파일을 보고한다 (커밋하지 않는다)** + +--- + +## 완료 판정 + +이 계획이 끝나면 다음이 참이다. + +```text +DP studio-v1.yaml v3.0.0이 봉투를 기술하고 검증 스크립트 4종이 통과한다 +FE 계약이 재생성되고 전송 경계에서 봉투를 언랩하며 test:tech-log가 통과한다 +BE feature/techlog-studio-backend에서 + - 계약 DTO가 생성되고 컴파일된다 + - StudioError 23종이 enum·레지스트리 양쪽에 있다 + - bounded context 경계가 ArchUnit으로 강제된다 + - V7 스키마가 PostgreSQL에 적용된다 + - GET /api/v1/studio/session 이 봉투에 담긴 StudioSession을 돌려준다 + - GET /api/v1/studio/catalog 가 TOPIC/PROJECT를 돌려준다 + - 구현된 operation이 계약과 어긋나면 functionalTest가 실패한다 +``` + +## 다음 계획 + +슬라이스 2~5는 각각 별도 계획으로 쓴다. 이 계획이 끝나 계약과 기반이 확정된 뒤에 +써야 추측이 들어가지 않는다. + +```text +Plan 02 문서 CRUD 4종 + 낙관적 잠금 + 멱등 +Plan 03 validate / preview / dependencyRevision / nextAction / 렌더러 + 착수 전 프론트 렌더 모델 구현을 기준선으로 대조한다 (spec §10) +Plan 04 publish 20단계 / event / snapshot / unpublish / dashboard +Plan 05 asset 5종 (fileserver · objectstorage 위에) +``` diff --git a/docs/superpowers/specs/2026-08-18-techlog-studio-backend-design.md b/docs/superpowers/specs/2026-08-18-techlog-studio-backend-design.md new file mode 100644 index 0000000..d492493 --- /dev/null +++ b/docs/superpowers/specs/2026-08-18-techlog-studio-backend-design.md @@ -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..vo Value Object + +:application-core + dev.caskeleton.application.techlog..port.in Command · Query · *UseCase 인터페이스 + dev.caskeleton.application.techlog..port.out Repository · Renderer · Clock 포트 + dev.caskeleton.application.techlog..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..entity + dev.caskeleton.adapter.outbound.persistence.techlog..repository + dev.caskeleton.adapter.outbound.persistence.techlog..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`는 생성된 별개 클래스라 `dev.caskeleton.shared.response.Envelope`가 +아니다. `EnvelopeBodyAdvice.beforeBodyWrite`는 `Envelope`/`BulkEnvelope`만 통과시키므로 +이 본문을 **한 번 더 감싼다**. + +```text +controller → StudioSessionEnvelope +EnvelopeBodyAdvice → Envelope +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` / `QueryUseCase`를 구현하고 +이름이 `UseCase`로 끝나며 `@UseCaseCapability`를 선언한다. + +```java +@UseCaseCapability( + transactionMode = TransactionMode.WRITE, + idempotency = Idempotency.KEYED, + repositoryAccess = RepositoryAccess.WRITE_REPOSITORY) +final class PublishCaseUseCase implements CommandUseCase { } +``` + +트랜잭션 경계는 `@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` diff --git a/src/adapter/inbound/web/build.gradle b/src/adapter/inbound/web/build.gradle index 6533cb8..fc6bf52 100644 --- a/src/adapter/inbound/web/build.gradle +++ b/src/adapter/inbound/web/build.gradle @@ -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/**') } } diff --git a/src/adapter/inbound/web/gradle.lockfile b/src/adapter/inbound/web/gradle.lockfile index b8bb42d..c0429f4 100644 --- a/src/adapter/inbound/web/gradle.lockfile +++ b/src/adapter/inbound/web/gradle.lockfile @@ -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= diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/StudioClientSafeMessages.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/StudioClientSafeMessages.java new file mode 100644 index 0000000..7fad700 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/StudioClientSafeMessages.java @@ -0,0 +1,52 @@ +package dev.caskeleton.adapter.inbound.web.techlog; + +import dev.caskeleton.application.techlog.error.StudioError; + +/** + * Studio 실패의 client-safe {@code error.message} 단일 출처. + * + *

{@code StudioException#getMessage()}는 Studio use case/facade가 진단용으로 채우는 원문이라 {@code + * ApiErrorCarrier} javadoc이 경고하는 대로 SQLState나 upstream detail을 실을 수 있다. 그래서 응답에는 절대 흘리지 않고, 이 클래스가 + * code별 고정 문구만 내보낸다 — 스켈레톤의 {@code ClientSafeErrorMessages}가 {@code OperationalError}에 대해 하는 것과 같은 + * 역할을 {@link StudioError}에 대해 한다. + * + *

문구는 {@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 전용 한국어 문구다. + * + *

{@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 서비스를 일시적으로 사용할 수 없습니다. 잠시 후 다시 시도해 주세요"; + }; + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/StudioExceptionHandler.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/StudioExceptionHandler.java new file mode 100644 index 0000000..bd05a70 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/StudioExceptionHandler.java @@ -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 대상이다. + * + *

{@code error.message}에는 {@link StudioClientSafeMessages}가 주는 code별 고정 문구만 싣는다 — {@link + * StudioException#getMessage()}(진단용 원문)는 SQLState·upstream detail을 실을 수 있어 client-unsafe하다({@code + * ApiErrorCarrier} javadoc). 원문은 버리지 않고 서버 로그에만 남긴다 — {@code GlobalExceptionHandler}의 {@code + * handlePersistenceFailure}/{@code handleDependencyFailure}가 분류된 하위 계층 실패를 로깅하는 것과 같은 패턴이다. + * + *

{@code basePackages} 스코프 (final whole-branch review B4). 이 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> 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> 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> 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> requestValidationFailed( + String parameterName, String message) { + Map fieldError = Map.of("path", "/" + parameterName, "message", message); + Map details = Map.of("fieldErrors", List.of(fieldError)); + return ErrorResponseFactory.envelope( + StudioError.REQUEST_VALIDATION_FAILED, + StudioClientSafeMessages.forError(StudioError.REQUEST_VALIDATION_FAILED), + details); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/controller/StudioCatalogController.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/controller/StudioCatalogController.java new file mode 100644 index 0000000..6490190 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/controller/StudioCatalogController.java @@ -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가 감싼다. + * + *

{@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; + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/controller/StudioSessionController.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/controller/StudioSessionController.java new file mode 100644 index 0000000..2068679 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/controller/StudioSessionController.java @@ -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에 접근할 수도 없다. + * + *

반환값을 {@code Envelope}로 감싸지 않는다. {@code EnvelopeBodyAdvice}가 감싼다. + * + *

CSRF가 꺼진 auth-mode. {@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 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; + } +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/error/StudioExceptionHandlerScopeTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/error/StudioExceptionHandlerScopeTest.java new file mode 100644 index 0000000..c7c6a2b --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/error/StudioExceptionHandlerScopeTest.java @@ -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. + * + *

{@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 {} +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/techlog/StudioExceptionHandlerTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/techlog/StudioExceptionHandlerTest.java new file mode 100644 index 0000000..49b99f3 --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/techlog/StudioExceptionHandlerTest.java @@ -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> response = + handler.handleStudio(StudioException.of(StudioError.DOCUMENT_NOT_FOUND, "없음")); + + assertThat(response.getStatusCode().value()).isEqualTo(404); + Envelope 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> 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> 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(); + } +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/techlog/studio/controller/StudioCatalogBindingErrorEnvelopeTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/techlog/studio/controller/StudioCatalogBindingErrorEnvelopeTest.java new file mode 100644 index 0000000..ecc264e --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/techlog/studio/controller/StudioCatalogBindingErrorEnvelopeTest.java @@ -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. + * + *

{@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. + * + *

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. + * + *

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 inWrite(Supplier action) { + return action.get(); + } + + @Override + public T inRootWrite(Supplier action) { + return action.get(); + } + + @Override + public T inRead(Supplier action) { + return action.get(); + } + + @Override + public T inNew(Supplier action) { + return action.get(); + } + } + + @SpringBootConfiguration + @EnableAutoConfiguration(exclude = SecurityAutoConfiguration.class) + static class TestBootstrap {} +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/techlog/studio/controller/StudioSessionControllerTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/techlog/studio/controller/StudioSessionControllerTest.java new file mode 100644 index 0000000..5ca2243 --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/techlog/studio/controller/StudioSessionControllerTest.java @@ -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 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); + } +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/techlog/studio/controller/StudioSessionCsrfDisabledTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/techlog/studio/controller/StudioSessionCsrfDisabledTest.java new file mode 100644 index 0000000..aff5c33 --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/techlog/studio/controller/StudioSessionCsrfDisabledTest.java @@ -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를 아무도 채우지 않는다. + * + *

그런데 {@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**이다. + * + *

이 테스트는 그 사실을 슬라이스 필터체인으로 직접 재현한다 — {@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 {} +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/techlog/studio/controller/StudioSessionEnvelopeTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/techlog/studio/controller/StudioSessionEnvelopeTest.java new file mode 100644 index 0000000..f42b732 --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/techlog/studio/controller/StudioSessionEnvelopeTest.java @@ -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} 빈도 + * 하나뿐이다). + * + *

이 모듈(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}한다. + * + *

보안: 이 슬라이스는 실제 {@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 참조). + * + *

{@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}) — 그래야 그 인자 리졸버들이 등록된다. + * + *

{@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 {} +} diff --git a/src/adapter/outbound/persistence-jpa/build.gradle b/src/adapter/outbound/persistence-jpa/build.gradle index 7bbb230..151578b 100644 --- a/src/adapter/outbound/persistence-jpa/build.gradle +++ b/src/adapter/outbound/persistence-jpa/build.gradle @@ -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' diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/query/JdbcCatalogQueryAdapter.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/query/JdbcCatalogQueryAdapter.java new file mode 100644 index 0000000..38e32bc --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/query/JdbcCatalogQueryAdapter.java @@ -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). + * + *

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 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 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 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(); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/resources/db/migration/postgresql/V7__techlog_core.sql b/src/adapter/outbound/persistence-jpa/src/main/resources/db/migration/postgresql/V7__techlog_core.sql new file mode 100644 index 0000000..d60cd56 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/resources/db/migration/postgresql/V7__techlog_core.sql @@ -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); diff --git a/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/readiness/PostgreSqlMigrationIntegrationTest.java b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/readiness/PostgreSqlMigrationIntegrationTest.java index 0697bcb..96451ce 100644 --- a/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/readiness/PostgreSqlMigrationIntegrationTest.java +++ b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/readiness/PostgreSqlMigrationIntegrationTest.java @@ -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() diff --git a/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/techlog/TechLogSchemaMigrationTest.java b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/techlog/TechLogSchemaMigrationTest.java new file mode 100644 index 0000000..4e6b07a --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/techlog/TechLogSchemaMigrationTest.java @@ -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 제약이 벤더 의미이기 + * 때문이다. + * + *

이 모듈(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 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 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; + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/techlog/query/JdbcCatalogQueryAdapterTest.java b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/techlog/query/JdbcCatalogQueryAdapterTest.java new file mode 100644 index 0000000..a916c0c --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/techlog/query/JdbcCatalogQueryAdapterTest.java @@ -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(); + } +} diff --git a/src/app-bootstrap/build.gradle b/src/app-bootstrap/build.gradle index c96b1be..5d45c8b 100644 --- a/src/app-bootstrap/build.gradle +++ b/src/app-bootstrap/build.gradle @@ -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( diff --git a/src/app-bootstrap/gradle.lockfile b/src/app-bootstrap/gradle.lockfile index a4d058c..ccddc13 100644 --- a/src/app-bootstrap/gradle.lockfile +++ b/src/app-bootstrap/gradle.lockfile @@ -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 diff --git a/src/app-bootstrap/src/functionalTest/java/dev/caskeleton/bootstrap/contract/StudioContractDriftTest.java b/src/app-bootstrap/src/functionalTest/java/dev/caskeleton/bootstrap/contract/StudioContractDriftTest.java new file mode 100644 index 0000000..8d4a876 --- /dev/null +++ b/src/app-bootstrap/src/functionalTest/java/dev/caskeleton/bootstrap/contract/StudioContractDriftTest.java @@ -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 implemented operations are checked (direction is "published ⊆ contract", never the + * reverse), so this stays green as slices 2-5 add the other 17 operations — on one condition: + * 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 outside 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. + * + *

Why a hand-built minimal context rather than {@code CaSkeletonApplication}

+ * + *

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. + * + *

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. + * + *

{@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. + * + *

Why two nested contexts instead of one

+ * + *

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} — 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. + * + *

Why {@link ListCatalogUseCase} is real, not mocked

+ * + *

{@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}, are + * 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. + * + *

Second test: envelope wrapping, without real DB/auth infrastructure

+ * + *

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 problems = new ArrayList<>(); + JsonNode publishedPaths = published.path("paths"); + for (Map.Entry 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 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 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 inWrite(Supplier action) { + return action.get(); + } + + @Override + public T inRootWrite(Supplier action) { + return action.get(); + } + + @Override + public T inRead(Supplier action) { + return action.get(); + } + + @Override + public T inNew(Supplier action) { + return action.get(); + } + } +} diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/techlog/TechLogStudioConfig.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/techlog/TechLogStudioConfig.java new file mode 100644 index 0000000..6256934 --- /dev/null +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/techlog/TechLogStudioConfig.java @@ -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); + } +} diff --git a/src/app-bootstrap/src/main/resources/application-dev.yml b/src/app-bootstrap/src/main/resources/application-dev.yml index 9545a3b..b14cd2a 100644 --- a/src/app-bootstrap/src/main/resources/application-dev.yml +++ b/src/app-bootstrap/src/main/resources/application-dev.yml @@ -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 diff --git a/src/app-bootstrap/src/main/resources/application-local.yml b/src/app-bootstrap/src/main/resources/application-local.yml index 6bce536..07818f2 100644 --- a/src/app-bootstrap/src/main/resources/application-local.yml +++ b/src/app-bootstrap/src/main/resources/application-local.yml @@ -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 diff --git a/src/app-bootstrap/src/main/resources/application-prod.yml b/src/app-bootstrap/src/main/resources/application-prod.yml index 0be1044..efa917e 100644 --- a/src/app-bootstrap/src/main/resources/application-prod.yml +++ b/src/app-bootstrap/src/main/resources/application-prod.yml @@ -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 diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/StudioErrorRegistryTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/StudioErrorRegistryTest.java new file mode 100644 index 0000000..08b8e6a --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/StudioErrorRegistryTest.java @@ -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: + * + *

    + *
  1. every {@link StudioError} constant has a matching registry row (code presence); + *
  2. 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; + *
  3. {@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. + *
+ * + *

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> 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 root = new Yaml().load(in); + List> errors = (List>) root.get("errors"); + for (Map row : errors) { + registryRowsByCode.put((String) row.get("code"), row); + } + } + } + + @Test + void everyStudioErrorHasARegistryRow() { + Set 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 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 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 contractCodes = contractApiErrorCodes(contract); + + Set 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 contractApiErrorCodes(Path contract) throws IOException { + try (InputStream in = Files.newInputStream(contract)) { + Map root = new Yaml().load(in); + Map components = (Map) root.get("components"); + Map schemas = (Map) components.get("schemas"); + Map apiError = (Map) schemas.get("ApiError"); + Map properties = (Map) apiError.get("properties"); + Map code = (Map) properties.get("code"); + List enumValues = (List) 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 }, 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(); + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/TechLogBoundaryArchTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/TechLogBoundaryArchTest.java new file mode 100644 index 0000000..4821656 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/TechLogBoundaryArchTest.java @@ -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); +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/StudioSessionCsrfHeaderProfileContractTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/StudioSessionCsrfHeaderProfileContractTest.java new file mode 100644 index 0000000..e2d241c --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/StudioSessionCsrfHeaderProfileContractTest.java @@ -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}. + * + *

{@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. + * + *

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()); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/error/StudioError.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/error/StudioError.java new file mode 100644 index 0000000..d6f23cd --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/error/StudioError.java @@ -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; + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/error/StudioException.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/error/StudioException.java new file mode 100644 index 0000000..4e81253 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/error/StudioException.java @@ -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를 알 필요가 없다. + * + *

{@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; + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/port/out/CatalogQueryPort.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/port/out/CatalogQueryPort.java new file mode 100644 index 0000000..a64f5a4 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/port/out/CatalogQueryPort.java @@ -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); +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/query/CatalogEntryType.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/query/CatalogEntryType.java new file mode 100644 index 0000000..e012f42 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/query/CatalogEntryType.java @@ -0,0 +1,9 @@ +package dev.caskeleton.application.techlog.studio.query; + +/** 계약 `CatalogEntryType`과 1:1. API enum이 그대로 application 용어다. */ +public enum CatalogEntryType { + TOPIC, + PROJECT, + RELATION, + EVIDENCE +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/query/CatalogEntryView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/query/CatalogEntryView.java new file mode 100644 index 0000000..ef48683 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/query/CatalogEntryView.java @@ -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) {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/query/CatalogPageView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/query/CatalogPageView.java new file mode 100644 index 0000000..04f039f --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/query/CatalogPageView.java @@ -0,0 +1,10 @@ +package dev.caskeleton.application.techlog.studio.query; + +import java.util.List; + +public record CatalogPageView(List items, String nextCursor) { + + public CatalogPageView { + items = List.copyOf(items); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/query/ListCatalogQuery.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/query/ListCatalogQuery.java new file mode 100644 index 0000000..53147ac --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/query/ListCatalogQuery.java @@ -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 {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/ListCatalogUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/ListCatalogUseCase.java new file mode 100644 index 0000000..f39c031 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/ListCatalogUseCase.java @@ -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다. + * + *

브리프 원안은 {@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 { + + 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())); + } +} diff --git a/src/application-core/src/test/java/dev/caskeleton/application/techlog/error/StudioErrorTest.java b/src/application-core/src/test/java/dev/caskeleton/application/techlog/error/StudioErrorTest.java new file mode 100644 index 0000000..3d4dadf --- /dev/null +++ b/src/application-core/src/test/java/dev/caskeleton/application/techlog/error/StudioErrorTest.java @@ -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(); + } +} diff --git a/src/application-core/src/test/java/dev/caskeleton/application/techlog/studio/service/ListCatalogUseCaseTest.java b/src/application-core/src/test/java/dev/caskeleton/application/techlog/studio/service/ListCatalogUseCaseTest.java new file mode 100644 index 0000000..9542ea7 --- /dev/null +++ b/src/application-core/src/test/java/dev/caskeleton/application/techlog/studio/service/ListCatalogUseCaseTest.java @@ -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 inWrite(Supplier action) { + return action.get(); + } + + @Override + public T inRootWrite(Supplier action) { + return action.get(); + } + + @Override + public T inRead(Supplier action) { + return action.get(); + } + + @Override + public T inNew(Supplier action) { + return action.get(); + } + } +} diff --git a/src/build.gradle b/src/build.gradle index 263120b..25de083 100644 --- a/src/build.gradle +++ b/src/build.gradle @@ -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 diff --git a/src/config/openapi/MANIFEST.sha256 b/src/config/openapi/MANIFEST.sha256 new file mode 100644 index 0000000..9315d6e --- /dev/null +++ b/src/config/openapi/MANIFEST.sha256 @@ -0,0 +1,2 @@ +# source: tech-log-design-package contracts/openapi/studio-v1.yaml @ b20d7a2 (feature/response-envelope-adr-006) +6cae9924403d0761f401643a022980b8e04183eea0d890c143c9fbbbbc7431e4 studio-v1.yaml diff --git a/src/config/openapi/studio-v1.yaml b/src/config/openapi/studio-v1.yaml new file mode 100644 index 0000000..836aa00 --- /dev/null +++ b/src/config/openapi/studio-v1.yaml @@ -0,0 +1,1836 @@ +openapi: 3.1.0 +info: + title: Tech Log Studio API + version: 3.0.0 + description: | + Tech Log Studio orchestration 계약이다. + + 이 파일이 Studio HTTP 계약의 canonical source다. Frontend의 + `contracts/studio-api.openapi.yaml`은 이 계약에서 생성되어야 하며 + 독립적인 두 번째 canonical source가 되어서는 안 된다. (ADR-004) + + ## 이 계약의 성격 + + 현재 Studio UI의 단일 문서 제작 흐름을 그대로 계약으로 승격한 것이다. + + ```text + 작업본 → 편집 → 저장 → 검증 → Public Preview → 게시/재게시 → 게시 기록/Snapshot + ``` + + 유형별 CMS 메뉴(Case 관리 / Reference 관리 / Question 관리)는 이 계약의 + primary mental model이 아니다. 유형별 specialized management capability는 + `studio-management-v1.yaml`에 secondary API로 보존한다. + + ## WorkingCopy는 API projection이다 + + `WorkingCopy`는 Domain Aggregate가 아니다. Studio API가 여러 도메인을 + 동일한 편집 경험으로 보여주기 위한 API/Application projection이며, + Backend는 이를 기존 유형별 use case로 dispatch한다. + + ```text + createStudioDocument(kind=CASE) → CreateCaseDraft + saveStudioDocument(kind=CASE) → UpdateCaseDraft + validateStudioDocument(kind=CASE) → CasePublicationValidator + publishStudioDocument(kind=CASE) → CasePublicationHandler + + kind=REFERENCE → Reference capability + kind=QUESTION → Inquiry capability + kind=PROJECT_DECISION → ProjectDecision capability + ``` + + `documentId`는 source aggregate id를 그대로 사용한다. 별도 surrogate + Studio id를 만들지 않으며 `working_copy` 범용 테이블도 만들지 않는다. + + ## API 용어 ↔ Domain 용어 mapper 경계 + + API enum은 UI 용어이고 Domain enum은 그대로 보존한다. 변환은 mapper가 한다. + + | API (이 계약) | Domain | 비고 | + |---|---|---| + | `questionStatus=OPEN` | `OPEN`, `INVESTIGATING`, `PAUSED` | 축약 view. 저장이 Domain 상태를 덮어쓰지 않는다 | + | `questionStatus=RESOLVED` | `RESOLVED` | Resolve command로 해석하며 기존 resolve invariant를 통과해야 한다 | + | `decisionStatus=ADOPTED` | `ACCEPTED`/`ADOPTED` | Domain enum을 UI 용어 때문에 변경하지 않는다 | + | `documentId` | `source_kind` + `source_id` | Publication 계열 테이블은 두 컬럼으로 저장한다 | + | `nextAction` | (없음) | Domain state가 아니라 Studio projection이다. `workflow_status`에 저장하지 않는다 | + + `saveStudioDocument`는 편집 가능한 content field만 저장한다. lifecycle + 전이는 명시적인 Domain Action이 담당한다. Frontend가 `OPEN`을 보냈다고 해서 + `INVESTIGATING → OPEN`으로 자동 전이하면 안 된다. + + ## Frontend 계약과의 현재 차이 (2026-08-17 실측) + + `tech-log-frontend`의 `src/features/tech-log/contracts/studio/studio-api.openapi.yaml`과 + 대조한 결과 다음이 이미 일치한다. + + ```text + operation 13개 전부 operationId · method · path 일치 + RecordKind 양쪽 4종 동일 (CASE / REFERENCE / QUESTION / PROJECT_DECISION) + 오류 코드 Frontend 15종이 이 계약의 23종에 모두 포함 + ``` + + Backend가 추가로 제공하는 것은 두 가지이며, Frontend가 이 계약에서 재생성할 + 때 흡수된다. + + 1. Asset operation 5개와 `getStudioSession`. + Frontend는 아직 Asset capability를 구현하지 않았다. + 2. mutating operation의 `X-CSRF-TOKEN`. + Frontend 계약은 `security: []`이며 이 header를 선언하지 않는다. + + ## 게시 경로는 하나다 + + `studio-management-v1.yaml`의 publish/unpublish 계열 operation도 이 계약과 + 동일한 Publication Event/Snapshot 경로를 거쳐야 한다. 서로 다른 두 개의 + 게시 경로를 허용하지 않는다. +servers: +- url: / +security: +- sessionCookie: [] +tags: +- name: Session + description: Studio 접근 제어 +- name: Dashboard + description: Workspace 대시보드 +- name: Documents + description: 통합 Working Copy 편집 +- name: Validation + description: Validation Artifact +- name: Preview + description: 인증된 Public Preview Artifact +- name: Publication + description: Publication Aggregate / Event / Snapshot +- name: Catalog + description: Editor picker 통합 조회 +- name: Assets + description: Asset Library / Picker / Upload +paths: + /api/v1/studio/session: + get: + operationId: getStudioSession + tags: [Session] + summary: 현재 Studio 세션과 CSRF 토큰을 조회한다 + responses: + "200": { description: 인증된 Studio 세션, content: { application/json: { schema: { $ref: "#/components/schemas/StudioSessionEnvelope" } } } } + "401": { $ref: "#/components/responses/AuthenticationRequired" } + "403": { $ref: "#/components/responses/AccessDenied" } + "503": { $ref: "#/components/responses/StudioUnavailable" } + + /api/v1/studio/dashboard: + get: + operationId: getStudioDashboard + tags: [Dashboard] + summary: Get the Studio dashboard + description: | + `nextAction`을 포함한 모든 workflow 상태는 서버가 계산한다. + Frontend는 여러 endpoint를 조합해 workflow 상태를 재추론하지 않는다. + responses: + "200": { description: Dashboard lists and totals, content: { application/json: { schema: { $ref: "#/components/schemas/StudioDashboardEnvelope" } } } } + "401": { $ref: "#/components/responses/AuthenticationRequired" } + "403": { $ref: "#/components/responses/AccessDenied" } + "503": { $ref: "#/components/responses/StudioUnavailable" } + + /api/v1/studio/documents: + get: + operationId: listStudioDocuments + tags: [Documents] + summary: List working copies + description: | + Case/Reference/OpenQuestion/ProjectDecision은 서로 다른 table/aggregate에 + 존재한다. 공통 CRUD repository를 만들지 않고 query side에서 union projection을 + 구성한다(`StudioDocumentQueryService`). + + `cursor`는 normalized filter/sort와 결합된 opaque 값이다. 필터가 달라진 + cursor 재사용은 거절한다. + parameters: + - { $ref: "#/components/parameters/Query" } + - { $ref: "#/components/parameters/DocumentKind" } + - { $ref: "#/components/parameters/PublicationStatus" } + - { $ref: "#/components/parameters/NextAction" } + - { $ref: "#/components/parameters/ProjectId" } + - { $ref: "#/components/parameters/DocumentSort" } + - { $ref: "#/components/parameters/Cursor" } + - { $ref: "#/components/parameters/Limit" } + responses: + "200": { description: Working-copy cursor page, content: { application/json: { schema: { $ref: "#/components/schemas/DocumentPageEnvelope" } } } } + "400": { $ref: "#/components/responses/MalformedRequest" } + "401": { $ref: "#/components/responses/AuthenticationRequired" } + "403": { $ref: "#/components/responses/AccessDenied" } + "422": { $ref: "#/components/responses/RequestValidationFailed" } + "503": { $ref: "#/components/responses/StudioUnavailable" } + post: + operationId: createStudioDocument + tags: [Documents] + summary: Create a working copy + description: 불완전한 초안도 생성할 수 있다. 생성은 Public Projection을 변경하지 않는다. + parameters: + - { $ref: "#/components/parameters/IdempotencyKey" } + - { $ref: "#/components/parameters/CsrfToken" } + requestBody: { required: true, content: { application/json: { schema: { $ref: "#/components/schemas/CreateDocumentInput" } } } } + responses: + "201": + description: Created working copy + headers: { Idempotency-Replayed: { $ref: "#/components/headers/IdempotencyReplayed" } } + content: { application/json: { schema: { $ref: "#/components/schemas/WorkingCopyEnvelope" } } } + "400": { $ref: "#/components/responses/MalformedRequest" } + "401": { $ref: "#/components/responses/AuthenticationRequired" } + "403": { $ref: "#/components/responses/AccessDenied" } + "409": { $ref: "#/components/responses/CommandConflict" } + "422": { $ref: "#/components/responses/RequestValidationFailed" } + "503": { $ref: "#/components/responses/StudioUnavailable" } + + /api/v1/studio/documents/{documentId}: + parameters: [{ $ref: "#/components/parameters/DocumentId" }] + get: + operationId: getStudioDocument + tags: [Documents] + summary: Get a working copy and its current state + responses: + "200": { description: Working-copy detail, content: { application/json: { schema: { $ref: "#/components/schemas/WorkingCopyDetailEnvelope" } } } } + "401": { $ref: "#/components/responses/AuthenticationRequired" } + "403": { $ref: "#/components/responses/AccessDenied" } + "404": { $ref: "#/components/responses/DocumentNotFound" } + "503": { $ref: "#/components/responses/StudioUnavailable" } + put: + operationId: saveStudioDocument + tags: [Documents] + summary: Save a full working copy + description: | + 편집 가능한 content field만 저장한다. lifecycle 전이는 명시적인 Domain + Action이 담당한다. 저장은 Public Projection을 변경하지 않는다. + + `expectedVersion` 불일치는 `VERSION_CONFLICT`이며 응답 `error.details` + (`VersionConflictDetails`)의 `latestDocument`로 현재 상태를 함께 제공한다. + parameters: + - { $ref: "#/components/parameters/IdempotencyKey" } + - { $ref: "#/components/parameters/CsrfToken" } + requestBody: { required: true, content: { application/json: { schema: { $ref: "#/components/schemas/SaveDocumentCommand" } } } } + responses: + "200": + description: Saved working-copy detail + headers: { Idempotency-Replayed: { $ref: "#/components/headers/IdempotencyReplayed" } } + content: { application/json: { schema: { $ref: "#/components/schemas/WorkingCopyDetailEnvelope" } } } + "400": { $ref: "#/components/responses/MalformedRequest" } + "401": { $ref: "#/components/responses/AuthenticationRequired" } + "403": { $ref: "#/components/responses/AccessDenied" } + "404": { $ref: "#/components/responses/DocumentNotFound" } + "409": { $ref: "#/components/responses/CommandConflict" } + "422": { $ref: "#/components/responses/RequestValidationFailed" } + "503": { $ref: "#/components/responses/StudioUnavailable" } + + /api/v1/studio/documents/{documentId}/validate: + parameters: [{ $ref: "#/components/parameters/DocumentId" }] + post: + operationId: validateStudioDocument + tags: [Validation] + summary: Validate a saved working copy + description: | + 단순 request validation이 아니다. 다음 체인을 모두 수행한다. + + ```text + Schema/Input Validation + → 유형별 Domain Validation + → 관계/Project/Topic 존재 검증 + → Asset READY 검증 + → Slug/Route 충돌 검증 + → Publication Validation + ``` + + 결과는 일급 artifact인 `ValidationReport`로 영속되며 `validationId`로 + `createStudioPreview`와 `publishStudioDocument`가 이를 참조한다. + parameters: + - { $ref: "#/components/parameters/IdempotencyKey" } + - { $ref: "#/components/parameters/CsrfToken" } + requestBody: { required: true, content: { application/json: { schema: { $ref: "#/components/schemas/ValidateDocumentCommand" } } } } + responses: + "200": + description: Validation report + headers: { Idempotency-Replayed: { $ref: "#/components/headers/IdempotencyReplayed" } } + content: { application/json: { schema: { $ref: "#/components/schemas/ValidationReportEnvelope" } } } + "400": { $ref: "#/components/responses/MalformedRequest" } + "401": { $ref: "#/components/responses/AuthenticationRequired" } + "403": { $ref: "#/components/responses/AccessDenied" } + "404": { $ref: "#/components/responses/DocumentNotFound" } + "409": { $ref: "#/components/responses/CommandConflict" } + "422": { $ref: "#/components/responses/RequestValidationFailed" } + "503": { $ref: "#/components/responses/StudioUnavailable" } + + /api/v1/studio/documents/{documentId}/preview: + parameters: [{ $ref: "#/components/parameters/DocumentId" }] + get: + operationId: getCurrentStudioPreview + tags: [Preview] + summary: Get the latest preview and its computed state + description: | + Preview 상태(`CURRENT`/`STALE`/`EXPIRED`)는 서버가 계산한다. + anonymous `/preview/{token}` 계약은 폐기되었다. Preview는 인증된 + Studio API로만 조회한다. + responses: + "200": { description: Preview detail, content: { application/json: { schema: { $ref: "#/components/schemas/PreviewDetailEnvelope" } } } } + "401": { $ref: "#/components/responses/AuthenticationRequired" } + "403": { $ref: "#/components/responses/AccessDenied" } + "404": { $ref: "#/components/responses/PreviewNotFound" } + "503": { $ref: "#/components/responses/StudioUnavailable" } + post: + operationId: createStudioPreview + tags: [Preview] + summary: Create a public-layout preview + description: | + 저장된 working version + validationId + dependency revision을 묶어 + `PublicRenderModel` snapshot을 만든다. Preview는 Public과 동일한 + semantic renderer와 Asset resolver를 사용한다. + parameters: + - { $ref: "#/components/parameters/IdempotencyKey" } + - { $ref: "#/components/parameters/CsrfToken" } + requestBody: { required: true, content: { application/json: { schema: { $ref: "#/components/schemas/CreatePreviewCommand" } } } } + responses: + "201": + description: Created preview + headers: { Idempotency-Replayed: { $ref: "#/components/headers/IdempotencyReplayed" } } + content: { application/json: { schema: { $ref: "#/components/schemas/PublicPreviewEnvelope" } } } + "400": { $ref: "#/components/responses/MalformedRequest" } + "401": { $ref: "#/components/responses/AuthenticationRequired" } + "403": { $ref: "#/components/responses/AccessDenied" } + "404": { $ref: "#/components/responses/DocumentNotFound" } + "409": { $ref: "#/components/responses/CommandConflict" } + "422": { $ref: "#/components/responses/PreviewRejected" } + "503": { $ref: "#/components/responses/StudioUnavailable" } + + /api/v1/studio/documents/{documentId}/publish: + parameters: [{ $ref: "#/components/parameters/DocumentId" }] + post: + operationId: publishStudioDocument + tags: [Publication] + summary: Publish or republish a validated preview + description: | + 하나의 transaction으로 다음을 수행한다. + + ```text + 1. source version lock/check + 2. validationId / current dependency revision 검증 + 3. previewId / current dependency revision 검증 + 4. warning acknowledgement 검증 + 5. 유형별 publication validation + 6. Publication Event 생성 (PUBLISHED | REPUBLISHED) + 7. Publication Snapshot 생성 (immutable) + 8. public_resource_projection 교체 + 9. public_route 교체 / alias 처리 + 10. public relation/tag/project projection 교체 + 11. published Asset reference 교체 + 12. Publication Aggregate 갱신 + 13. commit + ``` + + 재시도가 중복 Publication Event를 만들면 안 된다. `Idempotency-Key`로 + 최초 결과를 재생한다. + parameters: + - { $ref: "#/components/parameters/IdempotencyKey" } + - { $ref: "#/components/parameters/CsrfToken" } + requestBody: { required: true, content: { application/json: { schema: { $ref: "#/components/schemas/PublishDocumentCommand" } } } } + responses: + "200": + description: Publication aggregate and immutable event + headers: { Idempotency-Replayed: { $ref: "#/components/headers/IdempotencyReplayed" } } + content: { application/json: { schema: { $ref: "#/components/schemas/PublishResultEnvelope" } } } + "400": { $ref: "#/components/responses/MalformedRequest" } + "401": { $ref: "#/components/responses/AuthenticationRequired" } + "403": { $ref: "#/components/responses/AccessDenied" } + "404": { $ref: "#/components/responses/DocumentNotFound" } + "409": { $ref: "#/components/responses/CommandConflict" } + "422": { $ref: "#/components/responses/PublishRejected" } + "503": { $ref: "#/components/responses/StudioUnavailable" } + + /api/v1/studio/publications: + get: + operationId: listStudioPublications + tags: [Publication] + summary: List immutable publication events + description: Events are ordered by occurredAt DESC, then publicationEventId. + parameters: + - { $ref: "#/components/parameters/Query" } + - { $ref: "#/components/parameters/PublicationEventType" } + - { $ref: "#/components/parameters/Cursor" } + - { $ref: "#/components/parameters/Limit" } + responses: + "200": { description: Publication cursor page, content: { application/json: { schema: { $ref: "#/components/schemas/PublicationPageEnvelope" } } } } + "400": { $ref: "#/components/responses/MalformedRequest" } + "401": { $ref: "#/components/responses/AuthenticationRequired" } + "403": { $ref: "#/components/responses/AccessDenied" } + "422": { $ref: "#/components/responses/RequestValidationFailed" } + "503": { $ref: "#/components/responses/StudioUnavailable" } + + /api/v1/studio/publications/{publicationId}/unpublish: + parameters: [{ $ref: "#/components/parameters/PublicationId" }] + post: + operationId: unpublishStudioPublication + tags: [Publication] + summary: Unpublish the current publication + description: | + `UNPUBLISHED` Event를 생성하고 current projection을 `WITHDRAWN`으로 바꾼다. + canonical route ownership/history는 보존한다. + 과거 Snapshot은 삭제하거나 재계산하지 않는다. + + Backend 내부에서 working copy가 `DRAFT`로 되돌아가는 lifecycle 전이는 + 유지하되 이 API로 직접 노출하지 않는다. 응답은 + `publicationStatus=UNPUBLISHED`와 다음 `nextAction`으로 표현한다. + parameters: + - { $ref: "#/components/parameters/IdempotencyKey" } + - { $ref: "#/components/parameters/CsrfToken" } + requestBody: { required: true, content: { application/json: { schema: { $ref: "#/components/schemas/UnpublishCommand" } } } } + responses: + "200": + description: Updated publication aggregate and event + headers: { Idempotency-Replayed: { $ref: "#/components/headers/IdempotencyReplayed" } } + content: { application/json: { schema: { $ref: "#/components/schemas/PublishResultEnvelope" } } } + "400": { $ref: "#/components/responses/MalformedRequest" } + "401": { $ref: "#/components/responses/AuthenticationRequired" } + "403": { $ref: "#/components/responses/AccessDenied" } + "404": { $ref: "#/components/responses/PublicationNotFound" } + "409": { $ref: "#/components/responses/CommandConflict" } + "422": { $ref: "#/components/responses/RequestValidationFailed" } + "503": { $ref: "#/components/responses/StudioUnavailable" } + + /api/v1/studio/publications/{publicationEventId}/preview: + parameters: [{ $ref: "#/components/parameters/PublicationEventId" }] + get: + operationId: getStudioPublicationSnapshot + tags: [Publication] + summary: Get an immutable publication snapshot + description: | + `PUBLISHED`/`REPUBLISHED` Event 시점에 저장된 불변 `PublicRenderModel`을 + 반환한다. 현재 Working Copy나 현재 Projection에서 재계산하지 않는다. + + `UNPUBLISHED` Event는 자체 snapshot을 갖지 않는다. 이 경우 + `sourcePublishedEventId`가 가리키는 마지막 공개 Snapshot을 사용한다. + responses: + "200": { description: Publication snapshot, content: { application/json: { schema: { $ref: "#/components/schemas/PublicationSnapshotEnvelope" } } } } + "401": { $ref: "#/components/responses/AuthenticationRequired" } + "403": { $ref: "#/components/responses/AccessDenied" } + "404": { $ref: "#/components/responses/PublicationSnapshotNotFound" } + "503": { $ref: "#/components/responses/StudioUnavailable" } + + /api/v1/studio/catalog: + get: + operationId: listStudioCatalog + tags: [Catalog] + summary: List catalog entries for editor pickers + description: | + Editor picker가 사용하는 통합 read API다. Backend source는 다음과 같다. + + ```text + TOPIC → topic capability + PROJECT → project capability + RELATION → Case/Reference/Question/ProjectDecision 중 연결 가능한 대상 + EVIDENCE → resolution/decision 근거로 사용할 수 있는 공개 기록 + ``` + + Asset 검색/업로드/metadata는 Catalog가 아니라 `/api/v1/studio/assets`가 + 소유한다. + parameters: + - { $ref: "#/components/parameters/CatalogType" } + - { $ref: "#/components/parameters/Query" } + - { $ref: "#/components/parameters/Cursor" } + - { $ref: "#/components/parameters/Limit" } + responses: + "200": { description: Catalog cursor page, content: { application/json: { schema: { $ref: "#/components/schemas/CatalogPageEnvelope" } } } } + "400": { $ref: "#/components/responses/MalformedRequest" } + "401": { $ref: "#/components/responses/AuthenticationRequired" } + "403": { $ref: "#/components/responses/AccessDenied" } + "422": { $ref: "#/components/responses/RequestValidationFailed" } + "503": { $ref: "#/components/responses/StudioUnavailable" } + + /api/v1/studio/assets: + get: + operationId: listStudioAssets + tags: [Assets] + summary: List assets for the library and the editor picker + parameters: + - { $ref: "#/components/parameters/Query" } + - { $ref: "#/components/parameters/AssetKindFilter" } + - { $ref: "#/components/parameters/AssetStatusFilter" } + - { $ref: "#/components/parameters/Cursor" } + - { $ref: "#/components/parameters/Limit" } + responses: + "200": { description: Asset cursor page, content: { application/json: { schema: { $ref: "#/components/schemas/AssetPageEnvelope" } } } } + "400": { $ref: "#/components/responses/MalformedRequest" } + "401": { $ref: "#/components/responses/AuthenticationRequired" } + "403": { $ref: "#/components/responses/AccessDenied" } + "422": { $ref: "#/components/responses/RequestValidationFailed" } + "503": { $ref: "#/components/responses/StudioUnavailable" } + post: + operationId: uploadStudioAsset + tags: [Assets] + summary: Upload an image, diagram, or attachment + description: | + MVP 업로드는 `multipart/form-data`를 사용한다. 향후 presigned/resumable로 + 교체하더라도 같은 Asset port 뒤에서 처리한다. + + Backend는 확장자를 신뢰하지 않는다. MIME/type 검증과 크기 제한을 적용하고 + 검사에 실패하면 `REJECTED` 또는 `QUARANTINED`로 저장한다. `READY` 전환은 + 검증 완료 후에만 일어난다. + + `image/svg+xml`을 지원하지만 업로드 원문을 HTML에 inline하지 않는다. + Public/Preview는 검증된 delivery URL을 ``로 렌더링한다. + parameters: + - { $ref: "#/components/parameters/IdempotencyKey" } + - { $ref: "#/components/parameters/CsrfToken" } + requestBody: + required: true + content: + multipart/form-data: + schema: { $ref: "#/components/schemas/AssetUploadForm" } + encoding: + file: { contentType: "image/png, image/jpeg, image/webp, image/gif, image/svg+xml, application/pdf" } + responses: + "201": + description: Stored asset + headers: { Idempotency-Replayed: { $ref: "#/components/headers/IdempotencyReplayed" } } + content: { application/json: { schema: { $ref: "#/components/schemas/AssetEnvelope" } } } + "400": { $ref: "#/components/responses/MalformedRequest" } + "401": { $ref: "#/components/responses/AuthenticationRequired" } + "403": { $ref: "#/components/responses/AccessDenied" } + "409": { $ref: "#/components/responses/CommandConflict" } + "413": { $ref: "#/components/responses/PayloadTooLarge" } + "415": { $ref: "#/components/responses/UnsupportedMediaType" } + "422": { $ref: "#/components/responses/RequestValidationFailed" } + "503": { $ref: "#/components/responses/StudioUnavailable" } + + /api/v1/studio/assets/{assetId}: + parameters: [{ $ref: "#/components/parameters/AssetId" }] + get: + operationId: getStudioAsset + tags: [Assets] + summary: Get an asset with its usage + responses: + "200": { description: Asset detail, content: { application/json: { schema: { $ref: "#/components/schemas/AssetDetailEnvelope" } } } } + "401": { $ref: "#/components/responses/AuthenticationRequired" } + "403": { $ref: "#/components/responses/AccessDenied" } + "404": { $ref: "#/components/responses/AssetNotFound" } + "503": { $ref: "#/components/responses/StudioUnavailable" } + put: + operationId: updateStudioAsset + tags: [Assets] + summary: Update asset metadata + description: | + `altText`, `decorative`, `kind`와 `READY ↔ ARCHIVED` 전환만 허용한다. + `REJECTED`/`QUARANTINED`는 서버 검증 결과이며 클라이언트가 지정할 수 없다. + parameters: + - { $ref: "#/components/parameters/IdempotencyKey" } + - { $ref: "#/components/parameters/CsrfToken" } + requestBody: { required: true, content: { application/json: { schema: { $ref: "#/components/schemas/UpdateAssetCommand" } } } } + responses: + "200": + description: Updated asset + headers: { Idempotency-Replayed: { $ref: "#/components/headers/IdempotencyReplayed" } } + content: { application/json: { schema: { $ref: "#/components/schemas/AssetEnvelope" } } } + "400": { $ref: "#/components/responses/MalformedRequest" } + "401": { $ref: "#/components/responses/AuthenticationRequired" } + "403": { $ref: "#/components/responses/AccessDenied" } + "404": { $ref: "#/components/responses/AssetNotFound" } + "409": { $ref: "#/components/responses/CommandConflict" } + "422": { $ref: "#/components/responses/AssetRejected" } + "503": { $ref: "#/components/responses/StudioUnavailable" } + delete: + operationId: deleteStudioAsset + tags: [Assets] + summary: Delete an unused asset + description: | + 공개 이력이 있는 Asset과 사용 중인 Asset은 hard delete하지 않는다. + 두 경우 모두 `ASSET_IN_USE`로 거절하고 `ARCHIVED` 전환을 사용한다. + parameters: + - { $ref: "#/components/parameters/IdempotencyKey" } + - { $ref: "#/components/parameters/CsrfToken" } + responses: + "204": { description: Deleted } + "401": { $ref: "#/components/responses/AuthenticationRequired" } + "403": { $ref: "#/components/responses/AccessDenied" } + "404": { $ref: "#/components/responses/AssetNotFound" } + "409": { $ref: "#/components/responses/CommandConflict" } + "503": { $ref: "#/components/responses/StudioUnavailable" } + +components: + securitySchemes: + sessionCookie: + type: apiKey + in: cookie + name: TECHLOG_SESSION + description: | + Backend Session Cookie. `TECH_LOG_ADMIN` 권한이 필요하다. + mutating operation은 추가로 `X-CSRF-TOKEN` header를 요구한다. + + parameters: + DocumentId: { name: documentId, in: path, required: true, schema: { type: string, format: uuid } } + PublicationId: { name: publicationId, in: path, required: true, schema: { type: string, format: uuid } } + PublicationEventId: { name: publicationEventId, in: path, required: true, schema: { type: string, format: uuid } } + AssetId: { name: assetId, in: path, required: true, schema: { type: string, format: uuid } } + IdempotencyKey: + name: Idempotency-Key + in: header + required: true + description: | + 동일 key + 동일 normalized request는 최초 결과를 재생한다. + 동일 key + 다른 request는 `IDEMPOTENCY_KEY_REUSED` conflict다. + key 원문을 로그에 남길 때 요청 본문의 민감정보가 함께 기록되지 않게 한다. + schema: { type: string, minLength: 1, maxLength: 200 } + CsrfToken: + name: X-CSRF-TOKEN + in: header + required: true + description: "`getStudioSession`이 발급한 CSRF 토큰." + schema: { type: string, minLength: 1, maxLength: 200 } + Query: { name: q, in: query, description: Free-text query normalized as part of the opaque cursor, schema: { type: string, maxLength: 100 } } + Cursor: { name: cursor, in: query, description: Opaque cursor bound to normalized filters and sort, schema: { type: string, minLength: 1, maxLength: 2000 } } + Limit: { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 100, default: 20 } } + DocumentKind: { name: kind, in: query, schema: { $ref: "#/components/schemas/RecordKind" } } + PublicationStatus: { name: publicationStatus, in: query, schema: { $ref: "#/components/schemas/PublicationStatus" } } + NextAction: { name: nextAction, in: query, schema: { $ref: "#/components/schemas/NextAction" } } + ProjectId: { name: projectId, in: query, schema: { type: string, format: uuid } } + DocumentSort: { name: sort, in: query, schema: { type: string, enum: [UPDATED_DESC, UPDATED_ASC, TITLE_ASC], default: UPDATED_DESC } } + PublicationEventType: { name: type, in: query, schema: { $ref: "#/components/schemas/PublicationEventType" } } + CatalogType: { name: type, in: query, required: true, schema: { $ref: "#/components/schemas/CatalogEntryType" } } + AssetKindFilter: { name: kind, in: query, schema: { $ref: "#/components/schemas/AssetKind" } } + AssetStatusFilter: { name: managementStatus, in: query, schema: { $ref: "#/components/schemas/AssetManagementStatus" } } + + headers: + IdempotencyReplayed: { description: True when the original result was replayed, schema: { type: boolean } } + + responses: + MalformedRequest: { description: Malformed request, x-error-codes: [REQUEST_VALIDATION_FAILED], content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } } + AuthenticationRequired: { description: Authentication required, x-error-codes: [AUTHENTICATION_REQUIRED], content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } } + AccessDenied: { description: Studio access denied, x-error-codes: [STUDIO_ACCESS_DENIED], content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } } + DocumentNotFound: { description: Document not found, x-error-codes: [DOCUMENT_NOT_FOUND], content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } } + PreviewNotFound: { description: Document or preview not found, x-error-codes: [DOCUMENT_NOT_FOUND, PREVIEW_NOT_FOUND], content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } } + PublicationNotFound: { description: Publication not found, x-error-codes: [PUBLICATION_NOT_FOUND], content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } } + PublicationSnapshotNotFound: { description: Publication event or snapshot not found, x-error-codes: [PUBLICATION_EVENT_NOT_FOUND, PUBLICATION_SNAPSHOT_NOT_FOUND], content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } } + AssetNotFound: { description: Asset not found, x-error-codes: [ASSET_NOT_FOUND], content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } } + CommandConflict: + description: | + Command conflicts with current state, freshness, or idempotency. + + `ASSET_IN_USE`는 사용 중이거나 공개 이력이 있는 Asset의 hard delete 시도다. + x-error-codes: [VERSION_CONFLICT, PUBLICATION_CONFLICT, VALIDATION_STALE, PREVIEW_STALE, PREVIEW_EXPIRED, IDEMPOTENCY_KEY_REUSED, ASSET_IN_USE] + content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } + RequestValidationFailed: { description: Request validation failed, x-error-codes: [REQUEST_VALIDATION_FAILED], content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } } + PreviewRejected: + description: Preview 생성이 도메인 규칙으로 거절되었다 + x-error-codes: [REQUEST_VALIDATION_FAILED, DOCUMENT_VALIDATION_FAILED, ASSET_NOT_READY, ASSET_QUARANTINED] + content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } + PublishRejected: + description: | + Publication validation이 실패했다. + + `WARNING_ACKNOWLEDGEMENT_REQUIRED`는 `acknowledgedWarningCodes`가 + 현재 Validation의 WARNING 집합을 덮지 못한 경우다. + x-error-codes: [REQUEST_VALIDATION_FAILED, DOCUMENT_VALIDATION_FAILED, WARNING_ACKNOWLEDGEMENT_REQUIRED, ASSET_NOT_READY, ASSET_QUARANTINED] + content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } + AssetRejected: + description: Asset metadata 변경이 거절되었다 + x-error-codes: [REQUEST_VALIDATION_FAILED, ASSET_NOT_READY, ASSET_QUARANTINED] + content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } + PayloadTooLarge: { description: Upload exceeds the configured size limit, x-error-codes: [PAYLOAD_TOO_LARGE], content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } } + UnsupportedMediaType: { description: Unsupported media type, x-error-codes: [UNSUPPORTED_MEDIA_TYPE], content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } } + StudioUnavailable: { description: Studio unavailable, x-error-codes: [STUDIO_UNAVAILABLE], content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } } + + schemas: + # ------------------------------------------------------------- envelope + # wire format은 봉투다 (ADR-006). payload 스키마는 그대로 두고 + # 응답만 Envelope으로 감싼다. + ResponseMeta: + type: object + additionalProperties: false + required: [requestId, traceId] + properties: + requestId: { type: string, minLength: 1, maxLength: 200 } + traceId: { type: string, minLength: 1, maxLength: 200 } + correlationId: { type: [string, "null"], maxLength: 200 } + page: { type: ["object", "null"], additionalProperties: true, description: "Studio는 body 안 cursor 페이지네이션을 쓰므로 항상 null이다. 백엔드 템플릿의 ResponseMeta record가 이 필드를 직렬화한다." } + ApiError: + type: object + additionalProperties: false + required: [code, category, message, retryable] + properties: + code: + type: string + enum: [AUTHENTICATION_REQUIRED, STUDIO_ACCESS_DENIED, DOCUMENT_NOT_FOUND, VERSION_CONFLICT, + REQUEST_VALIDATION_FAILED, DOCUMENT_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] + category: + type: string + enum: [VALIDATION, AUTH, AUTHZ, NOT_FOUND, CONFLICT, RATE_LIMIT, + TRANSIENT_DEPENDENCY, PERMANENT_DEPENDENCY, DATA_INTEGRITY, INTERNAL] + message: { type: string, minLength: 1, maxLength: 5000 } + retryable: { type: boolean } + details: + oneOf: + - $ref: "#/components/schemas/ValidationErrorDetails" + - $ref: "#/components/schemas/VersionConflictDetails" + - $ref: "#/components/schemas/PublicationConflictDetails" + - type: "null" + ErrorEnvelope: + type: object + additionalProperties: false + required: [success, error, meta] + properties: + success: { type: boolean, const: false } + error: { $ref: "#/components/schemas/ApiError" } + meta: { $ref: "#/components/schemas/ResponseMeta" } + ValidationErrorDetails: + type: object + additionalProperties: false + required: [fieldErrors] + properties: + fieldErrors: { type: array, maxItems: 200, items: { $ref: "#/components/schemas/FieldError" } } + VersionConflictDetails: + type: object + additionalProperties: false + required: [latestDocument] + properties: + latestDocument: { $ref: "#/components/schemas/WorkingCopyDetail" } + conflictingFields: + type: array + uniqueItems: true + maxItems: 200 + items: { type: string, pattern: "^(?:/(?:[^~/]|~0|~1)*)*$" } + PublicationConflictDetails: + type: object + additionalProperties: false + required: [latestPublication] + properties: + latestPublication: { $ref: "#/components/schemas/PublicationAggregate" } + StudioSessionEnvelope: + type: object + additionalProperties: false + required: [success, data, meta] + properties: + success: { type: boolean, const: true } + data: { $ref: "#/components/schemas/StudioSession" } + meta: { $ref: "#/components/schemas/ResponseMeta" } + StudioDashboardEnvelope: + type: object + additionalProperties: false + required: [success, data, meta] + properties: + success: { type: boolean, const: true } + data: { $ref: "#/components/schemas/StudioDashboard" } + meta: { $ref: "#/components/schemas/ResponseMeta" } + DocumentPageEnvelope: + type: object + additionalProperties: false + required: [success, data, meta] + properties: + success: { type: boolean, const: true } + data: { $ref: "#/components/schemas/DocumentPage" } + meta: { $ref: "#/components/schemas/ResponseMeta" } + WorkingCopyDetailEnvelope: + type: object + additionalProperties: false + required: [success, data, meta] + properties: + success: { type: boolean, const: true } + data: { $ref: "#/components/schemas/WorkingCopyDetail" } + meta: { $ref: "#/components/schemas/ResponseMeta" } + WorkingCopyEnvelope: + type: object + additionalProperties: false + required: [success, data, meta] + properties: + success: { type: boolean, const: true } + data: { $ref: "#/components/schemas/WorkingCopy" } + meta: { $ref: "#/components/schemas/ResponseMeta" } + ValidationReportEnvelope: + type: object + additionalProperties: false + required: [success, data, meta] + properties: + success: { type: boolean, const: true } + data: { $ref: "#/components/schemas/ValidationReport" } + meta: { $ref: "#/components/schemas/ResponseMeta" } + PreviewDetailEnvelope: + type: object + additionalProperties: false + required: [success, data, meta] + properties: + success: { type: boolean, const: true } + data: { $ref: "#/components/schemas/PreviewDetail" } + meta: { $ref: "#/components/schemas/ResponseMeta" } + PublicPreviewEnvelope: + type: object + additionalProperties: false + required: [success, data, meta] + properties: + success: { type: boolean, const: true } + data: { $ref: "#/components/schemas/PublicPreview" } + meta: { $ref: "#/components/schemas/ResponseMeta" } + PublishResultEnvelope: + type: object + additionalProperties: false + required: [success, data, meta] + properties: + success: { type: boolean, const: true } + data: { $ref: "#/components/schemas/PublishResult" } + meta: { $ref: "#/components/schemas/ResponseMeta" } + PublicationPageEnvelope: + type: object + additionalProperties: false + required: [success, data, meta] + properties: + success: { type: boolean, const: true } + data: { $ref: "#/components/schemas/PublicationPage" } + meta: { $ref: "#/components/schemas/ResponseMeta" } + PublicationSnapshotEnvelope: + type: object + additionalProperties: false + required: [success, data, meta] + properties: + success: { type: boolean, const: true } + data: { $ref: "#/components/schemas/PublicationSnapshot" } + meta: { $ref: "#/components/schemas/ResponseMeta" } + CatalogPageEnvelope: + type: object + additionalProperties: false + required: [success, data, meta] + properties: + success: { type: boolean, const: true } + data: { $ref: "#/components/schemas/CatalogPage" } + meta: { $ref: "#/components/schemas/ResponseMeta" } + AssetPageEnvelope: + type: object + additionalProperties: false + required: [success, data, meta] + properties: + success: { type: boolean, const: true } + data: { $ref: "#/components/schemas/AssetPage" } + meta: { $ref: "#/components/schemas/ResponseMeta" } + AssetDetailEnvelope: + type: object + additionalProperties: false + required: [success, data, meta] + properties: + success: { type: boolean, const: true } + data: { $ref: "#/components/schemas/AssetDetail" } + meta: { $ref: "#/components/schemas/ResponseMeta" } + AssetEnvelope: + type: object + additionalProperties: false + required: [success, data, meta] + properties: + success: { type: boolean, const: true } + data: { $ref: "#/components/schemas/Asset" } + meta: { $ref: "#/components/schemas/ResponseMeta" } + # ---------------------------------------------------------------- session + StudioSession: + type: object + additionalProperties: false + required: [authenticated, displayName, roles, csrfToken, csrfHeaderName] + properties: + authenticated: { type: boolean } + displayName: { type: string, minLength: 1, maxLength: 120 } + roles: { type: array, uniqueItems: true, maxItems: 20, items: { type: string, minLength: 1, maxLength: 60 } } + csrfToken: { type: string, minLength: 1, maxLength: 200 } + csrfHeaderName: { type: string, const: X-CSRF-TOKEN } + + # ------------------------------------------------------------ enumerations + RecordKind: + type: string + enum: [CASE, REFERENCE, QUESTION, PROJECT_DECISION] + description: | + Studio 편집 대상 유형. API projection discriminator이며 Domain Aggregate가 + 아니다. `PROJECT_DECISION`은 `ProjectDecision` capability로 dispatch된다. + PublicationStatus: { type: string, enum: [NEVER_PUBLISHED, PUBLISHED, UNPUBLISHED] } + NextAction: + type: string + enum: [CONTINUE_EDITING, VALIDATE, FIX_VALIDATION, CREATE_PREVIEW, PUBLISH, NONE] + description: | + 서버가 계산하는 Studio projection이다. Domain state machine이 아니며 + `workflow_status` 같은 domain 컬럼에 저장하지 않는다. + + # --------------------------------------------------------- shared fragments + RelationInput: + type: object + additionalProperties: false + required: [id, targetId, reason, order] + properties: + id: { type: [string, "null"], format: uuid } + targetId: { type: [string, "null"], format: uuid } + reason: { type: string, maxLength: 100000 } + order: { type: integer, minimum: 0 } + Relation: + type: object + additionalProperties: false + required: [id, targetId, reason, order] + properties: + id: { type: string, format: uuid } + targetId: { type: string, format: uuid } + reason: { type: string, maxLength: 100000 } + order: { type: integer, minimum: 0 } + OrderedText: + type: object + additionalProperties: false + required: [id, text, order] + properties: + id: { type: string, format: uuid } + text: { type: string, minLength: 1, maxLength: 100000 } + order: { type: integer, minimum: 0 } + ReferenceRule: + type: object + additionalProperties: false + required: [id, title, body, order] + properties: + id: { type: string, format: uuid } + title: { type: string, minLength: 1, maxLength: 120 } + body: { type: string, minLength: 1, maxLength: 100000 } + order: { type: integer, minimum: 0 } + QuestionOption: + type: object + additionalProperties: false + required: [id, title, description, order] + properties: + id: { type: string, format: uuid } + title: { type: string, minLength: 1, maxLength: 120 } + description: { type: string, maxLength: 100000 } + order: { type: integer, minimum: 0 } + QuestionResolution: + type: object + additionalProperties: false + required: [summary, evidenceTargetId, linkLabel] + properties: + summary: { type: string, maxLength: 100000 } + evidenceTargetId: { type: [string, "null"], format: uuid } + linkLabel: { type: string, maxLength: 120 } + + # -------------------------------------------------------- working copy input + WorkingCopyInputBase: + type: object + description: | + 불완전한 초안도 저장할 수 있어야 하므로 필드는 required이되 빈 값과 null을 + 허용한다. 게시 가능 여부는 `validateStudioDocument`가 판단한다. + required: [kind, title, slug, summary, topicId, projectId, relations] + properties: + kind: { $ref: "#/components/schemas/RecordKind" } + title: { type: string, maxLength: 120 } + slug: + oneOf: + - { type: string, const: "" } + - { type: string, minLength: 3, maxLength: 100, pattern: "^[a-z0-9]+(?:-[a-z0-9]+)*$" } + summary: { type: string, maxLength: 300 } + topicId: { type: [string, "null"], format: uuid } + projectId: + type: [string, "null"] + format: uuid + description: "`kind=PROJECT_DECISION`은 게시 시점에 non-null이어야 한다. 저장 시점에는 강제하지 않는다." + relations: { type: array, maxItems: 20, items: { $ref: "#/components/schemas/RelationInput" } } + CaseInput: + unevaluatedProperties: false + allOf: + - { $ref: "#/components/schemas/WorkingCopyInputBase" } + - type: object + required: [kind, problem, conclusion, environment, reproduction, lastVerifiedOn, bodyMarkdown] + properties: + kind: { type: string, enum: [CASE] } + problem: { type: string, maxLength: 100000 } + conclusion: { type: string, maxLength: 100000 } + environment: { type: string, maxLength: 100000 } + reproduction: { type: string, maxLength: 100000 } + lastVerifiedOn: { type: [string, "null"], format: date } + bodyMarkdown: + type: string + maxLength: 100000 + description: | + Markdown 원문. Asset은 `:::evidence key=""` directive로 + 참조한다. object storage URL을 원문에 직접 저장하지 않는다. + ReferenceInput: + unevaluatedProperties: false + allOf: + - { $ref: "#/components/schemas/WorkingCopyInputBase" } + - type: object + required: [kind, purpose, rules, applyWhen, exceptions, examples, verifiedOn] + properties: + kind: { type: string, enum: [REFERENCE] } + purpose: { type: string, maxLength: 100000 } + rules: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/ReferenceRule" } } + applyWhen: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } } + exceptions: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } } + examples: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } } + verifiedOn: { type: [string, "null"], format: date } + QuestionInput: + unevaluatedProperties: false + allOf: + - { $ref: "#/components/schemas/WorkingCopyInputBase" } + - type: object + required: [kind, questionStatus, facts, assumptions, unknowns, constraints, options, nextValidation, resolution] + properties: + kind: { type: string, enum: [QUESTION] } + questionStatus: + type: [string, "null"] + enum: [OPEN, RESOLVED, null] + description: | + Backend Inquiry lifecycle의 축약 view다. + `OPEN`은 Domain의 `OPEN`/`INVESTIGATING`/`PAUSED`를 모두 대표하므로 + 저장 시 Domain 상태를 `OPEN`으로 덮어쓰지 않는다. + `RESOLVED`로의 변경만 Resolve command로 해석하며 기존 resolve + invariant를 통과해야 한다. + facts: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } } + assumptions: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } } + unknowns: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } } + constraints: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } } + options: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/QuestionOption" } } + nextValidation: { type: string, maxLength: 100000 } + resolution: + oneOf: + - { $ref: "#/components/schemas/QuestionResolution" } + - { type: "null" } + ProjectDecisionInput: + unevaluatedProperties: false + allOf: + - { $ref: "#/components/schemas/WorkingCopyInputBase" } + - type: object + required: [kind, decisionStatus, decidedOn, statement, rationale, consequences] + properties: + kind: { type: string, enum: [PROJECT_DECISION] } + decisionStatus: + type: [string, "null"] + enum: [PROPOSED, ADOPTED, null] + description: | + UI 용어다. Backend Domain의 `ACCEPTED`/`ADOPTED` 명칭이 다르면 + mapper에서 변환하고 Domain enum을 UI 용어 때문에 변경하지 않는다. + `supersede`/`reject`는 secondary management 계약이 소유한다. + decidedOn: { type: [string, "null"], format: date } + statement: { type: string, maxLength: 100000 } + rationale: { type: string, maxLength: 100000 } + consequences: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } } + WorkingCopyInput: + oneOf: + - { $ref: "#/components/schemas/CaseInput" } + - { $ref: "#/components/schemas/ReferenceInput" } + - { $ref: "#/components/schemas/QuestionInput" } + - { $ref: "#/components/schemas/ProjectDecisionInput" } + discriminator: + propertyName: kind + mapping: + CASE: "#/components/schemas/CaseInput" + REFERENCE: "#/components/schemas/ReferenceInput" + QUESTION: "#/components/schemas/QuestionInput" + PROJECT_DECISION: "#/components/schemas/ProjectDecisionInput" + + # ------------------------------------------------------- working copy output + WorkingCopyBase: + allOf: + - { $ref: "#/components/schemas/WorkingCopyInputBase" } + - type: object + required: [id, version, relations, updatedAt] + properties: + id: + type: string + format: uuid + description: source aggregate id를 그대로 사용한다. 별도 Studio surrogate id를 만들지 않는다. + version: { type: integer, minimum: 1 } + relations: { type: array, maxItems: 20, items: { $ref: "#/components/schemas/Relation" } } + updatedAt: { type: string, format: date-time } + CaseWorkingCopy: + unevaluatedProperties: false + allOf: + - { $ref: "#/components/schemas/WorkingCopyBase" } + - type: object + required: [kind, problem, conclusion, environment, reproduction, lastVerifiedOn, bodyMarkdown] + properties: + kind: { type: string, enum: [CASE] } + problem: { type: string, maxLength: 100000 } + conclusion: { type: string, maxLength: 100000 } + environment: { type: string, maxLength: 100000 } + reproduction: { type: string, maxLength: 100000 } + lastVerifiedOn: { type: [string, "null"], format: date } + bodyMarkdown: { type: string, maxLength: 100000 } + ReferenceWorkingCopy: + unevaluatedProperties: false + allOf: + - { $ref: "#/components/schemas/WorkingCopyBase" } + - type: object + required: [kind, purpose, rules, applyWhen, exceptions, examples, verifiedOn] + properties: + kind: { type: string, enum: [REFERENCE] } + purpose: { type: string, maxLength: 100000 } + rules: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/ReferenceRule" } } + applyWhen: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } } + exceptions: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } } + examples: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } } + verifiedOn: { type: [string, "null"], format: date } + QuestionWorkingCopy: + unevaluatedProperties: false + allOf: + - { $ref: "#/components/schemas/WorkingCopyBase" } + - type: object + required: [kind, questionStatus, facts, assumptions, unknowns, constraints, options, nextValidation, resolution] + properties: + kind: { type: string, enum: [QUESTION] } + questionStatus: { type: [string, "null"], enum: [OPEN, RESOLVED, null] } + facts: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } } + assumptions: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } } + unknowns: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } } + constraints: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } } + options: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/QuestionOption" } } + nextValidation: { type: string, maxLength: 100000 } + resolution: + oneOf: + - { $ref: "#/components/schemas/QuestionResolution" } + - { type: "null" } + ProjectDecisionWorkingCopy: + unevaluatedProperties: false + allOf: + - { $ref: "#/components/schemas/WorkingCopyBase" } + - type: object + required: [kind, decisionStatus, decidedOn, statement, rationale, consequences] + properties: + kind: { type: string, enum: [PROJECT_DECISION] } + decisionStatus: { type: [string, "null"], enum: [PROPOSED, ADOPTED, null] } + decidedOn: { type: [string, "null"], format: date } + statement: { type: string, maxLength: 100000 } + rationale: { type: string, maxLength: 100000 } + consequences: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } } + WorkingCopy: + description: | + API union이다. DB에 `working_copy` 범용 테이블을 만들지 않는다. + + ```text + WorkingCopy + = CaseWorkingCopy + | ReferenceWorkingCopy + | QuestionWorkingCopy + | ProjectDecisionWorkingCopy + ``` + oneOf: + - { $ref: "#/components/schemas/CaseWorkingCopy" } + - { $ref: "#/components/schemas/ReferenceWorkingCopy" } + - { $ref: "#/components/schemas/QuestionWorkingCopy" } + - { $ref: "#/components/schemas/ProjectDecisionWorkingCopy" } + discriminator: + propertyName: kind + mapping: + CASE: "#/components/schemas/CaseWorkingCopy" + REFERENCE: "#/components/schemas/ReferenceWorkingCopy" + QUESTION: "#/components/schemas/QuestionWorkingCopy" + PROJECT_DECISION: "#/components/schemas/ProjectDecisionWorkingCopy" + + # -------------------------------------------------------------- commands + CreateDocumentInput: { $ref: "#/components/schemas/WorkingCopyInput" } + SaveDocumentCommand: + type: object + additionalProperties: false + required: [expectedVersion, document] + properties: + expectedVersion: { type: integer, minimum: 1 } + document: { $ref: "#/components/schemas/WorkingCopyInput" } + ValidateDocumentCommand: + type: object + additionalProperties: false + required: [expectedVersion] + properties: { expectedVersion: { type: integer, minimum: 1 } } + CreatePreviewCommand: + type: object + additionalProperties: false + required: [expectedVersion, validationId] + properties: + expectedVersion: { type: integer, minimum: 1 } + validationId: { type: string, format: uuid } + PublishDocumentCommand: + type: object + additionalProperties: false + required: [expectedVersion, validationId, previewId, acknowledgedWarningCodes] + properties: + expectedVersion: { type: integer, minimum: 1 } + validationId: { type: string, format: uuid } + previewId: { type: string, format: uuid } + acknowledgedWarningCodes: + type: array + uniqueItems: true + maxItems: 200 + items: { type: string, minLength: 1, maxLength: 100 } + description: 현재 Validation의 WARNING code 집합을 모두 덮지 못하면 `WARNING_ACKNOWLEDGEMENT_REQUIRED`로 거절한다. + UnpublishCommand: + type: object + additionalProperties: false + required: [expectedPublicationRevision] + properties: { expectedPublicationRevision: { type: integer, minimum: 1 } } + + # ------------------------------------------------------------- validation + ValidationIssue: + type: object + additionalProperties: false + required: [code, severity, path, message] + properties: + code: { type: string, minLength: 1, maxLength: 100 } + severity: { type: string, enum: [ERROR, WARNING] } + path: + type: string + pattern: "^(?:/(?:[^~/]|~0|~1)*)*$" + description: JSON Pointer to the affected field + message: { type: string, minLength: 1, maxLength: 1000 } + ValidationReport: + type: object + additionalProperties: false + description: | + 일급 application artifact다. 실행 결과를 그때그때 반환하고 버리지 않고 + `studio_validation`에 영속한다. + required: [validationId, documentId, validatedVersion, status, issues, validatedAt, validUntil, dependencyRevision] + properties: + validationId: { type: string, format: uuid } + documentId: { type: string, format: uuid } + validatedVersion: { type: integer, minimum: 1 } + status: { type: string, enum: [INVALID, WARNINGS, VALID] } + issues: { type: array, maxItems: 200, items: { $ref: "#/components/schemas/ValidationIssue" } } + validatedAt: { type: string, format: date-time } + validUntil: { type: string, format: date-time } + dependencyRevision: { $ref: "#/components/schemas/DependencyRevision" } + DependencyRevision: + type: string + minLength: 1 + maxLength: 200 + description: | + 검증 결과에 영향을 주는 외부 의존 상태를 대표하는 값이다. + + ```text + Topic/Project 존재와 publishability + relation target 상태 + Asset READY/QUARANTINED 상태 + slug/route ownership + catalog revision + 필요 시 renderer/content-format version + ``` + + 모든 테이블의 global counter일 필요는 없다. 검증에 사용한 dependency + identity/version을 정규화해 hash로 만들 수 있다. Publish 시 동일 + dependency set을 다시 계산해 값이 다르면 `VALIDATION_STALE`로 거절한다. + + # ------------------------------------------------------- public render model + DisplayTarget: + type: object + additionalProperties: false + required: [id, label, publicPath] + properties: + id: { type: string, format: uuid } + label: { type: string, minLength: 1, maxLength: 200 } + publicPath: { type: [string, "null"], maxLength: 500 } + ResolvedRelation: + type: object + additionalProperties: false + required: [id, targetId, targetKind, title, publicPath, reason, order] + properties: + id: { type: string, format: uuid } + targetId: { type: string, format: uuid } + targetKind: { type: string, enum: [CASE, REFERENCE, QUESTION, PROJECT, PROJECT_DECISION] } + title: { type: string, minLength: 1, maxLength: 200 } + publicPath: { type: [string, "null"], maxLength: 500 } + reason: { type: string, maxLength: 100000 } + order: { type: integer, minimum: 0 } + RenderContext: + type: object + additionalProperties: false + required: [generatedAt, dependencyRevision] + properties: + generatedAt: { type: string, format: date-time } + dependencyRevision: { $ref: "#/components/schemas/DependencyRevision" } + PublicRenderModelBase: + type: object + required: [kind, slug, title, summary, publicPath, topic, project, relations, renderContext] + properties: + kind: { $ref: "#/components/schemas/RecordKind" } + slug: { type: string, minLength: 3, maxLength: 100, pattern: "^[a-z0-9]+(?:-[a-z0-9]+)*$" } + title: { type: string, minLength: 1, maxLength: 120 } + summary: { type: string, minLength: 1, maxLength: 300 } + publicPath: { type: string, minLength: 1, maxLength: 500 } + topic: { $ref: "#/components/schemas/DisplayTarget" } + project: + oneOf: + - { $ref: "#/components/schemas/DisplayTarget" } + - { type: "null" } + relations: { type: array, maxItems: 20, items: { $ref: "#/components/schemas/ResolvedRelation" } } + renderContext: { $ref: "#/components/schemas/RenderContext" } + InlineText: + type: object + additionalProperties: false + required: [type, text] + properties: + type: { type: string, enum: [TEXT] } + text: { type: string, minLength: 1, maxLength: 100000 } + InlineContainer: + type: object + required: [type, children] + properties: + type: { type: string, enum: [EMPHASIS, STRONG] } + children: { type: array, maxItems: 1000, items: { $ref: "#/components/schemas/Inline" } } + InlineCode: + type: object + additionalProperties: false + required: [type, code] + properties: + type: { type: string, enum: [INLINE_CODE] } + code: { type: string, minLength: 1, maxLength: 100000 } + InlineLink: + type: object + additionalProperties: false + required: [type, label, href] + properties: + type: { type: string, enum: [LINK] } + label: { type: string, minLength: 1, maxLength: 100000 } + href: { type: string, format: uri, maxLength: 2000 } + InlineStatus: + type: object + additionalProperties: false + required: [type, label, tone] + properties: + type: { type: string, enum: [STATUS] } + label: { type: string, minLength: 1, maxLength: 120 } + tone: { type: string, enum: [warning, evidence, neutral] } + Inline: + oneOf: + - { $ref: "#/components/schemas/InlineText" } + - { $ref: "#/components/schemas/InlineEmphasis" } + - { $ref: "#/components/schemas/InlineStrong" } + - { $ref: "#/components/schemas/InlineCode" } + - { $ref: "#/components/schemas/InlineLink" } + - { $ref: "#/components/schemas/InlineStatus" } + discriminator: + propertyName: type + mapping: + TEXT: "#/components/schemas/InlineText" + EMPHASIS: "#/components/schemas/InlineEmphasis" + STRONG: "#/components/schemas/InlineStrong" + INLINE_CODE: "#/components/schemas/InlineCode" + LINK: "#/components/schemas/InlineLink" + STATUS: "#/components/schemas/InlineStatus" + InlineEmphasis: + unevaluatedProperties: false + allOf: + - { $ref: "#/components/schemas/InlineContainer" } + - { type: object, properties: { type: { type: string, enum: [EMPHASIS] } } } + InlineStrong: + unevaluatedProperties: false + allOf: + - { $ref: "#/components/schemas/InlineContainer" } + - { type: object, properties: { type: { type: string, enum: [STRONG] } } } + HeadingBlock: + type: object + additionalProperties: false + required: [type, id, level, content] + properties: + type: { type: string, enum: [HEADING] } + id: { type: string, minLength: 1, maxLength: 200 } + level: { type: integer, minimum: 2, maximum: 4 } + content: { type: array, maxItems: 1000, items: { $ref: "#/components/schemas/Inline" } } + ParagraphBlock: + type: object + additionalProperties: false + required: [type, content] + properties: + type: { type: string, enum: [PARAGRAPH] } + content: { type: array, maxItems: 1000, items: { $ref: "#/components/schemas/Inline" } } + BlockquoteBlock: + type: object + additionalProperties: false + required: [type, content] + properties: + type: { type: string, enum: [BLOCKQUOTE] } + content: { type: array, maxItems: 1000, items: { $ref: "#/components/schemas/Inline" } } + ListItem: + type: object + additionalProperties: false + required: [id, content] + properties: + id: { type: string, minLength: 1, maxLength: 200 } + content: { type: array, maxItems: 1000, items: { $ref: "#/components/schemas/Inline" } } + ListBlockBase: + type: object + required: [type, items] + properties: + type: { type: string, enum: [UNORDERED_LIST, ORDERED_LIST] } + items: { type: array, maxItems: 1000, items: { $ref: "#/components/schemas/ListItem" } } + UnorderedListBlock: + unevaluatedProperties: false + allOf: + - { $ref: "#/components/schemas/ListBlockBase" } + - { type: object, properties: { type: { type: string, enum: [UNORDERED_LIST] } } } + OrderedListBlock: + unevaluatedProperties: false + allOf: + - { $ref: "#/components/schemas/ListBlockBase" } + - { type: object, properties: { type: { type: string, enum: [ORDERED_LIST] } } } + CodeBlock: + type: object + additionalProperties: false + required: [type, code, language, label] + properties: + type: { type: string, enum: [CODE_BLOCK] } + code: { type: string, maxLength: 100000 } + language: { type: [string, "null"], maxLength: 100 } + label: { type: [string, "null"], maxLength: 200 } + DataTableColumn: + type: object + additionalProperties: false + required: [id, label, alignment] + properties: + id: { type: string, minLength: 1, maxLength: 200 } + label: { type: string, minLength: 1, maxLength: 500 } + alignment: { type: string, enum: [LEFT, CENTER, RIGHT] } + DataTableCell: + type: object + additionalProperties: false + required: [columnId, content] + properties: + columnId: { type: string, minLength: 1, maxLength: 200 } + content: { type: array, maxItems: 1000, items: { $ref: "#/components/schemas/Inline" } } + DataTableRow: + type: object + additionalProperties: false + required: [id, cells] + properties: + id: { type: string, minLength: 1, maxLength: 200 } + cells: { type: array, maxItems: 100, items: { $ref: "#/components/schemas/DataTableCell" } } + DataTableBlock: + type: object + additionalProperties: false + required: [type, id, caption, rowHeaderColumn, columns, rows] + properties: + type: { type: string, enum: [DATA_TABLE] } + id: { type: string, minLength: 1, maxLength: 200 } + caption: { type: string, maxLength: 1000 } + rowHeaderColumn: { type: [integer, "null"], minimum: 1 } + columns: { type: array, minItems: 1, maxItems: 100, items: { $ref: "#/components/schemas/DataTableColumn" } } + rows: { type: array, maxItems: 1000, items: { $ref: "#/components/schemas/DataTableRow" } } + CalloutBlock: + type: object + additionalProperties: false + required: [type, tone, label, content] + properties: + type: { type: string, enum: [CALLOUT] } + tone: { type: string, enum: [warning, info] } + label: { type: string, maxLength: 200 } + content: { type: array, maxItems: 1000, items: { $ref: "#/components/schemas/Inline" } } + EvidenceFigureBlock: + type: object + additionalProperties: false + required: [type, key, alt, caption, zoom, asset] + description: | + `key`는 managed `asset_key`다. object storage key나 raw URL이 아니다. + + ```text + asset_key → Asset lookup → current approved delivery path + ``` + + `alt`는 빈 문자열을 허용한다. 빈 alt 자체를 syntax error로 차단하지 않고 + Publication Validation이 Asset metadata와 함께 의미 검증한다. + + ```text + Asset decorative=false + 사용 위치 alt 비어 있음 → PublishValidationFailed + Asset decorative=true → alt="" 허용 + ``` + properties: + type: { type: string, enum: [EVIDENCE_FIGURE] } + key: { type: string, minLength: 1, maxLength: 200 } + alt: { type: string, maxLength: 1000 } + caption: { type: string, maxLength: 1000 } + zoom: { type: boolean } + asset: { $ref: "#/components/schemas/ResolvedAsset" } + ResolvedAsset: + type: object + additionalProperties: false + description: | + renderer가 사용하는 Asset descriptor다. Preview/Public/Snapshot이 동일한 + resolver를 통해 동일 semantic output을 만들어야 한다. + SVG도 URL 기반 ``로 렌더링하며 원문을 inline하지 않는다. + required: [assetId, assetKey, mediaType, publicPath, width, height, decorative] + properties: + assetId: { type: string, format: uuid } + assetKey: { type: string, minLength: 1, maxLength: 200 } + mediaType: { type: string, minLength: 1, maxLength: 200 } + publicPath: { type: string, minLength: 1, maxLength: 500 } + width: { type: [integer, "null"], minimum: 1 } + height: { type: [integer, "null"], minimum: 1 } + decorative: { type: boolean } + CaseRenderBlock: + oneOf: + - { $ref: "#/components/schemas/HeadingBlock" } + - { $ref: "#/components/schemas/ParagraphBlock" } + - { $ref: "#/components/schemas/BlockquoteBlock" } + - { $ref: "#/components/schemas/UnorderedListBlock" } + - { $ref: "#/components/schemas/OrderedListBlock" } + - { $ref: "#/components/schemas/CodeBlock" } + - { $ref: "#/components/schemas/DataTableBlock" } + - { $ref: "#/components/schemas/CalloutBlock" } + - { $ref: "#/components/schemas/EvidenceFigureBlock" } + discriminator: + propertyName: type + mapping: + HEADING: "#/components/schemas/HeadingBlock" + PARAGRAPH: "#/components/schemas/ParagraphBlock" + BLOCKQUOTE: "#/components/schemas/BlockquoteBlock" + UNORDERED_LIST: "#/components/schemas/UnorderedListBlock" + ORDERED_LIST: "#/components/schemas/OrderedListBlock" + CODE_BLOCK: "#/components/schemas/CodeBlock" + DATA_TABLE: "#/components/schemas/DataTableBlock" + CALLOUT: "#/components/schemas/CalloutBlock" + EVIDENCE_FIGURE: "#/components/schemas/EvidenceFigureBlock" + CasePublicRenderModel: + unevaluatedProperties: false + allOf: + - { $ref: "#/components/schemas/PublicRenderModelBase" } + - type: object + required: [kind, problem, conclusion, environment, reproduction, lastVerifiedOn, bodyBlocks] + properties: + kind: { type: string, enum: [CASE] } + problem: { type: string, minLength: 1, maxLength: 100000 } + conclusion: { type: string, minLength: 1, maxLength: 100000 } + environment: { type: string, maxLength: 100000 } + reproduction: { type: string, maxLength: 100000 } + lastVerifiedOn: { type: string, format: date } + bodyBlocks: { type: array, maxItems: 10000, items: { $ref: "#/components/schemas/CaseRenderBlock" } } + ReferencePublicRenderModel: + unevaluatedProperties: false + allOf: + - { $ref: "#/components/schemas/PublicRenderModelBase" } + - type: object + required: [kind, purpose, rules, applyWhen, exceptions, examples, verifiedOn] + properties: + kind: { type: string, enum: [REFERENCE] } + purpose: { type: string, minLength: 1, maxLength: 100000 } + rules: { type: array, minItems: 1, maxItems: 50, items: { $ref: "#/components/schemas/ReferenceRule" } } + applyWhen: { type: array, minItems: 1, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } } + exceptions: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } } + examples: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } } + verifiedOn: { type: string, format: date } + ResolvedQuestionResolution: + type: object + additionalProperties: false + required: [summary, evidenceTarget, linkLabel] + properties: + summary: { type: string, minLength: 1, maxLength: 100000 } + evidenceTarget: { $ref: "#/components/schemas/DisplayTarget" } + linkLabel: { type: string, minLength: 1, maxLength: 120 } + QuestionPublicRenderModel: + unevaluatedProperties: false + allOf: + - { $ref: "#/components/schemas/PublicRenderModelBase" } + - type: object + required: [kind, status, facts, assumptions, unknowns, constraints, options, nextValidation, resolution] + properties: + kind: { type: string, enum: [QUESTION] } + status: + type: string + enum: [OPEN, RESOLVED] + description: 공개 표현용 축약 상태다. Domain의 `INVESTIGATING`/`PAUSED`는 `OPEN`으로 표현된다. + facts: { type: array, minItems: 1, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } } + assumptions: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } } + unknowns: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } } + constraints: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } } + options: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/QuestionOption" } } + nextValidation: { type: string, minLength: 1, maxLength: 100000 } + resolution: + oneOf: + - { $ref: "#/components/schemas/ResolvedQuestionResolution" } + - { type: "null" } + ProjectDecisionPublicRenderModel: + unevaluatedProperties: false + allOf: + - { $ref: "#/components/schemas/PublicRenderModelBase" } + - type: object + required: [kind, status, decidedOn, statement, rationale, consequences] + properties: + kind: { type: string, enum: [PROJECT_DECISION] } + status: { type: string, enum: [PROPOSED, ADOPTED] } + decidedOn: { type: string, format: date } + statement: { type: string, minLength: 1, maxLength: 100000 } + rationale: { type: string, minLength: 1, maxLength: 100000 } + consequences: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } } + PublicRenderModel: + oneOf: + - { $ref: "#/components/schemas/CasePublicRenderModel" } + - { $ref: "#/components/schemas/ReferencePublicRenderModel" } + - { $ref: "#/components/schemas/QuestionPublicRenderModel" } + - { $ref: "#/components/schemas/ProjectDecisionPublicRenderModel" } + discriminator: + propertyName: kind + mapping: + CASE: "#/components/schemas/CasePublicRenderModel" + REFERENCE: "#/components/schemas/ReferencePublicRenderModel" + QUESTION: "#/components/schemas/QuestionPublicRenderModel" + PROJECT_DECISION: "#/components/schemas/ProjectDecisionPublicRenderModel" + + # ---------------------------------------------------------------- preview + PublicPreview: + type: object + additionalProperties: false + required: [previewId, documentId, previewVersion, validationId, dependencyRevision, createdAt, expiresAt, renderModel] + properties: + previewId: { type: string, format: uuid } + documentId: { type: string, format: uuid } + previewVersion: { type: integer, minimum: 1 } + validationId: { type: string, format: uuid } + dependencyRevision: { $ref: "#/components/schemas/DependencyRevision" } + createdAt: { type: string, format: date-time } + expiresAt: { type: string, format: date-time } + renderModel: { $ref: "#/components/schemas/PublicRenderModel" } + PreviewDetail: + type: object + additionalProperties: false + required: [preview, state, currentDocumentVersion, currentValidationId] + properties: + preview: { $ref: "#/components/schemas/PublicPreview" } + state: + type: string + enum: [CURRENT, STALE, EXPIRED] + description: | + 서버가 계산한다. + `STALE`은 working version 또는 dependency revision이 달라진 경우, + `EXPIRED`는 `expiresAt`이 지난 경우다. + currentDocumentVersion: { type: integer, minimum: 1 } + currentValidationId: { type: [string, "null"], format: uuid } + + # ------------------------------------------------------------ publication + PublicationAggregate: + type: object + additionalProperties: false + description: 현재 게시 상태다. 게시 이력(`PublicationEvent`)과 구분한다. + required: [publicationId, documentId, status, publishedVersion, publicationRevision, latestEventId, publicPath, updatedAt] + properties: + publicationId: { type: string, format: uuid } + documentId: { type: string, format: uuid } + status: { type: string, enum: [PUBLISHED, UNPUBLISHED] } + publishedVersion: { type: integer, minimum: 1 } + publicationRevision: { type: integer, minimum: 1 } + latestEventId: { type: string, format: uuid } + publicPath: { type: string, minLength: 1, maxLength: 500 } + updatedAt: { type: string, format: date-time } + PublicationEventType: { type: string, enum: [PUBLISHED, REPUBLISHED, UNPUBLISHED] } + PublicationEvent: + type: object + additionalProperties: false + description: 불변 이력이다. 생성 후 수정하지 않는다. + required: [publicationEventId, publicationId, documentId, type, occurredAt, publishedVersion, sourcePublishedEventId, snapshotAvailable] + properties: + publicationEventId: { type: string, format: uuid } + publicationId: { type: string, format: uuid } + documentId: { type: string, format: uuid } + type: { $ref: "#/components/schemas/PublicationEventType" } + occurredAt: { type: string, format: date-time } + publishedVersion: { type: integer, minimum: 1 } + sourcePublishedEventId: + type: [string, "null"] + format: uuid + description: "`UNPUBLISHED` Event가 참조하는 마지막 공개 Snapshot의 Event id다." + snapshotAvailable: { type: boolean } + PublicationSnapshot: + type: object + additionalProperties: false + description: | + `PUBLISHED`/`REPUBLISHED` 시점의 불변 `PublicRenderModel`이다. + 현재 source에서 재생성하지 않는다. + required: [event, renderModel, contentFormatVersion, rendererContractVersion] + properties: + event: { $ref: "#/components/schemas/PublicationEvent" } + renderModel: { $ref: "#/components/schemas/PublicRenderModel" } + contentFormatVersion: { type: string, minLength: 1, maxLength: 50 } + rendererContractVersion: { type: string, minLength: 1, maxLength: 50 } + PublishResult: + type: object + additionalProperties: false + required: [publication, event] + properties: + publication: { $ref: "#/components/schemas/PublicationAggregate" } + event: { $ref: "#/components/schemas/PublicationEvent" } + + # ---------------------------------------------------------------- listing + DocumentSummary: + type: object + additionalProperties: false + required: [id, title, kind, project, updatedAt, publicationStatus, publishedVersion, hasUnpublishedChanges, nextAction] + properties: + id: { type: string, format: uuid } + title: { type: string, maxLength: 120 } + kind: { $ref: "#/components/schemas/RecordKind" } + project: + oneOf: + - { $ref: "#/components/schemas/DisplayTarget" } + - { type: "null" } + updatedAt: { type: string, format: date-time } + publicationStatus: { $ref: "#/components/schemas/PublicationStatus" } + publishedVersion: { type: [integer, "null"], minimum: 1 } + hasUnpublishedChanges: + type: boolean + description: "`currentWorkingVersion != currentPublication.publishedVersion`. 게시 취소 상태에서도 과거 publishedVersion과 비교한다." + nextAction: { $ref: "#/components/schemas/NextAction" } + PublicationAction: { type: string, enum: [VIEW_SNAPSHOT, VIEW_SOURCE_SNAPSHOT, UNPUBLISH] } + PublicationListItem: + type: object + additionalProperties: false + required: [event, publication, document, availableActions] + properties: + event: { $ref: "#/components/schemas/PublicationEvent" } + publication: { $ref: "#/components/schemas/PublicationAggregate" } + document: { $ref: "#/components/schemas/DocumentSummary" } + availableActions: { type: array, uniqueItems: true, maxItems: 3, items: { $ref: "#/components/schemas/PublicationAction" } } + WorkingCopyDetail: + type: object + additionalProperties: false + required: [document, currentValidation, latestPreview, currentPublication, dependencyRevision, nextAction] + properties: + document: { $ref: "#/components/schemas/WorkingCopy" } + currentValidation: + oneOf: + - { $ref: "#/components/schemas/ValidationReport" } + - { type: "null" } + latestPreview: + oneOf: + - { $ref: "#/components/schemas/PublicPreview" } + - { type: "null" } + currentPublication: + oneOf: + - { $ref: "#/components/schemas/PublicationAggregate" } + - { type: "null" } + dependencyRevision: { $ref: "#/components/schemas/DependencyRevision" } + nextAction: { $ref: "#/components/schemas/NextAction" } + StudioDashboard: + type: object + additionalProperties: false + required: [continueWriting, readyToPublish, recentPublications, totals] + properties: + continueWriting: { type: array, maxItems: 5, items: { $ref: "#/components/schemas/DocumentSummary" } } + readyToPublish: { type: array, maxItems: 5, items: { $ref: "#/components/schemas/DocumentSummary" } } + recentPublications: { type: array, maxItems: 5, items: { $ref: "#/components/schemas/PublicationListItem" } } + totals: { $ref: "#/components/schemas/DashboardTotals" } + DashboardTotals: + type: object + additionalProperties: false + required: [documents, needsValidation, readyToPublish, publications] + properties: + documents: { type: integer, minimum: 0 } + needsValidation: { type: integer, minimum: 0 } + readyToPublish: { type: integer, minimum: 0 } + publications: { type: integer, minimum: 0 } + DocumentPage: + type: object + additionalProperties: false + required: [items, nextCursor] + properties: + items: { type: array, maxItems: 100, items: { $ref: "#/components/schemas/DocumentSummary" } } + nextCursor: { type: [string, "null"], maxLength: 2000 } + PublicationPage: + type: object + additionalProperties: false + required: [items, nextCursor] + properties: + items: { type: array, maxItems: 100, items: { $ref: "#/components/schemas/PublicationListItem" } } + nextCursor: { type: [string, "null"], maxLength: 2000 } + + # ---------------------------------------------------------------- catalog + CatalogEntryType: { type: string, enum: [TOPIC, PROJECT, RELATION, EVIDENCE] } + CatalogEntry: + type: object + additionalProperties: false + required: [id, type, label, dependencyRevision] + properties: + id: { type: string, format: uuid } + type: { $ref: "#/components/schemas/CatalogEntryType" } + label: { type: string, minLength: 1, maxLength: 200 } + kind: { type: string, enum: [CASE, REFERENCE, QUESTION, PROJECT, PROJECT_DECISION] } + publicPath: { type: string, minLength: 1, maxLength: 500 } + dependencyRevision: { $ref: "#/components/schemas/DependencyRevision" } + CatalogPage: + type: object + additionalProperties: false + required: [items, nextCursor] + properties: + items: { type: array, maxItems: 100, items: { $ref: "#/components/schemas/CatalogEntry" } } + nextCursor: { type: [string, "null"], maxLength: 2000 } + + # ----------------------------------------------------------------- assets + AssetKind: { type: string, enum: [IMAGE, DIAGRAM, ATTACHMENT] } + AssetManagementStatus: + type: string + enum: [READY, ARCHIVED, REJECTED, QUARANTINED] + description: | + `READY`만 Public Preview/Publish에 사용할 수 있다. + `REJECTED`/`QUARANTINED`는 서버 검증 결과이며 클라이언트가 지정할 수 없다. + Asset: + type: object + additionalProperties: false + required: [id, assetKey, kind, mediaType, originalFilename, byteSize, width, height, altText, decorative, managementStatus, publicPath, usageCount, version, createdAt, updatedAt] + properties: + id: { type: string, format: uuid } + assetKey: + type: string + minLength: 1 + maxLength: 200 + description: | + Public content가 사용하는 안정적인 key다. object storage key나 raw URL이 + 아니다. immutable이며 공개 이력 이후 재사용을 금지한다. + kind: { $ref: "#/components/schemas/AssetKind" } + mediaType: { type: string, minLength: 1, maxLength: 200 } + originalFilename: { type: string, minLength: 1, maxLength: 500 } + byteSize: { type: integer, minimum: 0 } + width: { type: [integer, "null"], minimum: 1 } + height: { type: [integer, "null"], minimum: 1 } + altText: { type: [string, "null"], maxLength: 1000 } + decorative: { type: boolean } + managementStatus: { $ref: "#/components/schemas/AssetManagementStatus" } + publicPath: { type: [string, "null"], maxLength: 500 } + usageCount: { type: integer, minimum: 0 } + version: { type: integer, minimum: 1 } + createdAt: { type: string, format: date-time } + updatedAt: { type: string, format: date-time } + AssetUsage: + type: object + additionalProperties: false + required: [documentId, documentKind, title, published] + properties: + documentId: { type: string, format: uuid } + documentKind: { $ref: "#/components/schemas/RecordKind" } + title: { type: string, minLength: 1, maxLength: 200 } + published: { type: boolean } + AssetDetail: + type: object + additionalProperties: false + required: [asset, usages, hasPublicationHistory] + properties: + asset: { $ref: "#/components/schemas/Asset" } + usages: { type: array, maxItems: 200, items: { $ref: "#/components/schemas/AssetUsage" } } + hasPublicationHistory: + type: boolean + description: true면 hard delete를 금지하고 `ARCHIVED` 전환만 허용한다. + AssetUploadForm: + type: object + additionalProperties: false + required: [file, kind] + properties: + file: { type: string, format: binary } + kind: { $ref: "#/components/schemas/AssetKind" } + altText: { type: string, maxLength: 1000 } + decorative: { type: boolean, default: false } + UpdateAssetCommand: + type: object + additionalProperties: false + required: [expectedVersion] + properties: + expectedVersion: { type: integer, minimum: 1 } + kind: { $ref: "#/components/schemas/AssetKind" } + altText: { type: [string, "null"], maxLength: 1000 } + decorative: { type: boolean } + managementStatus: { type: string, enum: [READY, ARCHIVED] } + AssetPage: + type: object + additionalProperties: false + required: [items, nextCursor] + properties: + items: { type: array, maxItems: 100, items: { $ref: "#/components/schemas/Asset" } } + nextCursor: { type: [string, "null"], maxLength: 2000 } + + # ------------------------------------------------------------------ errors + FieldError: + type: object + additionalProperties: false + required: [path, message] + properties: + path: + type: string + pattern: "^(?:/(?:[^~/]|~0|~1)*)*$" + description: JSON Pointer to the invalid field + message: { type: string, minLength: 1, maxLength: 1000 } diff --git a/src/config/spotbugs/exclude.xml b/src/config/spotbugs/exclude.xml index 928020f..4f681e6 100644 --- a/src/config/spotbugs/exclude.xml +++ b/src/config/spotbugs/exclude.xml @@ -16,6 +16,14 @@ + + + + + + + + + +