merge: develop — Tech Log 백엔드 계약 2종 완성 (studio-v1 19/19, public-v1 18/18)

main이 마지막으로 본 것은 스켈레톤 초기화(697fc74)까지였다. 그 뒤 develop에 쌓인
Tech Log 백엔드 전체를 가져온다.

- Studio 백엔드 기반(Plan 01)과 남은 17개 operation → studio-v1 19/19
- release-gate 수정 4건(authz 배선, BFF 로그인 경로, 계약 nullable/오류 코드, 체크리스트)
- 공개 조회 백엔드 → public-v1 18/18

각 판단의 근거는 해당 커밋 메시지에 있다.

검증: ./gradlew check BUILD SUCCESSFUL (248 task). PostgreSQL 통합 테스트
(Studio 11 + 공개 조회 24) 통과. 실제 앱 기동 후 public-v1 18개 operation 실호출 5xx 0건.

알려진 선재 실패: ActuatorSecurityHttpTest가 /actuator/health 503으로 실패한다.
redis가 호스트 포트에 노출되지 않아 헬스가 DOWN인 환경 문제이며 코드와 무관하다
(기저 커밋에서도 동일하게 재현됨).

AGENTS.md의 commit 정책은 human-only다. 이 머지는 사용자가 "develop과 main에
반영하도록" 지시해 예외로 수행한다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-08-20 18:35:04 +09:00
co-authored by Claude Opus 5
344 changed files with 31184 additions and 15573 deletions
+63
View File
@@ -4249,3 +4249,66 @@ env_keys:
validation: positive_int_bounded validation: positive_int_bounded
compatibility_impact: behavior-change compatibility_impact: behavior-change
required_test: async-contract:executor-queue-bounded required_test: async-contract:executor-queue-bounded
# === Tech Log Studio (feature-techlog-studio-backend) ===
- name: APP_STUDIO_CURSOR_SIGNING_KEY
# source: studio-v1.yaml components.parameters.Cursor — "Opaque cursor bound to
# normalized filters and sort". 서명 키가 인스턴스마다 다르면 한 인스턴스가 발급한
# 커서를 다른 인스턴스가 거부한다. 비어 있으면 StudioSettings가 경고하고 개발용 값으로
# 대체한다(커서에 권한이 실리지 않으므로 부팅은 막지 않는다).
type: string
default: null
allowed_values: null
classification: secret
required: false
reload_policy: restart-only
owner_branch: feature-techlog-studio-backend
validation: min_length_16_bytes
compatibility_impact: behavior-change
required_test: techlog-studio-contract:cursor-round-trip
- name: APP_STUDIO_VALIDATION_TTL
# source: 백엔드 설계 §7.3 — "now() < validUntil" 이 Validation 유효 조건의 하나다.
# studio_validation.valid_until 을 채우는 값.
type: duration
default: 1h
allowed_values: null
classification: public-config
required: false
reload_policy: restart-only
owner_branch: feature-techlog-studio-backend
validation: spring_duration_shorthand
compatibility_impact: behavior-change
required_test: techlog-studio-contract:validation-window
- name: APP_STUDIO_PREVIEW_TTL
# source: 백엔드 설계 §7.3 — Preview 가 EXPIRED 로 넘어가는 기준.
# studio_preview.expires_at 을 채우는 값.
type: duration
default: 24h
allowed_values: null
classification: public-config
required: false
reload_policy: restart-only
owner_branch: feature-techlog-studio-backend
validation: spring_duration_shorthand
compatibility_impact: behavior-change
required_test: techlog-studio-contract:preview-expiry
- name: APP_STUDIO_AUTHOR_ROLE
# source: 백엔드 설계 §9 — 권한은 기존 RolePermissionPolicy 를 재사용한다.
# ca-skeleton.authz.role-permissions 의 키로 쓰이는 IdP RAW role 이름. 배포마다 다르다.
# application.yml 이 아니라 프로파일(application-{local,dev,prod}.yml)에 있다 —
# SampleRemovalSmokeContractTest 가 application.yml 의 `role-permissions: {}` 기준선을
# 그대로 유지하도록 요구하기 때문이다.
type: string
default: studio-author
allowed_values: null
classification: public-config
required: false
reload_policy: restart-only
owner_branch: feature-techlog-studio-backend
validation: non_blank
compatibility_impact: behavior-change
required_test: techlog-studio-contract:author-permission
+348
View File
@@ -916,3 +916,351 @@ errors:
runbook_link: "runbook://management/actuator-forbidden" runbook_link: "runbook://management/actuator-forbidden"
compatibility_impact: none compatibility_impact: none
required_test: contract-verification:management-actuator 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
# === Tech Log Public (feature-techlog-public-v1) ===
#
# public-v1.yaml 의 ApiError.code 는 세 값이다. 나머지 하나 INTERNAL_ERROR 는 스켈레톤
# 공통 코드로 이미 이 레지스트리에 있으므로 여기서 다시 선언하지 않는다.
#
# Studio 와 이름을 겹치지 않게 한 이유: 이 레지스트리는 코드 하나에 http_status 하나만
# 담는다. public 의 400 과 studio 의 422 를 같은 이름으로 쓸 수 없다.
# source: public-v1.yaml ApiError.code — PUBLIC_REQUEST_INVALID (PublicError.PUBLIC_REQUEST_INVALID)
- code: PUBLIC_REQUEST_INVALID
category: VALIDATION
http_status: 400
retryable: false
retry_after_seconds: null
owner_branch: feature-techlog-public-v1
owner_layer: application
client_safe_message: "요청 값이 올바르지 않습니다"
log_level: INFO
runbook_link: null
compatibility_impact: additive
required_test: PublicErrorRegistryTest
# source: public-v1.yaml ApiError.code — PUBLIC_RESOURCE_NOT_FOUND (PublicError.PUBLIC_RESOURCE_NOT_FOUND)
- code: PUBLIC_RESOURCE_NOT_FOUND
category: NOT_FOUND
http_status: 404
retryable: false
retry_after_seconds: null
owner_branch: feature-techlog-public-v1
owner_layer: application
client_safe_message: "요청한 자료를 찾을 수 없습니다"
log_level: INFO
runbook_link: null
compatibility_impact: additive
required_test: PublicErrorRegistryTest
+5 -2
View File
@@ -1,10 +1,10 @@
--- ---
title: Runbook — AUTH_TOKEN_MISSING (인증 토큰 누락) title: Runbook — AUTH_TOKEN_MISSING (인증 토큰 누락)
category: AUTH category: AUTH
error_codes: [AUTH_TOKEN_MISSING] error_codes: [AUTH_TOKEN_MISSING, AUTHENTICATION_REQUIRED]
severity: P3 severity: P3
owner: oncall owner: oncall
last_updated: 2026-06-15 last_updated: 2026-08-18
status: stub status: stub
--- ---
@@ -14,6 +14,9 @@ status: stub
- HTTP 401 responses with `error.code=AUTH_TOKEN_MISSING` - HTTP 401 responses with `error.code=AUTH_TOKEN_MISSING`
- Client missing Authorization header or Bearer token - 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 ## Diagnosis
@@ -1,10 +1,10 @@
--- ---
title: Runbook — AUTHZ_INSUFFICIENT_PERMISSION (권한 부족) title: Runbook — AUTHZ_INSUFFICIENT_PERMISSION (권한 부족)
category: AUTHZ category: AUTHZ
error_codes: [AUTHZ_INSUFFICIENT_PERMISSION] error_codes: [AUTHZ_INSUFFICIENT_PERMISSION, STUDIO_ACCESS_DENIED]
severity: P3 severity: P3
owner: oncall owner: oncall
last_updated: 2026-06-15 last_updated: 2026-08-18
status: stub status: stub
--- ---
@@ -14,6 +14,9 @@ status: stub
- HTTP 403 with `error.code=AUTHZ_INSUFFICIENT_PERMISSION` - HTTP 403 with `error.code=AUTHZ_INSUFFICIENT_PERMISSION`
- Valid token but missing required role or 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 ## Diagnosis
+95
View File
@@ -0,0 +1,95 @@
---
title: Runbook — STUDIO_UNAVAILABLE (Tech Log Studio 일시 장애)
category: TRANSIENT_DEPENDENCY
error_codes: [STUDIO_UNAVAILABLE]
severity: P1
owner: oncall
last_updated: 2026-08-18
status: active
---
# Runbook: STUDIO_UNAVAILABLE (`runbook://studio/unavailable`)
## 1. Trigger
이 runbook은 Tech Log Studio API(`studio-v1.yaml`)가 `error.code=STUDIO_UNAVAILABLE`
(HTTP 503, category `TRANSIENT_DEPENDENCY`, `retryable=true`)을 응답할 때 발동됩니다.
- alert name: `studio_error_rate_critical` 또는 `studio_dependency_unavailable`
- alert payload 필수 field: `operation`(문서/미리보기/게시 중 어느 슬라이스인지), `error.code`,
`error.category`, `dependency_name`(가능하면), `runbook_link`
- 임계:
- P1: `STUDIO_UNAVAILABLE` 비율이 1분간 Studio 전체 트래픽의 10% 초과
- P2: 단발성 spike이나 5분 내 자연 회복
`StudioExceptionHandler`(adapter/inbound/web)가 `StudioException`을 봉투로 변환하는
지점이므로, 이 코드는 항상 Studio facade/use case(`application-core`)가 자신의 하위
의존성(영속성, 캐시, 오브젝트 스토리지, 렌더링/미리보기 파이프라인 등) 실패를
클라이언트에 안전한 단일 코드로 접어(classify) 던진 결과입니다 — 원인 그 자체가 아니라
**Studio가 판단한 결과**라는 점을 유의합니다.
## 2. First Response (5분 이내)
### Step 1 — 확인
1. 최근 배포 이력 확인 (`app-bootstrap` 롤아웃, config 변경) — 배포 직후 spike면 롤백 우선 검토
2. 로그에서 `StudioException`이 감싸고 있던 실제 원인을 확인: `dev.caskeleton.application.techlog`
패키지의 use case/facade 로그에서 `STUDIO_UNAVAILABLE`로 분류되기 직전의 원인 예외
(`PersistenceFailureException`, `DependencyFailureException` 등)를 추적
3. runtime-health Dependency Matrix에서 Studio가 의존하는 구성요소(DB, 캐시, object storage)의
required/optional 분류와 현재 상태 확인
4. 특정 slice(문서 편집/미리보기/게시)에 국한된 장애인지, Studio 전역 장애인지 구분
### Step 2 — 임시 격리
- 원인이 특정 하위 의존성이면 해당 의존성의 runbook으로 전환 (예: `runbook://db/unavailable`,
`runbook://cache/unavailable`) — `STUDIO_UNAVAILABLE`은 진입점일 뿐, 근본 원인 대응은
하위 의존성 runbook이 담당
- 클라이언트(Frontend)는 이미 `retryable=true`를 신뢰해 지수 백오프 재시도를 수행하므로,
단기 spike는 자연 회복을 우선 관찰 (조기 개입으로 인한 추가 부하 유발 방지)
## 3. Diagnosis
- log query: `{service="app"} | error.code="STUDIO_UNAVAILABLE" | stats count by operation`
- 원인 추적: 같은 요청의 correlation id로 use case 로그를 따라가 어떤 하위 호출이
`StudioException.withDetails(StudioError.STUDIO_UNAVAILABLE, ...)`로 재분류됐는지 확인
- metric panel:
- `http_server_requests_seconds_count{uri=~"/api/v1/studio/.*", outcome="SERVER_ERROR"}`
- 하위 의존성 metric (`hikaricp_connections_active`, `resilience4j_circuitbreaker_state`,
object storage client 오류율)
- 가능한 원인:
- DB/캐시/오브젝트 스토리지 등 필수 의존성 장애가 Studio 계층까지 전파
- Studio 자체 리소스 고갈 (스레드풀, 커넥션풀)
- 미리보기/렌더링 파이프라인의 타임아웃 누적
- 배포 직후 신규 코드 경로의 미검증 예외가 fallback으로 `STUDIO_UNAVAILABLE`에 접힘
## 4. Mitigation
- 단기: 근본 원인이 확인된 하위 의존성이면 해당 dependency runbook의 mitigation을 적용
- Studio 자체 리소스 고갈이면 인스턴스 스케일아웃 또는 커넥션/스레드풀 상향 검토
- 특정 slice(예: 미리보기)만 영향받는다면 해당 slice만 일시적으로 기능 차단하고
나머지(문서 편집/게시)는 정상 유지 검토 — 전면 장애보다 부분 degrade 우선
- 장기: 반복되는 하위 의존성 장애가 `STUDIO_UNAVAILABLE`로 잦게 나타나면, 해당 의존성의
circuit breaker/timeout 임계를 재조정하고 fallback 경로 보강
## 5. Escalation
- P1 5분 내 회복 신호 없으면 해당 하위 의존성 오너 팀에 page
- 여러 slice(문서/미리보기/게시)에서 동시에 발생하면 incident commander 호출
(공유 인프라 계층 문제 의심)
## 6. Recovery / Verification
- 회복 확인 metric: `STUDIO_UNAVAILABLE` 비율이 5분간 1% 미만으로 유지
- 하위 의존성 metric(circuit breaker CLOSED, connection pool 정상)도 함께 확인
- post-incident:
- 어떤 하위 의존성이 `STUDIO_UNAVAILABLE`로 접혔는지 기록하고 근본 원인 runbook에 링크
- Studio 클라이언트(Frontend `STUDIO_ERROR_CODES`) 쪽 재시도/백오프 동작이 기대대로
작동했는지 확인
- 특정 slice 반복 장애면 chaos test 시나리오 추가 검토
## 7. Related
- error-codes.yaml row: `STUDIO_UNAVAILABLE` (feature-techlog-studio-backend,
`dev.caskeleton.application.techlog.error.StudioError.STUDIO_UNAVAILABLE`)
- 매핑 지점: `dev.caskeleton.adapter.inbound.web.techlog.StudioExceptionHandler`
- 관련 runbook: `runbook://db/unavailable`, `runbook://cache/unavailable`
- 관련 branch: [[feature-techlog-studio-backend]]
+1
View File
@@ -2,3 +2,4 @@
# SSOT: SECURITY_PUBLIC_PATHS (src/.env) -> SecurityConfig permitAll(); anyRequest authenticated. # SSOT: SECURITY_PUBLIC_PATHS (src/.env) -> SecurityConfig permitAll(); anyRequest authenticated.
# Update only after review with: ./gradlew updatePublicPathSnapshot -PapprovePublicPathChange # Update only after review with: ./gradlew updatePublicPathSnapshot -PapprovePublicPathChange
/api/healthcheck /api/healthcheck
/api/v1/public/**
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,870 @@
# Tech Log Studio Backend — 설계
- 작성일: 2026-08-18
- 대상 저장소: `tech-log-backend` (`clean-architecture-backend-template` 스냅샷)
- 브랜치: `feature/techlog-studio-backend`
- 설계 원본: `/home/donghyeon/workspace/tech-log-design-package`
- 소비자: `/home/donghyeon/workspace/desktop-server-git/tech-log-frontend` (Studio SPA, 구현 완료)
---
## 1. 배경
설계 패키지가 Tech Log의 제품·정보구조·계약·DB·백엔드 모듈을 확정했다. 프론트는
Studio SPA가 이미 구현되어 있고 백엔드만 없다. 이 문서는 **설계 패키지의 결정을
이 저장소의 구조·게이트에 맞춰 어떻게 구현할지**를 정의한다.
설계 패키지의 `docs/plans/02-backend-core-plan.md`는 본문에 STALE 배너가 붙어 있고
파일맵도 `services/api/...`라 이 저장소에 적용되지 않는다. 이 문서가 그 자리를
대신한다.
### 1.1 계약 정합 (검증됨)
`tech-log-design-package/scripts/check-contract-parity.py` 실행 결과:
```text
Frontend operation 19개 / Backend primary operation 19개
[OK] Frontend operation 13개가 모두 Backend primary 계약에 존재한다.
[OK] 공통 operation의 method/path가 모두 일치한다.
RecordKind FE=BE=['CASE','PROJECT_DECISION','QUESTION','REFERENCE']
오류 코드 FE=23개 / BE=23개 [OK]
```
`contracts/openapi/studio-v1.yaml`이 프론트가 실제로 호출하는 계약 그대로다.
---
## 2. 범위
### 2.1 In scope
`studio-v1.yaml`의 19개 operation 전부.
| operation | method · path |
| --- | --- |
| `getStudioSession` | GET `/api/v1/studio/session` |
| `getStudioDashboard` | GET `/api/v1/studio/dashboard` |
| `listStudioDocuments` | GET `/api/v1/studio/documents` |
| `createStudioDocument` | POST `/api/v1/studio/documents` |
| `getStudioDocument` | GET `/api/v1/studio/documents/{documentId}` |
| `saveStudioDocument` | PUT `/api/v1/studio/documents/{documentId}` |
| `validateStudioDocument` | POST `/api/v1/studio/documents/{documentId}/validate` |
| `createStudioPreview` | POST `/api/v1/studio/documents/{documentId}/preview` |
| `getCurrentStudioPreview` | GET `/api/v1/studio/documents/{documentId}/preview` |
| `publishStudioDocument` | POST `/api/v1/studio/documents/{documentId}/publish` |
| `listStudioPublications` | GET `/api/v1/studio/publications` |
| `unpublishStudioPublication` | POST `/api/v1/studio/publications/{publicationId}/unpublish` |
| `getStudioPublicationSnapshot` | GET `/api/v1/studio/publications/{publicationEventId}/preview` |
| `listStudioCatalog` | GET `/api/v1/studio/catalog` |
| `listStudioAssets` | GET `/api/v1/studio/assets` |
| `uploadStudioAsset` | POST `/api/v1/studio/assets` |
| `getStudioAsset` | GET `/api/v1/studio/assets/{assetId}` |
| `updateStudioAsset` | PUT `/api/v1/studio/assets/{assetId}` |
| `deleteStudioAsset` | DELETE `/api/v1/studio/assets/{assetId}` |
`RecordKind` 4종(`CASE`, `REFERENCE`, `QUESTION`, `PROJECT_DECISION`)을 하나의 문서
편집 흐름으로 다룬다.
### 2.2 Out of scope (이번 브랜치)
- `contracts/openapi/public-v1.yaml` (Public 조회 API) — 소비자(Astro Public 사이트)가
아직 없다.
- `contracts/openapi/studio-management-v1.yaml` (secondary capability 보존 계약).
- `release`, `topic`/`tag` 관리 UI, `identity`(SiteConfig/Profile/HomeFocus) 편집 API.
- 이번 범위에 필요한 만큼의 `topic`/`project` **조회**는 catalog에서 다루되, 그
편집 API는 만들지 않는다.
---
## 3. 상위 결정
| ID | 결정 | 근거 |
| --- | --- | --- |
| D1 | 새 Gradle leaf를 만들지 않고 기존 18 leaf **안의 하위 패키지**로 배치한다 | `src/settings.gradle``expectedModuleCount = 18`로 fail-closed 검증. leaf 추가는 registry·settings·dependency gate·ArchUnit을 동시에 바꾸는 template-level 변경이고 `template.lock.json` 기반 향후 sync와 충돌한다 |
| D2 | 패키지는 `dev.caskeleton.{domain,application,adapter...}` **루트 아래**에 `techlog` 하위로 넣는다 | 일부 ArchUnit 규칙이 `dev.caskeleton.application..`처럼 루트를 고정한다(`CleanArchitectureTest.java:221`). 다른 루트에 두면 가드레일이 조용히 미적용된다 |
| D3 | 계약 우선(openapi-generator)으로 **DTO(model)만** 생성하고 controller는 얇게 손으로 쓴다 (`globalProperties.set(['models': ''])`, `useOneOfInterfaces=false`) — 실제 설정은 §5.2, 실측과의 차이는 §3.1 | ADR-004를 유지하되 D4와 충돌하지 않는 형태. 생성 API interface는 봉투 wrapper 타입을 반환하게 되고, 그 타입은 `dev.caskeleton.shared.response.Envelope`가 아니라서 `EnvelopeBodyAdvice`**한 번 더 감싼다**(이중 래핑). 드리프트 위험의 실체는 93개 schema·enum·required 필드이고 그건 model 생성으로 덮인다. controller 19개 signature 드리프트는 §5.5 회귀 테스트가 잡는다 |
| D4 | `/api/v1/studio/**`도 템플릿 응답 봉투를 **그대로 쓴다**. 계약을 봉투 형태로 재정의한다 | 봉투는 `shared-contract/README.md:230`의 문서화된 결정(boundary D5/D6)이고 정보 손실이 없다. 적응 코드를 백엔드 *템플릿* 파일이 아니라 프론트 *제품* 코드에 두는 편이 template sync 충돌이 없다(§5.3) |
| D5 | `studio_idempotency` 테이블을 만들지 않고 기존 `idempotency_record`를 쓴다 | 기존 스키마가 설계 요구의 상위집합이고 `IdempotencyExecutorV2`까지 있다(§8.2) |
| D6 | Studio 통합 목록은 물리 인덱스 테이블 없이 union query로 시작한다 | 설계 08장 §4. 성능이 입증되면 read model 추가 |
| D7 | 커밋은 하지 않는다 | `AGENTS.md:64` — commit 정책 `human-only` |
### 3.1 D3 정정 — 실제 구현과의 차이 (2026-08-19, final whole-branch review B2)
이 절 작성 시점(§12 슬라이스 0 계획 단계)에는 openapi-generator 7.x가 이 저장소의 Spring Boot
버전과 함께 검증된 바 없었고, §5.2가 예정한 `generateApis=false` / `generateModels=true`
실제 스파이크(슬라이스 0)에서 두 가지가 틀린 것으로 드러났다. 아래는 spec이 스스로 요구한
"폴백을 쓰면 D3을 갱신한다"(구 §5.2)는 약속이 이행되지 않고 있던 것을 바로잡는 정정이다 —
구현이 폴백으로 넘어간 것은 아니고(생성기는 여전히 model을 생성한다), **생성기 설정 자체가
설계 시점에 존재하지 않는 API를 가정**했던 것이 실측으로 확인됐다.
- **`generateApis`/`generateModels`는 openapi-generator-gradle-plugin 7.18.0의 `openApiGenerate`
확장에 존재하지 않는다** (디컴파일로 확인, task-4-report.md). 대신 CLI `--global-property`
같은 뜻인 `globalProperties.set(['models': ''])`로 "models만" 생성하도록 제한한다. 실제 설정은
§5.2를 그대로 참조.
- **생성 소스는 `sourceSets.main.java.srcDir`에 있지 않다.** 별도 `generatedOpenapi` sourceSet에
있고, `main`/`test`의 compile·runtime classpath와 `jar` 산출물에 각각 명시적으로 이어 붙인다
(`src/adapter/inbound/web/build.gradle:22-38`). 이유: 생성 코드가 deprecated
`org.springframework.lang.Nullable`을 참조하는데, 저장소 루트 `build.gradle`
`-Werror`/`-Xlint:deprecation`이 이걸 컴파일 실패로 승격한다. `main`에 직접 넣으면 그 플래그가
생성 코드에도 적용돼 빌드가 깨진다 — 별도 sourceSet으로 분리하고 그 sourceSet의
`compileGeneratedOpenapiJava` 태스크에서만 `-Werror`/`-Xlint:deprecation`을 뺀다.
- **spec에 없던 `useOneOfInterfaces=false`가 결정적 옵션으로 추가됐다.** discriminator(`oneOf`)
union을 부모 Java interface로 생성하면(`useOneOfInterfaces=true`, openapi-generator 기본값)
discriminator getter가 항상 `String`을 반환하도록 SpringCodegen이 고정하는데, 하위 타입의 실제
getter 타입(공유 enum이든 아니든)과 충돌해 컴파일이 깨진다. 판별 필드를 하위 타입에서
narrowing하지 않도록 계약을 고쳐도 동일하게 깨지는 것까지 스크래치에서 직접 검증했다(3개 설정
조합, task-4-report.md) — 계약 쪽에서 우회할 수 없는 SpringCodegen 자체의 제약이다.
**`useOneOfInterfaces=false`의 대가 — 생성 union 5종이 파손됐다.** 이 경고는 지금
`src/adapter/inbound/web/build.gradle:139-155`의 주석에만 있고 spec 본문에는 없었다 — Plan 02
작성자가 읽는 문서는 이 spec이므로 여기 옮긴다.
`useOneOfInterfaces=false`는 컴파일은 통과시키지만, discriminator union 5종
(`WorkingCopyInput`, `WorkingCopy`, `Inline`, `CaseRenderBlock`, `PublicRenderModel`)의 하위
타입이 Java `implements` 관계를 전혀 갖지 않는 독립 클래스로 생성된다. 실측 결과 Jackson
양방향 배선도 계약을 어긴다:
- **역직렬화**는 `InvalidTypeIdException`으로 실패한다 (예: `Class CaseInput not subtype of
WorkingCopyInput`).
- **직렬화**는 생성된 `@JsonIgnoreProperties(value="kind", allowSetters=true)` 때문에 실제
discriminator 값 대신 클래스 simple name이 나간다 (예: 응답에 `"kind":"WorkingCopyInput"`).
이 union들을 요청·응답에 직접 또는 (409 `VersionConflictDetails`처럼) 간접적으로 포함하는
operation은 최소 `createStudioDocument`/`getStudioDocument`/`saveStudioDocument`
(`WorkingCopyInput`/`WorkingCopy`)와 `createStudioPreview`/`getCurrentStudioPreview`/
`getStudioPublicationSnapshot`(`PublicRenderModel`)이며, `validateStudioDocument`를 포함해
409 conflict 응답 경로로 더 넓게 새어 들어갈 수 있다 — Task 8/9(`getStudioSession`,
`listStudioCatalog`)는 이 union들을 쓰지 않아 막히지 않았을 뿐, 영향받는 operation의 정확한
목록과 대응 전략(수동 Jackson `@JsonTypeInfo`/`@JsonSubTypes` 재작성, 계약 재구조화, 또는 별도
수기 DTO)은 **Plan 02가 착수 전에 확정해야 한다.**
---
## 4. 코드 배치
### 4.1 패키지
```text
:domain-core
dev.caskeleton.domain.techlog.content Document / CaseDetail / ReferenceDetail
dev.caskeleton.domain.techlog.inquiry OpenQuestion
dev.caskeleton.domain.techlog.project ProjectDecision
dev.caskeleton.domain.techlog.asset Asset
dev.caskeleton.domain.techlog.publication Publication / PublicationEvent
dev.caskeleton.domain.techlog.<ctx>.vo Value Object
:application-core
dev.caskeleton.application.techlog.<ctx>.port.in Command · Query · *UseCase 인터페이스
dev.caskeleton.application.techlog.<ctx>.port.out Repository · Renderer · Clock 포트
dev.caskeleton.application.techlog.<ctx>.service *UseCase 구현
dev.caskeleton.application.techlog.studio.facade WorkingCopy / Validation / Preview / Publication
dev.caskeleton.application.techlog.studio.query Dashboard / Document / Publication / Catalog
dev.caskeleton.application.techlog.studio.mapper API 용어 ↔ Domain 용어
dev.caskeleton.application.techlog.studio.nextaction NextAction 계산
dev.caskeleton.application.techlog.studio.port.out Studio 자신의 read 포트(예: CatalogQueryPort) —
§4.3 경계 규칙 2가 금지하는 것은 studio가
*타* context의 port.out을 참조하는 것이지,
studio 자신의 port.out이 아니다
:adapter:outbound:persistence-jpa
dev.caskeleton.adapter.outbound.persistence.techlog.<ctx>.entity
dev.caskeleton.adapter.outbound.persistence.techlog.<ctx>.repository
dev.caskeleton.adapter.outbound.persistence.techlog.<ctx>.mapper
dev.caskeleton.adapter.outbound.persistence.techlog.query JdbcClient union query
:adapter:inbound:web
dev.caskeleton.adapter.inbound.web.techlog.studio.controller
dev.caskeleton.adapter.inbound.web.techlog.studio.mapper
dev.caskeleton.adapter.inbound.web.techlog.studio.problem
:app-bootstrap
dev.caskeleton.bootstrap.techlog 조립·설정만
```
`modules.json`, `settings.gradle`, `verifyCleanArchitectureDependencies`는 변경하지
않는다.
### 4.2 상속되는 기존 가드레일
신규 코드에 자동으로 적용된다.
- `..domain..` 순수성 (프레임워크·전송·DB 의존 금지)
- `..domain.vo..` / `@ValueObject`: public 무인자 생성자 금지
- `@AggregateRoot`: `set*` 메서드 public 금지
- `@DomainEvent`: record 필수
- `..application..`: `@Transactional` 금지 → `TransactionPort` 사용
- `..application..`: `ApplicationContext` 의존 금지
- `CommandUseCase`/`QueryUseCase` 구현: 이름이 `UseCase`로 끝나야 하고
`@UseCaseCapability` 필수
- `verifyApplicationCoreDependencyPurity`: application-core 생산 의존은 project-only,
클래스패스에 Spring/slf4j/logback/micrometer 금지
### 4.3 추가할 경계 규칙 — `TechLogBoundaryArchTest`
`:app-bootstrap` 테스트에 추가한다.
1. `techlog.content` / `techlog.inquiry` / `techlog.project` / `techlog.asset`은
서로 의존하지 않는다.
2. `application.techlog.studio`는 타 context의 `port.in`만 참조한다.
타 context의 `domain`, `port.out`, `service` 직접 참조는 위반이다. (studio 자신의
`application.techlog.studio.port.out`은 이 규칙의 대상이 아니다 — §4.1 참조.)
3. 타 context는 `application.techlog.studio`를 참조하지 않는다 (역방향 금지).
4. `domain.techlog.publication`을 제외한 어떤 domain 패키지도 `Publication`을
직접 변경하지 않는다.
설계 08장의 "`studio`는 도메인 모듈이 아니다"를 빌드로 강제하는 장치다.
---
## 5. 계약 → 코드
### 5.1 계약 원본
`studio-v1.yaml`을 `src/config/openapi/studio-v1.yaml`로 복사한다
(`src/config/architecture`, `src/config/messaging`과 같은 authority 위치).
`src/config/openapi/MANIFEST.sha256`에 해시를 기록해 설계 패키지와의 드리프트를
가시화한다.
### 5.2 생성
`:adapter:inbound:web`에 `org.openapi.generator` 플러그인을 추가한다. 아래는 실제 구현 설정
(`src/adapter/inbound/web/build.gradle`)이다 — 이 절이 원래 예정했던 `generateApis=false`/
`generateModels=true`는 openapi-generator-gradle-plugin 7.18.0에 존재하지 않는 프로퍼티였다.
무엇이 왜 달라졌는지는 §3.1 참조.
```text
generatorName spring
globalProperties ['models': ''] ← "models만" 생성 (CLI --global-property와 동치)
modelPackage dev.caskeleton.adapter.inbound.web.techlog.studio.api.model
useSpringBoot3 true
useJakartaEe true
openApiNullable true (jackson-databind-nullable 이미 선언됨)
useOneOfInterfaces false ← §3.1 — discriminator union 5종의 Jackson 배선이 깨지는 대가
output build/generated/openapi
```
- 생성 소스는 `sourceSets.main.java.srcDir`가 아니라 **별도 `generatedOpenapi` sourceSet**에
있다. 생성 코드가 deprecated `org.springframework.lang.Nullable`을 쓰는데 저장소 루트의
`-Werror`가 이를 빌드 실패로 승격하기 때문이다 — 자세한 배선은 §3.1과
`build.gradle:1-38`의 주석 참조. `jar`/`test` 클래스패스에는 별도로 명시적으로 얹는다.
- spotless / checkstyle / spotbugs / errorprone 대상에서 제외한다.
- `adapter/inbound/web/gradle.lockfile`을 `--write-locks`로 재생성한다.
#### 왜 API interface를 생성하지 않는가
D4로 응답 봉투를 유지하면 계약의 성공 응답 스키마가 봉투 wrapper가 된다. 그러면
생성 interface의 signature가 `ResponseEntity<StudioSessionEnvelope>`가 되는데,
`StudioSessionEnvelope`는 생성된 별개 클래스라 `dev.caskeleton.shared.response.Envelope`가
아니다. `EnvelopeBodyAdvice.beforeBodyWrite`는 `Envelope`/`BulkEnvelope`만 통과시키므로
이 본문을 **한 번 더 감싼다**.
```text
controller → StudioSessionEnvelope
EnvelopeBodyAdvice → Envelope<StudioSessionEnvelope>
wire → {"success":true,"data":{"success":true,"data":{...},"meta":{...}},"meta":{...}}
```
이걸 피하려면 `EnvelopeBodyAdvice`(템플릿 파일)를 고쳐야 하는데, 그건 D4가 피하려던
바로 그 template sync 충돌이다.
따라서 **model만 생성하고 controller는 손으로 쓴다.**
```java
@RestController
final class StudioSessionController {
@GetMapping("/api/v1/studio/session")
StudioSession getStudioSession() { // 생성된 payload DTO를 그대로 반환
return mapper.toApi(facade.currentSession());
}
}
// EnvelopeBodyAdvice가 여기서 정확히 한 번 감싼다.
```
controller는 facade 호출과 매핑만 하고 비즈니스 로직을 두지 않는다.
봉투 wrapper 스키마도 함께 생성되지만 백엔드는 쓰지 않는다. 계약의 wrapper는
소비자(프론트·외부 도구)를 위한 기술이고, 백엔드에서는 advice가 그 역할을 한다.
**선행 검증 결과:** openapi-generator 7.x × 이 저장소의 Spring Boot 조합은 슬라이스 0의 폐기용
스파이크로 검증했다. 폴백(생성기 1회 실행 후 수기 유지)으로 넘어가지는 않았다 — 생성기는 지금도
빌드마다 model을 생성한다. 대신 §5.2 상단에 적은 세 가지(`globalProperties`, 별도 sourceSet,
`useOneOfInterfaces=false`)가 스파이크에서 드러난 실제 조건이었다. §3.1이 그 정정 기록이다.
### 5.3 wire format — 템플릿 봉투를 그대로 쓴다
성공과 실패가 한 모양을 공유한다.
```jsonc
// 성공 (200 / 201)
{ "success": true, "data": { /* studio-v1 payload */ }, "meta": { "requestId": "...", "traceId": "...", "correlationId": "..." } }
// 실패 (4xx / 5xx) — HTTP status는 그대로 의미를 갖는다
{ "success": false, "error": { "code": "VERSION_CONFLICT", "category": "CONFLICT",
"message": "...", "retryable": false,
"details": { /* code별 polymorphic */ } },
"meta": { "requestId": "...", "traceId": "...", "correlationId": "..." } }
// 204 (deleteStudioAsset) — 본문 없음. EnvelopeBodyAdvice는 null body를 감싸지 않는다.
```
미디어 타입은 성공·실패 모두 `application/json`이다. `application/problem+json`은 쓰지
않는다.
#### 왜 이 방향인가
- 봉투는 이 템플릿의 **문서화된 결정**이다. `shared-contract/README.md:230` —
"RFC 7807 ProblemDetail 을 대체한다(boundary D5/D6)", "D5 가 RFC 7807 ProblemDetail 을
거부하고, D10 이 `category`를 1급 필드로 추가했다".
- **정보 손실이 없다.** §5.4의 매핑표 참조. `error.category`는 봉투 쪽이 추가로 준다.
- **적응 코드의 위치가 결정적이다.** 봉투를 벗기려면 `EnvelopeBodyAdvice.supports()`를
고쳐야 하는데 이는 템플릿 파일이고, 이 저장소는 `template.lock.json`의
`"materialization": "tracked-snapshot"`이라 이후 모든 template sync의 충돌 지점이 된다.
반대로 봉투를 유지하면 적응은 프론트 **제품 코드**
(`src/features/tech-log/contracts/tech-log-studio-contract-contribution.ts`) 안에서
끝나고 프론트 플랫폼(`src/adapters/http/http-execution-v3.ts`)도 무변경이다.
#### 백엔드가 해야 할 일
**봉투 관련 신규 작업은 없다.** `EnvelopeBodyAdvice`, `ErrorResponseFactory`,
`GlobalExceptionHandler`, `EnvelopeAuthenticationEntryPoint`,
`EnvelopeAccessDeniedHandler`, `RateLimitInterceptor`를 그대로 쓴다. 필요한 것은
§5.4의 오류 코드 등록과 Studio 전용 예외 → `ApiError` 매핑뿐이다.
#### 계약과 프론트가 해야 할 일
이 결정은 세 저장소에 걸친다. 상세는 §5.6.
```text
tech-log-design-package studio-v1.yaml을 봉투 형태로 재정의 (+ 06장 · ADR · MASTER_SPEC · 검증 스크립트)
tech-log-frontend 계약 재생성 + validator 2개를 봉투 언랩으로 교체
tech-log-backend 오류 코드 등록 + 예외 매핑 (봉투 자체는 무변경)
```
### 5.4 오류 코드
계약의 오류 코드 23개를 `ApiError.code`에 그대로 싣는다.
```text
AUTHENTICATION_REQUIRED STUDIO_ACCESS_DENIED DOCUMENT_NOT_FOUND
VERSION_CONFLICT REQUEST_VALIDATION_FAILED VALIDATION_FAILED
VALIDATION_STALE PREVIEW_NOT_FOUND PREVIEW_STALE
PREVIEW_EXPIRED PUBLICATION_NOT_FOUND PUBLICATION_CONFLICT
PUBLICATION_EVENT_NOT_FOUND PUBLICATION_SNAPSHOT_NOT_FOUND
WARNING_ACKNOWLEDGEMENT_REQUIRED IDEMPOTENCY_KEY_REUSED
ASSET_NOT_FOUND ASSET_NOT_READY ASSET_IN_USE
ASSET_QUARANTINED PAYLOAD_TOO_LARGE UNSUPPORTED_MEDIA_TYPE
STUDIO_UNAVAILABLE
```
#### 필드 매핑 — 정보 손실 없음
| 기존 계약 `ProblemDetails` | 봉투 |
| --- | --- |
| `code` | `error.code` |
| `retryable` | `error.retryable` (1급) |
| `detail` | `error.message` (client-safe. stack trace·내부 ID 금지) |
| `status` | HTTP status (봉투도 실제 status를 유지한다) |
| `traceId` | `meta.traceId` (D7 — 응답에서 절대 null이 아니다) |
| `fieldErrors` | `error.details` = `ValidationErrorDetails` |
| `latestDocument` | `error.details` = `VersionConflictDetails` |
| `latestPublication` | `error.details` = `PublicationConflictDetails` |
| `conflictingFields` | 위 두 details 안 |
| `type` / `title` | 버린다. 프론트가 이미 `code`에서 합성한다(`studio-error-mapping.ts`의 `synthetic()`) |
| — | `error.category` — 봉투가 추가로 준다 |
`details`는 code별 polymorphic이므로 계약에서 `oneOf` 타입 변형으로 선언한다.
`Object` 자유형으로 두지 않는다.
#### 레지스트리 등록
`docs/registries/error-codes.yaml`에 23개 row를 additive로 추가한다. row 스키마가
요구하는 필드를 모두 채운다 — `category`(10-value `Category` enum), `http_status`,
`retryable`, `owner_layer`, `client_safe_message`, `log_level`, `runbook_link`,
`compatibility_impact: additive`, `required_test`.
runbook 정책: `retryable=false`이고 category가 `AUTH`/`AUTHZ`/`RATE_LIMIT`/`INTERNAL`/
`TRANSIENT_DEPENDENCY`/`PERMANENT_DEPENDENCY`면 `runbook_link`가 필수다.
`VALIDATION`/`NOT_FOUND`/`CONFLICT`/`DATA_INTEGRITY`는 client-error로 면제 가능하다.
따라서 `AUTHENTICATION_REQUIRED`(AUTH), `STUDIO_ACCESS_DENIED`(AUTHZ),
`STUDIO_UNAVAILABLE`(TRANSIENT_DEPENDENCY)는 runbook을 함께 작성한다.
category 배정:
```text
AUTH AUTHENTICATION_REQUIRED
AUTHZ STUDIO_ACCESS_DENIED
NOT_FOUND DOCUMENT_NOT_FOUND PREVIEW_NOT_FOUND PUBLICATION_NOT_FOUND
PUBLICATION_EVENT_NOT_FOUND PUBLICATION_SNAPSHOT_NOT_FOUND
ASSET_NOT_FOUND
CONFLICT VERSION_CONFLICT PUBLICATION_CONFLICT IDEMPOTENCY_KEY_REUSED
VALIDATION_STALE PREVIEW_STALE PREVIEW_EXPIRED
ASSET_IN_USE ASSET_NOT_READY
VALIDATION REQUEST_VALIDATION_FAILED VALIDATION_FAILED
WARNING_ACKNOWLEDGEMENT_REQUIRED
PAYLOAD_TOO_LARGE UNSUPPORTED_MEDIA_TYPE
DATA_INTEGRITY ASSET_QUARANTINED
TRANSIENT_DEPENDENCY STUDIO_UNAVAILABLE
```
### 5.5 계약 회귀 테스트
**실제 구현 범위 (Task 10, `StudioContractDriftTest`, `:app-bootstrap`의 functional test
소스셋):** springdoc이 노출하는 `/v3/api-docs`를 `src/config/openapi/studio-v1.yaml`과 대조해
**operation id · method · path**가 어긋나면 실패시킨다 (`published ⊆ contract` 방향 — 아직
구현하지 않은 operation이 있어도 green을 유지한다). `dev.caskeleton.adapter.inbound.web.techlog`
아래를 `@ComponentScan`하므로 그 패키지 트리 밖에 컨트롤러를 두면 이 게이트가 못 본다 (스캔
범위를 벗어나는 즉시 유효성을 잃는다는 뜻이므로 §4.1의 패키지 배치를 반드시 따른다). 별도로
`getStudioSession`/`listStudioCatalog` 각 1개 케이스에 대해 성공 응답이
`{success:true, data, meta}` 모양이고 이중 래핑이 없는지도 고정한다 — **모든 operation**의
봉투 래핑을 확인하는 것은 아니다.
**아직 구현하지 않은 것 (Plan 02 몫):**
- 응답 media type 대조 (`application/json` 고정 여부)
- 스키마 필수 필드 대조 (93개 schema·enum·required 필드가 model 생성으로 덮인다는 D3의 전제를
실제로 검증하는 회귀 테스트는 없다 — 지금은 생성이 컴파일에 성공하는 것으로 암묵 검증한다)
- 나머지 17개 operation에 대한 봉투 래핑 확인
- `deleteStudioAsset` 204 본문 없음 확인 (해당 operation 미구현)
**401/403/429 코드 주장 — 삭제.** 이 절은 원래 401/403/429가 각각
`AUTHENTICATION_REQUIRED`/`STUDIO_ACCESS_DENIED`/rate-limit 코드로 난다고 적었으나 이는
코드상 사실이 아니다. 기존(템플릿 소유, 무변경 재사용) 경로가 실제로 내는 코드는:
- 401 — `EnvelopeAuthenticationEntryPoint` → `SecurityErrorClassifier.classifyAuthentication`
(`src/adapter/inbound/web/src/main/java/.../auth/SecurityErrorClassifier.java:21-35`)이
`AUTH_TOKEN_MISSING`(`InsufficientAuthenticationException`) 또는 `AUTH_TOKEN_MALFORMED`(그 외
분류 불가 인증 실패)를 낸다.
- 403 — `EnvelopeAccessDeniedHandler` → `SecurityErrorClassifier.classifyAccessDenied` (같은
파일:39)가 `AUTHZ_INSUFFICIENT_PERMISSION`을 낸다.
- 429 — `RateLimitInterceptor:75`가 `RATE_LIMIT_EXCEEDED`를 낸다.
이 세 코드 모두 계약(studio-v1.yaml) 23종 `ApiError.code` enum에 없다. 429는 계약에
rate-limit 코드 자체가 존재하지 않는다. §5.3 "백엔드가 해야 할 일"이 "봉투 관련 신규 작업은
없다"고 못 박았고 이 경로들은 템플릿 파일(`EnvelopeAuthenticationEntryPoint`,
`EnvelopeAccessDeniedHandler`, `RateLimitInterceptor`, `SecurityErrorClassifier`)만으로
동작해 Studio가 손댈 수 없다 — 계약을 이 세 코드로 확장할지, 별도 Studio 매핑 계층을 둘지는
이 spec이 결정하지 않은 채 남아 있다. **Plan 02가 착수 전에 결정해야 한다.**
### 5.6 계약 재정의 명세
`studio-v1.yaml`을 다음과 같이 바꾼다. 이것이 세 저장소의 공유 SSOT가 된다.
**추가 스키마**
```text
ResponseMeta requestId, traceId, correlationId, page(nullable)
ApiError code(enum 23), category(enum 10), message, retryable, details(oneOf|null)
ErrorEnvelope success(const false), error(ApiError), meta(ResponseMeta)
ValidationErrorDetails fieldErrors[]
VersionConflictDetails latestDocument, conflictingFields[]
PublicationConflictDetails latestPublication
```
**성공 응답 래핑** — JSON 본문을 갖는 18개 operation(200 15개 / 201 3개)의 응답
스키마를 `{success: const true, data: <기존 payload>, meta: ResponseMeta}` 래퍼로
교체한다. OpenAPI 3.1에 제네릭이 없으므로 payload별 래퍼 스키마를 만든다.
기존 payload 스키마(`StudioSession`, `WorkingCopyDetail`, …)는 **그대로 남긴다** —
프론트가 `components["schemas"]["StudioSession"]`로 도메인 타입을 계속 뽑아 쓴다.
**오류 응답 교체** — `components.responses`의 16개 항목 content를
`application/problem+json` + `ProblemDetails`에서 `application/json` + `ErrorEnvelope`로
바꾼다. `ProblemDetails` 스키마는 제거한다.
`deleteStudioAsset`의 204는 변경 없다.
**`public-v1.yaml` / `studio-management-v1.yaml`** — 두 계약도 `ProblemDetails`를
쓰지만 이번 구현 범위 밖이고 소비자가 없다. 지금 변환하지 않고 각 파일 상단에
"봉투 결정(ADR-006) 반영 대기" 배너를 붙인다. 설계 패키지가 `docs/plans/02`에 쓴
STALE 배너와 같은 방식이다. 구현에 착수할 때 변환한다.
**따라오는 파일**
```text
tech-log-design-package
contracts/openapi/studio-v1.yaml 위 변경
docs/specs/06-api-contract-design.md 7장 오류 계약 재작성
decisions/ADR-006-response-envelope.md 신규 — 봉투 채택 근거와 RFC 7807 미채택 기록
TECH_LOG_MASTER_SPEC.md scripts/build-master-spec.sh 재생성
MANIFEST.sha256 scripts/update-manifest.sh 재생성
scripts/check-contract-parity.py ProblemDetails.code → ApiError.code 참조 변경
scripts/check-consistency.py 동일
tech-log-frontend
src/features/tech-log/contracts/studio/studio-api.openapi.yaml 동기화
src/features/tech-log/contracts/studio/generated.ts 재생성
src/features/tech-log/contracts/studio/canonical-source.json 재생성
src/features/tech-log/contracts/studio/contract.ts 타입 export 확인
src/features/tech-log/contracts/tech-log-studio-contract-contribution.ts
outputValidator passthrough → 봉투 data 언랩
problemValidator PROBLEM(bare) → 봉투 error 언랩 + code enum 검증 유지
src/features/tech-log/adapters/http/studio-error-mapping.ts ApiError → StudioGatewayError
관련 테스트 · 픽스처의 HTTP 본문
```
`mock-studio-gateway.ts` 등 `StudioGateway` 포트를 직접 구현하는 mock은 전송 경계
아래가 아니라 위에 있으므로 **변경 대상이 아니다.** 앱·도메인·프레젠테이션 계층도
언랩이 경계에서 끝나므로 무변경이다.
---
## 6. 도메인 모델
### 6.1 Aggregate
| Aggregate | 소유 테이블 | 비고 |
| --- | --- | --- |
| `Document` | `document`, `case_detail`, `reference_detail`, `document_tag`, `document_relation` | `RecordKind.CASE` / `REFERENCE` |
| `OpenQuestion` | `open_question`, `question_point`, `question_update`, `question_tag`, `question_document_link` | `RecordKind.QUESTION` |
| `ProjectDecision` | `project_decision` | `RecordKind.PROJECT_DECISION` |
| `Asset` | `asset`, `asset_reference` | |
| `Publication` | `publication`, `publication_event`, `publication_snapshot` | Event·Snapshot은 불변 |
`WorkingCopy`는 Aggregate가 아니라 **API projection**이다. `working_copy` 범용
테이블을 만들지 않는다.
### 6.2 상태 기계 (설계 09장)
```text
Document DRAFT → IN_REVIEW → PUBLISHED → ARCHIVED (+ unpublish: PUBLISHED → DRAFT)
OpenQuestion OPEN → INVESTIGATING → PAUSED → RESOLVED → ARCHIVED
Publication PUBLISHED → REPUBLISHED → UNPUBLISHED → REPUBLISHED
```
**API 용어와 Domain 용어를 분리한다.**
```text
API questionStatus=OPEN ← Domain OPEN | INVESTIGATING | PAUSED
API questionStatus=RESOLVED ← Domain RESOLVED
```
`saveStudioDocument`는 편집 가능한 content field만 저장하며 **lifecycle 전이를
유발하지 않는다.** OPEN 계열 안에서의 값 변화는 무시한다. 프론트가 축약 상태를
보냈다는 이유로 `INVESTIGATING`을 `OPEN`으로 덮어쓰면 조사 이력이 소실된다.
### 6.3 저장하지 않는 값
`nextAction`, `publicationStatus`, `hasUnpublishedChanges`, Preview state는
**조회 시점 계산**이다. `workflow_status` 같은 domain 컬럼에 저장하지 않는다.
---
## 7. 애플리케이션 계층
### 7.1 use case 규약
context별 use case는 `CommandUseCase<C, R>` / `QueryUseCase<Q, R>`를 구현하고
이름이 `UseCase`로 끝나며 `@UseCaseCapability`를 선언한다.
```java
@UseCaseCapability(
transactionMode = TransactionMode.WRITE,
idempotency = Idempotency.KEYED,
repositoryAccess = RepositoryAccess.WRITE_REPOSITORY)
final class PublishCaseUseCase implements CommandUseCase<PublishCaseCommand, PublishResult> { }
```
트랜잭션 경계는 `@Transactional`이 아니라 `TransactionPort`로 연다.
`TransactionMode`는 `WRITE` / `READ_ONLY` / `REQUIRES_NEW` 계열이고,
`Idempotency`는 `IDEMPOTENT` / `KEYED` / `NOT_IDEMPOTENT`,
`RepositoryAccess`는 `NONE` / `READ_REPOSITORY` / `WRITE_REPOSITORY`다.
`Idempotency-Key`를 쓰는 mutation은 `KEYED`로 선언한다.
### 7.2 studio facade
```text
StudioWorkingCopyFacade create / get / save / list → 유형별 port.in dispatch
StudioValidationFacade validate → validation artifact 영속
StudioPreviewFacade create / get preview artifact + state 계산
StudioPublicationFacade publish / unpublish / 이력 / snapshot 조회
StudioDashboardQuery union query 기반 대시보드
StudioDocumentQuery union query 기반 문서 목록
StudioCatalogQuery TOPIC | PROJECT | RELATION | EVIDENCE
NextActionCalculator §7.4
StudioDocumentLocator documentId(UUID) → (sourceKind, sourceId)
```
facade는 domain object를 직접 수정하지 않는다. 타 context의 `port.in`만 호출하고
결과를 통합 DTO로 조립한다.
`documentId`는 계약상 **source aggregate id를 그대로 쓴다**(별도 surrogate 없음).
`StudioDocumentLocator`가 `document` / `open_question` / `project_decision` union
query로 kind를 해소한다.
### 7.3 Validation / Preview artifact
```text
studio_validation validationId, sourceKind, sourceId, validatedVersion,
status(INVALID|WARNINGS|VALID), issues(jsonb),
dependencyRevision, validatedAt, validUntil, createdBy
studio_preview previewId, sourceKind, sourceId, sourceVersion,
validationId(FK), dependencyRevision,
renderModel(jsonb), createdAt, expiresAt, createdBy
```
`dependencyRevision`은 검증에 쓴 외부 의존 상태(topic/project publishability,
relation target 상태, asset READY/QUARANTINED, slug/route ownership, catalog
revision, renderer/content-format version)의 identity+version을 정규화해 만든
해시다. 전역 카운터가 아니다.
상태는 저장하지 않고 조회 시 계산한다.
```text
Validation 유효 validatedVersion == 현재 working version
AND dependencyRevision == 현재 계산값
AND now() < validUntil
Preview CURRENT sourceVersion == 현재 working version
AND dependencyRevision == 현재 계산값
AND now() < expiresAt
STALE version 또는 dependencyRevision 불일치
EXPIRED now() >= expiresAt
```
`VALIDATION_FAILED`(지금 검증하면 실패)와 `VALIDATION_STALE`(통과했으나 전제가
바뀜)은 다른 사건이며 코드도 다르다.
### 7.4 nextAction 계산
```text
저장 가능한 형태조차 미달 → CONTINUE_EDITING
현재 version에 대한 Validation 없음 → VALIDATE
현재 Validation = INVALID → FIX_VALIDATION
유효 Validation + 현재 version Preview 없음 → CREATE_PREVIEW
Preview가 STALE 또는 EXPIRED → CREATE_PREVIEW
현재 Validation + 현재 Preview
+ Publication version 불일치 → PUBLISH
Publication version == working version → NONE
```
### 7.5 Publish 트랜잭션 (설계 07장 §14, 20단계)
```text
0. idempotency 선검사 — 동일 key + 동일 fingerprint면 최초 응답 재생, 이하 미실행
1. Document SELECT FOR UPDATE
2. expectedVersion 검증 불일치 → VERSION_CONFLICT
3. validationId 조회 + dependencyRevision 재계산 비교 불일치 → VALIDATION_STALE
4. previewId 조회 + sourceVersion / dependencyRevision 비교
불일치 → PREVIEW_STALE, 만료 → PREVIEW_EXPIRED
5. acknowledgedWarningCodes가 WARNING 집합을 덮는지
미달 → WARNING_ACKNOWLEDGEMENT_REQUIRED
6. 유형별 publication validation (게시 필수 필드 / slug 충돌)
7. Markdown 분석
8. Asset READY 및 사용 위치 alt/decorative 검증
9. 공개 payload 생성
10. Publication Event 생성 (PUBLISHED | REPUBLISHED)
11. Publication Snapshot 생성 (immutable)
12. public_resource_projection upsert
13. public_route canonical/alias 변경
14. public_resource_tag / project link 교체
15. PUBLISHED asset_reference 교체
16. asset.first_published_at 갱신
17. Publication aggregate 갱신 (latest_event_id, publication_revision)
18. Document publish metadata 갱신
19. 선택적 ProjectActivity 생성
20. Commit
```
전 단계가 하나의 트랜잭션이다.
**Snapshot의 render model은 게시 시점에 다시 렌더링하지 않고 사용자가 확인한
Preview의 render model을 그대로 쓴다.** 재렌더링하면 승인한 화면과 공개된 화면이
달라질 수 있다.
Unpublish는 `expectedPublicationRevision` 검증 → `UNPUBLISHED` Event(반드시
`sourcePublishedEventId` 보유) → Publication 상태 전환 및 revision 증가 →
Projection `ACTIVE → WITHDRAWN` → Working `workflow_status → DRAFT` → route 유지 →
commit. Snapshot은 삭제하지 않는다.
---
## 8. 영속화
### 8.1 마이그레이션
`PostgreSqlPersistenceConfig.java:57`이 Flyway location을
`classpath:db/migration/postgresql`로 고정한다. 기존 최대 버전이 V6이고
`out-of-order: false`이므로 **`V7__techlog_core.sql`부터** 추가한다.
이번 범위에 필요한 테이블:
```text
topic tag document case_detail reference_detail document_tag document_relation
open_question question_point question_update question_tag question_document_link
project project_decision project_document_link project_question_link project_activity
asset asset_reference
studio_validation studio_preview
publication publication_event publication_snapshot
public_resource_projection public_route public_resource_tag public_resource_project_link
```
`publication.latest_event_id` ↔ `publication_event.publication_id` 순환 FK는
`DEFERRABLE INITIALLY DEFERRED`로 선언한다. 즉시 검사로 두면 첫 게시가 불가능하다.
`publication_event` / `publication_snapshot`은 어떤 cleanup job도 삭제하지 않는다.
### 8.2 멱등 — 기존 자산 재사용 (D5)
설계의 `studio_idempotency` 테이블을 만들지 않는다.
| 설계 `studio_idempotency` | 기존 `idempotency_record` |
| --- | --- |
| `idempotency_key` | `idempotency_key` |
| `operation_id` | `use_case_name` |
| `request_fingerprint` | `request_hash` (char(64) sha256) |
| `response_status` / `response_body` | `response_payload` |
| `created_at` / `expires_at` | `created_at` / `expires_at` |
| `created_by` | `principal` (+ `tenant`) |
기존 것이 상위집합이고 `IdempotencyExecutorV2`, `IdempotencyStorePortV2`,
`IdempotencyKeySupport`(header 추출 · scope · sha256 fingerprint · JSON codec)까지
있다. 설계 대비 결손은 두 가지뿐이며 web 계층에서 채운다.
1. 재생 시 `Idempotency-Replayed: true` 응답 헤더
2. fingerprint 불일치 시 `IDEMPOTENCY_KEY_REUSED` ProblemDetails (409)
적용 대상: create / save / validate / create preview / publish / unpublish /
asset upload · update · delete.
### 8.3 읽기·쓰기 분리
- 쓰기: JPA aggregate + `@Version` 낙관적 잠금
- Studio 목록 / 대시보드 / 카탈로그: `JdbcClient` union query (도메인 repository 우회)
- 공통 CRUD repository를 만들지 않는다
---
## 9. 재사용 매핑 (신규 구현 금지)
| 설계 요구 | 재사용 대상 |
| --- | --- |
| `Idempotency-Key` | `application/idempotency/v2`, `web/idempotency/IdempotencyKeySupport`, `idempotency_record` |
| `expectedVersion` / 409 | JPA `@Version`, `web/conditional/ETags`, `PreconditionFailedException` |
| Studio cursor 페이지네이션 | `web/cursor/CursorCodec`, `web/pagination/PageParams` |
| 오류 응답 골격 | `shared/response/{Envelope,ApiError,ResponseMeta}`, `web/error/ErrorResponseFactory`, `GlobalExceptionHandler` — **무변경 재사용** |
| 401 / 403 / 429 | `EnvelopeAuthenticationEntryPoint`, `EnvelopeAccessDeniedHandler`, `RateLimitInterceptor` — **무변경 재사용** |
| Keycloak 세션 · CSRF | `web/auth/SecurityConfig`, `JwtDecoderConfig`, `PrimitiveSessionSecurityContextRepository`, `RedisSessionWebConfig` |
| 권한 | `application/security/RequiresPermission`, `web/authz/RolePermissionPolicy`, `RequiresPermissionAuthorizationManager` |
| Asset 업로드 · 저장 | `adapter:outbound:fileserver`, `adapter:outbound:objectstorage` |
| ID 생성 | `adapter:outbound:identifier` |
| 캐시 | `application/cache`, `adapter:outbound:cache-redis` (필요 입증 시에만) |
| 관측 | `web/observability`, `docs/registries/{metrics,mdc-keys,headers}.yaml` |
| 트랜잭션 | `application/transaction/TransactionPort` |
**Asset은 새 저장 계층을 만들지 않는다.** `asset` 테이블은 metadata·`asset_key
lifecycle만 소유하고 바이너리 저장·전송은 fileserver/objectstorage 어댑터에 위임한다.
`asset_key`(안정 참조)와 `object_key`(저장 위치)를 분리한다 — 본문에 object storage
URL을 저장하지 않는다.
---
## 10. 렌더러
`PublicRenderModel`은 Markdown을 의미 블록으로 변환한 결과다. 계약이 정의하는
블록·인라인 타입:
```text
블록 HeadingBlock ParagraphBlock CodeBlock BlockquoteBlock CalloutBlock
OrderedListBlock UnorderedListBlock DataTableBlock EvidenceFigureBlock
인라인 InlineText InlineStrong InlineEmphasis InlineCode InlineLink InlineStatus
```
제약:
- Public과 Studio Preview가 **같은 의미의 렌더 결과**를 써야 한다 (ADR-005).
- Snapshot은 `contentFormatVersion`, `rendererContractVersion`, asset manifest를 함께
보존한다. 이후 Asset이 교체되어도 과거 Snapshot의 표현이 변하지 않는다.
- Asset은 `assetKey`로 참조하고 렌더 시 `ResolvedAsset`으로 해소한다.
**이 항목이 이번 구현의 최대 리스크다.** 슬라이스 3 착수 전에 프론트의 기존 렌더
모델 구현(`src/features/tech-log/adapters/mock/project-public-render-model.ts` 및
static content 경로)을 기준선으로 대조해 블록 의미가 일치하는지 확인한다.
---
## 11. 테스트 전략
leaf별 템플릿 정책을 그대로 따른다.
| 대상 | 방식 |
| --- | --- |
| `domain-core` | 순수 JUnit. 상태 전이·불변 조건 |
| `application-core` | 손수 만든 fake 포트. 웹·영속 컨텍스트 금지 |
| `adapter:inbound:web` | 전송 slice 테스트 + §5.5 봉투 래핑·오류 코드 회귀 테스트 |
| `adapter:outbound:persistence-jpa` | 매핑·포트 계약 테스트. 벤더 의미가 필요한 것(deferrable FK, `SELECT FOR UPDATE`)은 `postgresqlIntegrationTest` 소스셋 |
| `app-bootstrap` | 배선·`TechLogBoundaryArchTest`·계약 회귀 테스트 |
필수 시나리오:
- publish 재시도가 중복 `publication_event`를 만들지 않는다
- `VALIDATION_STALE`과 `VALIDATION_FAILED`가 구분된다
- `saveStudioDocument`가 `INVESTIGATING`을 `OPEN`으로 되돌리지 않는다
- unpublish 후 과거 Snapshot이 그대로 조회된다
- 성공 응답이 `{success:true, data, meta}`이고 `meta.traceId`가 non-null이다
- 오류 응답이 `{success:false, error, meta}`이고 `error.code`가 계약의 23개 중 하나다
- 409가 `error.details`로 `latestDocument` / `latestPublication`을 싣는다
검증 명령 (`src/`에서):
```bash
./gradlew :domain-core:test --console=plain
./gradlew :application-core:test --console=plain
./gradlew :adapter:inbound:web:test --console=plain
./gradlew :adapter:outbound:persistence-jpa:test --console=plain
./gradlew verifyCleanArchitectureDependencies --console=plain
./gradlew :app-bootstrap:test --tests '*CleanArchitectureTest' --console=plain
./gradlew :app-bootstrap:test --tests '*TechLogBoundaryArchTest' --console=plain
```
---
## 12. 슬라이스
| # | 내용 | 완료 판정 |
| --- | --- | --- |
| 0 | **계약 봉투 재정의(§5.6) → 프론트 재생성·validator 교체**, 생성기 스파이크 → 배선, 계약 복사, `TechLogBoundaryArchTest`, 오류코드 레지스트리 23개, `V7__techlog_core.sql` | 빌드·아키텍처 검증 통과 + 3 저장소 계약 parity 통과 |
| 1 | `getStudioSession`, `listStudioCatalog` | 프론트 로그인·카탈로그 실동작 |
| 2 | `listStudioDocuments`, `createStudioDocument`, `getStudioDocument`, `saveStudioDocument` (+ 낙관적 잠금, 멱등) | 프론트 편집 실동작 |
| 3 | `validateStudioDocument`, `createStudioPreview`, `getCurrentStudioPreview`, `dependencyRevision`, `nextAction`, 렌더러 | 프론트 검증·미리보기 실동작 |
| 4 | `publishStudioDocument`, `unpublishStudioPublication`, `listStudioPublications`, `getStudioPublicationSnapshot`, `getStudioDashboard` | 프론트 게시 실동작 |
| 5 | asset 5종 | 프론트 자산 실동작 |
`getStudioDashboard`는 publication·validation 데이터에 의존하므로 슬라이스 4에서
완성한다.
---
## 13. 리스크
| 리스크 | 완화 |
| --- | --- |
| openapi-generator × Spring Boot 4.0.0 미검증 | 슬라이스 0 첫 스텝을 폐기용 스파이크로. 실패 시 §5.2 폴백 |
| 3 저장소(계약·프론트·백엔드) 동기화 실패 | 슬라이스 0에서 계약을 먼저 확정하고 `check-contract-parity.py`를 게이트로 삼는다. 백엔드는 §5.5 계약 회귀 테스트로 고정 |
| 프론트 언랩 누락 시 조용한 실패 (`outputValidator`가 passthrough라 전송 계층이 잡지 못한다) | 언랩 validator에 `success`/`data` 존재 검증을 넣어 하드 실패로 바꾼다 |
| 렌더 모델 의미 불일치 | 슬라이스 3 착수 전 프론트 구현 대조 |
| publish 20단계 트랜잭션 복잡도 | 단계별 실패 코드를 먼저 테스트로 고정한 뒤 구현 |
| 설계 DDL과 템플릿 스키마 충돌 | `studio_idempotency` 제거(D5) 외에는 이름 충돌 없음을 V7 작성 시 재확인 |
---
## 14. 운영 제약
- **커밋 금지** — `AGENTS.md:64` commit 정책 `human-only`. 브랜치 생성과 파일 작성까지
수행하고 stage/commit/push는 사용자가 한다. 이 제약은 `tech-log-backend`의 규약이며
`tech-log-design-package` / `tech-log-frontend`에는 적용되지 않는다.
- 이 설계는 **세 저장소**를 건드린다(§5.6). 계약이 SSOT이므로 순서는
`tech-log-design-package` → `tech-log-frontend` → `tech-log-backend`다.
- `git flow init`은 워킹트리에 미커밋 삭제분(`*-superpowers-package/`,
`scripts/verify-httpclient-docs.py`)이 있어 중단되었다. gitflow 설정은 기록했고
`develop` / `feature/techlog-studio-backend` 브랜치는 수동 생성했다. 미커밋 삭제분은
손대지 않았다.
---
## 15. 참조
- 설계: `tech-log-design-package/docs/specs/{06,07,08,09}`, `decisions/ADR-00{1..5}`
- 계약: `tech-log-design-package/contracts/openapi/studio-v1.yaml`
- 프론트 계약: `tech-log-frontend/src/features/tech-log/contracts/studio/studio-api.openapi.yaml`
- 프론트 실행 경로: `tech-log-frontend/src/adapters/http/http-execution-v3.ts`
- 저장소 권위: `src/config/architecture/modules.json`, `src/settings.gradle`, `AGENTS.md`
-25
View File
@@ -1,25 +0,0 @@
# Fileserver Superpowers Package
## 포함 파일
- `fileserver-platform-design.md` — Fileserver 플랫폼 설계 확정안
- `fileserver-platform-implementation-plan.md` — 33개 TDD 작업으로 분해한 구현 계획
- `VALIDATION.md` — 문서 정적 검증 결과
- `validate_fileserver_docs.py` — 검증 재실행 스크립트
## 저장소 배치 위치
```text
docs/superpowers/specs/2026-08-07-fileserver-platform-design.md
docs/superpowers/plans/2026-08-07-fileserver-platform-implementation-plan.md
```
## 실행 순서
1. 실제 Backend Skeleton 구조와 root package를 대조한다.
2. 설계서의 모듈 경계를 저장소에 반영한다.
3. 구현 계획 Task 1부터 순서대로 실행한다.
4. 각 Task에서 실패 테스트를 확인한 뒤 구현한다.
5. Milestone A~D마다 전체 검증 Gate를 실행한다.
실행에는 `superpowers:subagent-driven-development` 방식이 권장된다.
@@ -1,43 +0,0 @@
# Fileserver Superpowers 문서 검증
**결과:** PASS
## 파일
- `fileserver-platform-design.md` — 1893 lines, 59904 bytes, SHA-256 `ee7b21277b254b9606a9ec6e34118a10fba3abbe818b43cbce9ae832102411e6`
- `fileserver-platform-implementation-plan.md` — 3422 lines, 131608 bytes, SHA-256 `9a443852ab3a7e4a2232c1b443d4cb8d3478a4954d70510173d3e0ac1d3d2125`
## 검증 항목
- [x] **fileserver-platform-design.md exists** — /mnt/data/fileserver-platform-design.md
- [x] **fileserver-platform-implementation-plan.md exists** — /mnt/data/fileserver-platform-implementation-plan.md
- [x] **design title** — True
- [x] **plan header** — required Superpowers header
- [x] **design code fences** — count=94
- [x] **plan code fences** — count=416
- [x] **design placeholder scan** — hits=[]
- [x] **plan placeholder scan** — hits=[]
- [x] **design section coverage** — missing=[]
- [x] **design topic: MVC** — missing=[]
- [x] **design topic: WebFlux** — missing=[]
- [x] **design topic: local/PVC/NFS** — missing=[]
- [x] **design topic: content/metadata separation** — missing=[]
- [x] **design topic: upload** — missing=[]
- [x] **design topic: download** — missing=[]
- [x] **design topic: publish** — missing=[]
- [x] **design topic: security** — missing=[]
- [x] **design topic: resumable** — missing=[]
- [x] **design topic: observability** — missing=[]
- [x] **blocking core port leakage** — hits=[]
- [x] **task count** — count=33
- [x] **task numbering** — numbers=[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33]
- [x] **task block completeness** — {}
- [x] **unique create paths** — {}
- [x] **plan scope coverage** — missing=[]
- [x] **no Redis carryover** — search term=redis
- [x] **no deprecated nginx token design** — token mapper removed
## 검증 범위의 한계
- 현재 Backend Skeleton 저장소가 입력되지 않아 Gradle compilation, integration test, Nginx execution, PVC·NFS certification은 실행하지 않았다.
- 본 검증은 설계·계획 문서의 구조, 내부 일관성, 범위 추적성, 미확정 표식과 중복 경로를 확인한 정적 검증이다.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,174 +0,0 @@
from __future__ import annotations
from collections import Counter
from pathlib import Path
import hashlib
import json
import re
import sys
ROOT = Path('/mnt/data')
DESIGN = ROOT / 'fileserver-platform-design.md'
PLAN = ROOT / 'fileserver-platform-implementation-plan.md'
errors: list[str] = []
checks: list[tuple[str, bool, str]] = []
def add(name: str, ok: bool, detail: str) -> None:
checks.append((name, ok, detail))
if not ok:
errors.append(f'{name}: {detail}')
def sha256(path: Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()
for path in (DESIGN, PLAN):
add(f'{path.name} exists', path.exists(), str(path))
if errors:
print('\n'.join(errors), file=sys.stderr)
raise SystemExit(1)
design = DESIGN.read_text(encoding='utf-8')
plan = PLAN.read_text(encoding='utf-8')
add('design title', design.startswith('# Fileserver Platform 설계서'), True.__str__())
add('plan header', plan.startswith('# Fileserver Platform Implementation Plan\n\n> **For agentic workers:**'), 'required Superpowers header')
add('design code fences', design.count('```') % 2 == 0, f"count={design.count('```')}")
add('plan code fences', plan.count('```') % 2 == 0, f"count={plan.count('```')}")
for label, text in [('design', design), ('plan', plan)]:
forbidden = [r'\bTBD\b', r'\bTODO\b', r'implement later', r'fill in details', r'Similar to Task']
hits = [p for p in forbidden if re.search(p, text, re.I)]
add(f'{label} placeholder scan', not hits, f'hits={hits}')
required_design_sections = [
'## 5. 지원 매트릭스',
'## 6. 전체 아키텍처',
'## 9. 상태 머신과 invariant',
'## 10. Metadata Store 설계',
'## 11. Content Store Port',
'## 12. Local Filesystem Adapter',
'## 14. Publish와 완료 처리',
'## 15. Upload Application 설계',
'## 19. HTTP API',
'## 20. Range와 Conditional Request',
'## 21. Spring MVC Adapter',
'## 22. Spring WebFlux Adapter',
'## 23. Nginx 전송 위임',
'## 24. 재개 가능한 업로드',
'## 27. 보안 정책',
'## 28. 다중 인스턴스와 NFS',
'## 30. 관측성',
'## 33. 테스트 전략',
'## 37. 완료 정의',
]
missing_sections = [s for s in required_design_sections if s not in design]
add('design section coverage', not missing_sections, f'missing={missing_sections}')
source_topics = {
'MVC': ['Spring MVC Adapter', 'MvcTransferExecutorProperties'],
'WebFlux': ['Spring WebFlux Adapter', 'DataBuffer'],
'local/PVC/NFS': ['Kubernetes PVC', 'NFSv4.1', 'Local Filesystem Adapter'],
'content/metadata separation': ['Content Store Port', 'Metadata Store 설계'],
'upload': ['Upload Application 설계', 'multipart', 'application/octet-stream'],
'download': ['Range와 Conditional Request', 'ETag', 'If-Range'],
'publish': ['ATOMIC_MOVE_REQUIRED', 'METADATA_POINTER', 'AmbiguousCompletionException'],
'security': ['traversal', 'symlink', 'READY gate'],
'resumable': ['tus 1.0 Stable', 'draft-12 Experimental'],
'observability': ['Metric', 'Trace', 'Audit'],
}
for topic, needles in source_topics.items():
missing = [n for n in needles if n not in design]
add(f'design topic: {topic}', not missing, f'missing={missing}')
# Core Port snippet must not expose adapter types.
port_match = re.search(r'### 11\.2 Blocking SPI\n(.*?)### 11\.3 Async SPI', design, re.S)
port_text = port_match.group(1) if port_match else ''
forbidden_port_types = ['java.nio.file.Path', 'org.springframework.core.io.Resource', 'DataBuffer', 'Flux<']
port_hits = [x for x in forbidden_port_types if x in port_text]
add('blocking core port leakage', bool(port_match) and not port_hits, f'hits={port_hits}')
# Task structure.
task_matches = list(re.finditer(r'^### Task (\d+):', plan, re.M))
task_numbers = [int(m.group(1)) for m in task_matches]
add('task count', len(task_numbers) == 33, f'count={len(task_numbers)}')
add('task numbering', task_numbers == list(range(1, 34)), f'numbers={task_numbers}')
missing_task_blocks: dict[int, list[str]] = {}
for idx, match in enumerate(task_matches):
end = task_matches[idx + 1].start() if idx + 1 < len(task_matches) else plan.find('\n## 3.', match.start())
segment = plan[match.start():end]
required = [
'**Files:**', '**Interfaces:**', '**Step 1:', '**Step 2:',
'**Step 3:', '**Step 4:', '**Step 5:', 'Expected:', 'git commit'
]
missing = [item for item in required if item not in segment]
if missing:
missing_task_blocks[int(match.group(1))] = missing
add('task block completeness', not missing_task_blocks, json.dumps(missing_task_blocks, ensure_ascii=False))
create_paths = re.findall(r'^- Create: `([^`]+)`', plan, re.M)
duplicates = {path: count for path, count in Counter(create_paths).items() if count > 1}
add('unique create paths', not duplicates, json.dumps(duplicates, ensure_ascii=False))
required_plan_topics = [
'Task 10: Storage capability probe',
'Task 11: Streaming append',
'Task 13: Atomic move와 metadata pointer publish',
'Task 18: HTTP Range',
'Task 21: Spring WebFlux raw·multipart upload',
'Task 23: Nginx `X-Accel-Redirect`',
'Task 26: 다중 인스턴스 writer lease',
'Task 27: tus 1.0 Stable',
'Task 28: HTTPbis resumable upload draft-12 Experimental',
'Task 29: HTTP Problem Detail과 보안 hardening',
'Task 32: Filesystem, HTTP, fault, performance Testkit',
'Task 33: CI matrix',
]
missing_plan_topics = [x for x in required_plan_topics if x not in plan]
add('plan scope coverage', not missing_plan_topics, f'missing={missing_plan_topics}')
add('no Redis carryover', 'redis' not in design.lower() and 'redis' not in plan.lower(), 'search term=redis')
add('no deprecated nginx token design', 'DelegatedPathToken' not in design + plan and 'opaque-token' not in design + plan, 'token mapper removed')
status = 'PASS' if not errors else 'FAIL'
report = ROOT / 'fileserver-superpowers-validation.md'
lines = [
'# Fileserver Superpowers 문서 검증',
'',
f'**결과:** {status}',
'',
'## 파일',
'',
f'- `{DESIGN.name}` — {len(design.splitlines())} lines, {len(design.encode())} bytes, SHA-256 `{sha256(DESIGN)}`',
f'- `{PLAN.name}` — {len(plan.splitlines())} lines, {len(plan.encode())} bytes, SHA-256 `{sha256(PLAN)}`',
'',
'## 검증 항목',
'',
]
for name, ok, detail in checks:
lines.append(f"- [{'x' if ok else ' '}] **{name}** — {detail}")
lines += [
'',
'## 검증 범위의 한계',
'',
'- 현재 Backend Skeleton 저장소가 입력되지 않아 Gradle compilation, integration test, Nginx execution, PVC·NFS certification은 실행하지 않았다.',
'- 본 검증은 설계·계획 문서의 구조, 내부 일관성, 범위 추적성, 미확정 표식과 중복 경로를 확인한 정적 검증이다.',
]
report.write_text('\n'.join(lines) + '\n', encoding='utf-8')
print(json.dumps({
'status': status,
'errors': errors,
'checks': len(checks),
'design_lines': len(design.splitlines()),
'plan_lines': len(plan.splitlines()),
'task_count': len(task_numbers),
'report': str(report),
}, ensure_ascii=False, indent=2))
raise SystemExit(0 if not errors else 1)
-23
View File
@@ -1,23 +0,0 @@
# HTTP Client Superpowers 설계 패키지
이 패키지는 `Java/Spring 외부 HTTP Client 플랫폼 설계 심층 리서치`를 기반으로 작성한 설계서와 구현 계획서다.
## 파일
- `docs/superpowers/specs/2026-08-08-httpclient-platform-design.md`
- `docs/superpowers/plans/2026-08-08-httpclient-platform-implementation-plan.md`
- `VALIDATION.md`
- `validate_httpclient_docs.py`
## 구현 기준
- Java 21
- Gradle Kotlin DSL
- 공통 API는 Spring Framework 6.2 기준
- Spring Framework 7.0 호환성 검증
- Apache HttpClient 5 + RestClient
- JDK HttpClient + RestClient
- Reactor Netty + WebClient
- Jetty HTTP/3 Experimental
실제 Backend Skeleton 저장소가 제공되지 않았으므로 package 경로와 Gradle 구조는 설계서의 명시적 구현 가정이다. 구현 전 저장소의 기존 convention과 root package에 맞춰 경로만 조정하고 공개 계약과 정책 의미론은 유지한다.
@@ -1,31 +0,0 @@
# HTTP Client Superpowers 문서 검증
**검증 결과:** PASS
## 검증 항목
- 설계서 존재 및 최소 구조: PASS
- 구현 계획서 존재 및 최소 구조: PASS
- Task 번호 연속성: PASS
- Task별 Files·Interfaces·Step 1~5·Expected·Commit: PASS
- Markdown code fence 균형: PASS
- Placeholder scan: PASS
- 중복 Create 경로: PASS
- 핵심 설계 범위: PASS
- 핵심 구현 범위: PASS
## 통계
- explicitly forbidden signature documented: ApacheHttpClient nativeApacheClient()
- explicitly forbidden signature documented: HttpClient nativeJdkClient()
- explicitly forbidden signature documented: WebClient.Builder mutableBuilder()
- explicitly forbidden signature documented: RestClient.Builder mutableBuilder()
- design lines=1956, bytes=64493
- plan lines=3635, bytes=158401
- tasks=38, create_paths=306
## 결론
- 설계 결정과 구현 작업의 정적 추적성이 확인됐다.
- 실제 저장소가 제공되지 않았으므로 Gradle compile, integration, fault, security, performance test는 아직 실행되지 않았다.
- 계획의 Java 21, Gradle Kotlin DSL, root package는 명시된 구현 가정이다.
@@ -1,148 +0,0 @@
from pathlib import Path
import re
import sys
import zipfile
base = Path('/mnt/data')
design_path = base / 'httpclient-platform-design.md'
plan_path = base / 'httpclient-platform-implementation-plan.md'
errors = []
notes = []
def read(p):
if not p.exists():
errors.append(f'missing file: {p}')
return ''
return p.read_text(encoding='utf-8')
design = read(design_path)
plan = read(plan_path)
# Basic size and structure
if len(design.splitlines()) < 1200:
errors.append(f'design unexpectedly short: {len(design.splitlines())} lines')
if len(plan.splitlines()) < 2500:
errors.append(f'plan unexpectedly short: {len(plan.splitlines())} lines')
# Task continuity and task internals
matches = list(re.finditer(r'^### Task (\d+): (.+)$', plan, flags=re.M))
nums = [int(m.group(1)) for m in matches]
expected = list(range(1, (max(nums) if nums else 0) + 1))
if nums != expected:
errors.append(f'task numbers not continuous: {nums[:5]}...{nums[-5:] if nums else []}')
for i, m in enumerate(matches):
start = m.start()
end = matches[i+1].start() if i+1 < len(matches) else plan.find('\n## 3. Plan Self-Review Checklist', start)
if end == -1:
end = len(plan)
block = plan[start:end]
n = m.group(1)
for token in ['**Files:**', '**Interfaces:**', '**Step 1:', '**Step 2:', '**Step 3:', '**Step 4:', '**Step 5:']:
if token not in block:
errors.append(f'Task {n} missing {token}')
if 'git commit -m ' not in block:
errors.append(f'Task {n} missing commit command')
if 'Expected:' not in block:
errors.append(f'Task {n} missing expected result')
# Markdown fence balance
for name, text in [('design', design), ('plan', plan)]:
count = len(re.findall(r'^```', text, flags=re.M))
if count % 2:
errors.append(f'{name} has unbalanced code fences: {count}')
# Placeholder scan
patterns = {
'TBD': r'\bTBD\b',
'TODO': r'\bTODO\b',
'implement later': r'implement later',
'fill in': r'fill in',
'similar to task': r'similar to Task',
'placeholder': r'placeholder',
}
for name, text in [('design', design), ('plan', plan)]:
for label, pat in patterns.items():
if re.search(pat, text, flags=re.I):
errors.append(f'{name} contains placeholder pattern: {label}')
# Duplicate create path scan
create_paths = re.findall(r'^- Create: `([^`]+)`', plan, flags=re.M)
dupes = sorted({p for p in create_paths if create_paths.count(p) > 1})
if dupes:
errors.append(f'duplicate Create paths: {dupes}')
# Required design coverage
required_design_terms = [
'H1 Typed Service Client', 'H2 Generic Exchange', 'H3 Dynamic Target',
'ExecutionEvidence', 'BodyReplayability', 'OperationIdempotency',
'Named Client Profile', 'Apache HttpClient 5', 'Reactor Netty',
'Retry Coordinator', 'Circuit Breaker', 'Rate Limiter', 'Bulkhead',
'OAuth2', 'TLS', 'SSRF', 'Streaming', 'SSE', 'HTTP/3',
'Spring Framework 6.2', 'Spring 7', 'RestTemplate'
]
for term in required_design_terms:
if term not in design:
errors.append(f'design missing term: {term}')
required_plan_terms = [
'httpclient-core-api', 'httpclient-transport-apache', 'httpclient-transport-jdk',
'httpclient-transport-reactor-netty', 'httpclient-dynamic-target',
'httpclient-spring-boot-starter', 'HttpAmbiguousExecutionException',
'first response byte', 'DNS/IP Pinning', 'SingleFlightTokenLoader',
'httpClientStableContractTest', 'spring62CompatibilityTest',
'spring70CompatibilityTest'
]
for term in required_plan_terms:
if term not in plan:
errors.append(f'plan missing term: {term}')
# Core API should not deliberately expose native clients in design signatures.
for forbidden_signature in [
'ApacheHttpClient nativeApacheClient()',
'HttpClient nativeJdkClient()',
'WebClient.Builder mutableBuilder()',
'RestClient.Builder mutableBuilder()'
]:
# These appear in an explicit "do not provide" code block. Note rather than fail.
if forbidden_signature in design:
notes.append(f'explicitly forbidden signature documented: {forbidden_signature}')
# Record task count and file counts
notes.append(f'design lines={len(design.splitlines())}, bytes={len(design.encode())}')
notes.append(f'plan lines={len(plan.splitlines())}, bytes={len(plan.encode())}')
notes.append(f'tasks={len(nums)}, create_paths={len(create_paths)}')
report = base / 'httpclient-superpowers-validation.md'
status = 'PASS' if not errors else 'FAIL'
report_text = [
'# HTTP Client Superpowers 문서 검증', '',
f'**검증 결과:** {status}', '',
'## 검증 항목', '',
f'- 설계서 존재 및 최소 구조: {"PASS" if design else "FAIL"}',
f'- 구현 계획서 존재 및 최소 구조: {"PASS" if plan else "FAIL"}',
f'- Task 번호 연속성: {"PASS" if nums == expected else "FAIL"}',
f'- Task별 Files·Interfaces·Step 1~5·Expected·Commit: {"PASS" if not any("Task " in e for e in errors) else "FAIL"}',
f'- Markdown code fence 균형: {"PASS" if not any("code fences" in e for e in errors) else "FAIL"}',
f'- Placeholder scan: {"PASS" if not any("placeholder" in e for e in errors) else "FAIL"}',
f'- 중복 Create 경로: {"PASS" if not dupes else "FAIL"}',
f'- 핵심 설계 범위: {"PASS" if not any("design missing" in e for e in errors) else "FAIL"}',
f'- 핵심 구현 범위: {"PASS" if not any("plan missing" in e for e in errors) else "FAIL"}',
'', '## 통계', ''
]
report_text += [f'- {note}' for note in notes]
if errors:
report_text += ['', '## 오류', ''] + [f'- {e}' for e in errors]
else:
report_text += ['', '## 결론', '',
'- 설계 결정과 구현 작업의 정적 추적성이 확인됐다.',
'- 실제 저장소가 제공되지 않았으므로 Gradle compile, integration, fault, security, performance test는 아직 실행되지 않았다.',
'- 계획의 Java 21, Gradle Kotlin DSL, root package는 명시된 구현 가정이다.']
report.write_text('\n'.join(report_text) + '\n', encoding='utf-8')
print(status)
for note in notes:
print(note)
for e in errors:
print('ERROR:', e)
sys.exit(0 if not errors else 1)
-43
View File
@@ -1,43 +0,0 @@
# Redis Wrapper 및 Typed API 설계 패키지
이 패키지는 Spring 기반 Backend Skeleton에서 Redis 자료구조와 명령을 폭넓게 제공하기 위한 설계서와 구현 계획서다.
## 문서
- `docs/superpowers/specs/2026-08-07-redis-wrapper-typed-api-design.md`
- 범위와 비지원 범위
- Redis 버전·배포 모드
- 모듈과 의존 규칙
- 자료구조별 동기·Reactive Typed API
- R1~R4 명령 노출 정책
- permit·budget·Raw Gateway·Admin Plane
- namespace·직렬화·TTL·timeout·retry·오류·관측성·ACL
- Standalone·Sentinel·Cluster
- 테스트·CI·완료 정의
- `docs/superpowers/plans/2026-08-07-redis-wrapper-typed-api-implementation-plan.md`
- 27개 구현 작업
- 작업별 생성·수정 파일
- 작업 간 입력·출력 인터페이스
- 실패 테스트, 실행 명령, 최소 구현, 통과 검증, 커밋
- Redis 7.2·7.4·8.2·8.10 및 Sentinel·Cluster 테스트 작업
- `VALIDATION.md`
- 문서 구조와 계획 완전성에 대한 정적 검증 결과
## 핵심 결정
1. classic Redis 자료구조는 최대한 Typed API로 제공한다.
2. 고비용·Blocking·다중 키 명령은 permit와 `OperationBudget`을 요구한다.
3. Typed API에 아직 없는 R1·R2 명령은 승인형 Raw Gateway로 제공한다.
4. 운영 명령은 별도 Admin Plane, 파괴적 명령은 SDK 차단으로 분리한다.
5. command catalog는 Redis 공식 metadata에서 생성하고 조직 정책을 오버레이한다.
6. 동기와 Reactive API를 정식 지원하고 같은 내부 async primitive를 공유한다.
7. 일반·Blocking·Transaction·Pub/Sub·Admin 연결을 격리한다.
8. timeout 후 write는 자동 재시도하지 않고 실행 결과 불명을 표현한다.
## 적용 전제
현재 Backend Skeleton 저장소가 첨부되지 않아 경로와 Gradle 구조는 목표 구조로 확정했다. 실제 저장소에 적용할 때 기존 package naming, convention plugin, dependency management가 더 강한 기준을 이미 갖고 있다면 구조적 계약은 유지하면서 해당 규칙에 맞춘다.
입력 Markdown이 참조한 309행 Excel 워크북은 현재 작업 공간에 존재하지 않았다. 따라서 정확한 command matrix는 구현 과정에서 `COMMAND DOCS`, `COMMAND INFO`, `COMMAND GETKEYSANDFLAGS`를 읽어 재생성하고 정책 오버레이를 적용하도록 설계했다.
-38
View File
@@ -1,38 +0,0 @@
# 정적 검증 결과
- **결과:** PASS
- **검사 수:** 29
- **설계서 SHA-256:** `e742ea78f4f40c2f5ed65093a71d2c85c27da52b0761374030a5ca64143aea63`
- **계획서 SHA-256:** `6592a37373a79bc2ccf9434fc3516d8273d9f9369e392d5e4f3360a46d6398c0`
| 검사 | 결과 | 세부 |
|---|---|---|
| 설계서 파일 존재 | PASS | docs/superpowers/specs/2026-08-07-redis-wrapper-typed-api-design.md |
| 설계서 코드 펜스 균형 | PASS | fences=70 |
| 설계서 미확정 표식 없음 | PASS | none |
| 계획서 파일 존재 | PASS | docs/superpowers/plans/2026-08-07-redis-wrapper-typed-api-implementation-plan.md |
| 계획서 코드 펜스 균형 | PASS | fences=276 |
| 계획서 미확정 표식 없음 | PASS | none |
| 계획 작업 수 | PASS | 27 tasks |
| 모든 작업 Step 1 보유 | PASS | 27/27 |
| 모든 작업 Step 2 보유 | PASS | 27/27 |
| 모든 작업 Step 3 보유 | PASS | 27/27 |
| 모든 작업 Step 4 보유 | PASS | 27/27 |
| 모든 작업 Step 5 보유 | PASS | 27/27 |
| 모든 작업 **Files:** 보유 | PASS | 27/27 |
| 모든 작업 **Interfaces:** 보유 | PASS | 27/27 |
| 모든 작업 git commit -m 보유 | PASS | 27/27 |
| Create 경로 중복 없음 | PASS | none |
| Superpowers 계획 헤더 | PASS | required header present |
| 설계 입력 제약 명시 | PASS | missing workbook handled explicitly |
| Typed/Advanced/Raw/Admin 4단계 | PASS | four exposure tiers |
| classic 자료구조 범위 | PASS | all classic groups present |
| 동기·Reactive parity 계획 | PASS | API parity covered |
| permit 위조 검증 | PASS | provenance verification covered |
| 토폴로지 task 선행 등록 | PASS | test tasks available before contracts |
| 환경 파일 생명주기 일관성 | PASS | create once, extend twice |
| 잘못된 Persistent factory 없음 | PASS | constructor usage consistent |
| 공유 async primitive | PASS | sync/reactive executor share invocation |
| Raw 문자열 API 금지 | PASS | guardrail fixed |
| R4 차단 | PASS | blocked in plan and design |
| 완료 정의 존재 | PASS | definition and release task present |
-135
View File
@@ -1,135 +0,0 @@
#!/usr/bin/env python3
"""Fail when the HTTP Client Platform's code and documentation have drifted.
The design (§33 "Documentation") requires the support matrix, configuration reference, security
guide, runbook, and migration guide to match the code. Review cannot hold that line by itself, so
this verifier extracts the names that are part of the public contract -- stable exceptions, metric
names, configuration properties, startup violation codes, and transports -- and fails when one
exists in code but nowhere in the documentation.
It deliberately checks one direction only. A name documented but not yet implemented is a plan; a
name implemented but undocumented is a surprise for whoever is on call.
"""
from __future__ import annotations
import re
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
PLATFORM = REPO_ROOT / "src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient"
BOOTSTRAP = REPO_ROOT / "src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/httpclient"
DOCS_DIR = REPO_ROOT / "docs/httpclient"
ENV_FIELD_MANIFEST = REPO_ROOT / "docs/httpclient/env-fields.yaml"
REQUIRED_DOCS = [
"support-matrix.md",
"configuration-reference.md",
"retry-and-ambiguity.md",
"security.md",
"streaming.md",
"operations.md",
"migration-guide.md",
"release-checklist.md",
"performance-baseline.md",
"repository-adaptation.md",
]
def read_docs() -> str:
return "\n".join(
(DOCS_DIR / name).read_text(encoding="utf-8") for name in REQUIRED_DOCS
)
def stable_exceptions() -> list[str]:
error_dir = PLATFORM / "api/error"
return sorted(
path.stem
for path in error_dir.glob("Http*Exception.java")
if path.stem != "HttpClientException"
)
def metric_names() -> list[str]:
source = (PLATFORM / "observation/HttpClientObservationNames.java").read_text(encoding="utf-8")
return sorted(set(re.findall(r'"(http\.client\.[a-z_.]+)"', source)))
def violation_codes() -> list[str]:
codes: set[str] = set()
for source_file in [
PLATFORM / "profile/ClientProfileValidator.java",
PLATFORM / "security/TlsPolicyValidator.java",
BOOTSTRAP / "HttpClientStartupValidator.java",
]:
source = source_file.read_text(encoding="utf-8")
codes.update(re.findall(r'"([A-Z][A-Z0-9_]{4,})"', source))
return sorted(codes)
def configuration_properties() -> list[str]:
"""Every leaf property under `app.httpclient`, nested and dynamic blocks included.
Read from the environment-field manifest rather than from the record source. The manifest is
derived from `HttpClientPlatformSettings` by `HttpClientEnvironmentKeys` and held to it in both
directions by `HttpClientPlatformEnvManifestTest`, so it cannot drift from the code; parsing the
record here a second time, with a regex, could only agree with it by luck. The previous version
of this function did exactly that and saw eighteen top-level names, which is why a nested pool,
timeout or TLS setting could be added and documented nowhere.
"""
names: set[str] = set()
for line in ENV_FIELD_MANIFEST.read_text(encoding="utf-8").splitlines():
stripped = line.strip()
if not stripped.startswith("- field:"):
continue
path = stripped[len("- field:") :].strip()
leaf = path.split(".")[-1]
# `clients[N]` and `allowed-hosts[M]` are documented by name, not by position.
names.add(re.sub(r"\[[NM]\]$", "", leaf))
return sorted(names)
def transports() -> list[str]:
source = (PLATFORM / "profile/TransportType.java").read_text(encoding="utf-8")
body = source[source.index("public enum TransportType") :]
return sorted(set(re.findall(r"^\s{2}([A-Z][A-Z_]*),?$", body, flags=re.MULTILINE)))
def main() -> int:
missing_docs = [name for name in REQUIRED_DOCS if not (DOCS_DIR / name).is_file()]
if missing_docs:
print("FAIL missing documentation file(s): " + ", ".join(missing_docs))
return 1
documentation = read_docs()
failures: list[str] = []
checks = {
"stable exception": stable_exceptions(),
"metric": metric_names(),
"startup violation code": violation_codes(),
"configuration property": configuration_properties(),
"transport": transports(),
}
for kind, names in checks.items():
for name in names:
if name not in documentation:
failures.append(f"{kind} '{name}' exists in code but is not documented")
if failures:
print(f"FAIL httpclient documentation drift ({len(failures)} finding(s)):")
for failure in failures:
print(" - " + failure)
return 1
total = sum(len(names) for names in checks.values())
print(f"PASS httpclient documentation covers {total} code-derived name(s):")
for kind, names in checks.items():
print(f" {kind}: {len(names)}")
return 0
if __name__ == "__main__":
sys.exit(main())
+1 -1
View File
@@ -115,7 +115,7 @@ PRESENTATION_API_BASE_PATH=/api
APP_SECURITY_AUTH_MODE=jwt APP_SECURITY_AUTH_MODE=jwt
APP_SECURITY_JWT_ISSUER=http://localhost:8081/realms/ca-skeleton APP_SECURITY_JWT_ISSUER=http://localhost:8081/realms/ca-skeleton
APP_SECURITY_JWT_AUDIENCE=ca-skeleton-api APP_SECURITY_JWT_AUDIENCE=ca-skeleton-api
SECURITY_PUBLIC_PATHS=/api/healthcheck SECURITY_PUBLIC_PATHS=/api/healthcheck, /api/v1/public/**
APP_SESSION_COOKIE_NAME=CA_SESSION APP_SESSION_COOKIE_NAME=CA_SESSION
APP_SESSION_COOKIE_SECURE=true APP_SESSION_COOKIE_SECURE=true
APP_SESSION_COOKIE_HTTP_ONLY=true APP_SESSION_COOKIE_HTTP_ONLY=true
+630
View File
@@ -1,3 +1,61 @@
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'))
// 계약의 discriminator union을 Java interface로 파생한 소스(prepareStudioCodegenSpec).
// 생성 DTO와 같은 sourceSet이어야 한다 — 생성된 하위 타입이 `implements <Union>` 하므로
// 이 인터페이스가 compileGeneratedOpenapiJava의 컴파일 클래스패스에 있어야 한다.
// main sourceSet에 두면 main -> generatedOpenapi 단방향 배선(아래 참고) 때문에 보이지 않는다.
java.srcDir(layout.buildDirectory.dir('generated/openapi-unions/src/main/java'))
// public-v1 도 같은 방식으로 model 만 생성한다. 별도 sourceSet 을 만들지 않는 이유는
// 두 계약의 생성물이 같은 성질(생성 코드, 품질 게이트 제외 대상, jar/test 클래스패스에
// 얹어야 함)을 갖기 때문이다 — sourceSet 을 늘리면 그 배선을 한 벌 더 복제하게 된다.
java.srcDir(layout.buildDirectory.dir('generated/openapi-public/src/main/java'))
java.srcDir(layout.buildDirectory.dir('generated/openapi-public-unions/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. // HTTP / web adapters. Depends on application and shared operational contracts.
dependencies { dependencies {
implementation project(':application-core') implementation project(':application-core')
@@ -16,6 +74,15 @@ dependencies {
// never a hand-maintained stale schema). The release-blocking drift gate is // never a hand-maintained stale schema). The release-blocking drift gate is
// owned by feature-contract-verification-test-suite (planned). // owned by feature-contract-verification-test-suite (planned).
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-api:3.0.0' implementation 'org.springdoc:springdoc-openapi-starter-webmvc-api:3.0.0'
// Studio Preview / Public Snapshot 의 CaseRenderBlock 을 만드는 Markdown 파서.
// 설계 05장 §16 이 "특정 라이브러리를 도메인 계약으로 만들지 않는다"고 정하므로 이 의존은
// techlog/studio/render 패키지 안에서만 쓰고 바깥에는 계약 DTO 만 내보낸다.
// 프론트가 remark 계열로 같은 문법을 다루므로(ADR-005 동등성) CommonMark + GFM 확장이라는
// 같은 기준을 쓴다 — 자체 파서를 쓰면 두 화면의 해석이 갈라진다.
implementation 'org.commonmark:commonmark:0.21.0'
implementation 'org.commonmark:commonmark-ext-gfm-tables:0.21.0'
implementation 'org.commonmark:commonmark-ext-gfm-strikethrough:0.21.0'
implementation 'org.commonmark:commonmark-ext-autolink:0.21.0'
// Fileserver reactive transport. Only the WebFlux framework and Reactor core are declared — // Fileserver reactive transport. Only the WebFlux framework and Reactor core are declared —
// deliberately not spring-boot-starter-webflux, which would put a second embedded server // deliberately not spring-boot-starter-webflux, which would put a second embedded server
// (reactor-netty) on the runtime classpath. DispatcherServlet stays present, so Spring Boot's // (reactor-netty) on the runtime classpath. DispatcherServlet stays present, so Spring Boot's
@@ -72,3 +139,566 @@ tasks.register('webSecurityBoundaryTest', Test) {
tasks.named('check') { tasks.named('check') {
dependsOn tasks.named('webSecurityBoundaryTest') dependsOn tasks.named('webSecurityBoundaryTest')
} }
// ---------------------------------------------------------------------------
// 계약 union -> 생성 코드 배선 (슬라이스 2 선행 / spec §5.2 보강).
//
// 문제: useOneOfInterfaces=false 는 컴파일은 통과시키지만 discriminator union이 Jackson
// 양방향 모두 계약을 위반한다(역직렬화 InvalidTypeIdException, 직렬화는 kind 대신 클래스
// simple name). useOneOfInterfaces=true 는 SpringCodegen이 union interface의 discriminator
// getter를 String으로 고정해 컴파일이 깨진다 — 계약 쪽으로 우회 불가(Plan 01 task-4-report).
//
// 해법: 계약 원본은 그대로 두고, 생성 직전에 사본을 파생시킨다.
// (1) oneOf + discriminator 를 가진 스키마 S 의 각 하위 타입에 `x-implements: [S]` 를 주입한다
// -> 생성된 하위 타입이 `implements S` 로 나온다(7.18.0에서 실측 확인).
// (2) S 자체의 생성은 ignore 시키고, 같은 package 에 Jackson 다형성 애너테이션을 단
// Java interface 로 이 태스크가 직접 써 넣는다.
// (3) 모든 분기가 `type: string` 인 이름 없는 oneOf 는 `type: string` 으로 접는다.
// 접지 않으면 생성기가 필드가 하나도 없는 껍데기 클래스를 만든다
// (WorkingCopyInputBase.slug -> WorkingCopyInputBaseSlug: 실측 확인). 그 결과
// slug 가 문자열이 아니라 `{}` 로 직렬화되어 계약을 위반한다.
//
// 파생 규칙은 계약의 `oneOf`/`discriminator.mapping` 에서 전부 읽어낸다 — union 목록을
// 손으로 관리하지 않으므로 계약에 union이 추가돼도 따라온다.
//
// snakeyaml 은 org.openapi.generator 플러그인이 buildscript classpath 로 이미 가져온다
// (swagger-parser 경유, 2.4 — 실측 확인).
// ---------------------------------------------------------------------------
ext.studioModelPackage = 'dev.caskeleton.adapter.inbound.web.techlog.studio.api.model'
ext.studioCodegenSpecFile = layout.buildDirectory.file('openapi/studio-v1-codegen.yaml')
ext.studioCodegenIgnoreFile = layout.buildDirectory.file('openapi/.openapi-generator-ignore')
ext.studioUnionSrcDir = layout.buildDirectory.dir('generated/openapi-unions/src/main/java')
// 이 파생은 계약 두 벌(studio-v1, public-v1)에 똑같이 적용된다. 두 벌을 각자 복사해 두면
// 한쪽만 고쳐지는 날이 오므로 클로저 하나로 두고 태스크가 인자만 바꿔 호출한다.
ext.prepareTechLogCodegenSpec = { String label, File specSource, File specTarget,
File ignoreTarget, File unionDir, String modelPackage ->
def doc = new org.yaml.snakeyaml.Yaml().load(specSource.getText('UTF-8'))
def schemas = doc.components.schemas
// (3) 전부 string 인 이름 없는 oneOf 접기
int[] collapsed = [0]
def collapseStringOneOf
collapseStringOneOf = { Object node ->
if (node instanceof Map) {
for (Object key : new ArrayList(node.keySet())) {
def value = node.get(key)
if (value instanceof Map && value.get('oneOf') instanceof List
&& !value.containsKey('discriminator')) {
def branches = value.get('oneOf')
if (!branches.isEmpty()
&& branches.every { it instanceof Map && it.get('type') == 'string' }) {
def folded = new LinkedHashMap()
folded.put('type', 'string')
if (value.containsKey('description')) {
folded.put('description', value.get('description'))
}
node.put(key, folded)
collapsed[0]++
continue
}
}
collapseStringOneOf(value)
}
} else if (node instanceof List) {
node.each { collapseStringOneOf(it) }
}
}
collapseStringOneOf(doc)
// boolean 프로퍼티의 `const` 를 코드젠 사본에서만 걷어낸다.
//
// 봉투의 success 는 계약상 `{type: boolean, const: true}` 다. 생성기는 이 문서를 검증
// 경로 없이 읽으면 그 const 를 단일값 enum 으로 취급해 `enum SuccessEnum { TRUE("true") }`
// 를 만드는데, 그 enum 의 필드 타입은 Boolean 이고 생성자에는 String 을 넘겨 컴파일이
// 깨진다(실측). 검증 경로를 타는 studio 쪽에서는 같은 계약이 평범한 Boolean 으로 나온다 —
// 즉 계약이 아니라 생성기의 경로 차이가 원인이다.
//
// 값이 하나로 고정된다는 사실은 소비자에게 의미가 있으므로 정본 계약에는 그대로 두고,
// 여기서만 뗀다. 서버가 이 값을 잘못 넣을 위험은 없다 — 봉투는 EnvelopeBodyAdvice 가
// 만들고 컨트롤러가 손대지 않는다.
int[] consts = [0]
def dropBooleanConst
dropBooleanConst = { Object node ->
if (node instanceof Map) {
if (node.get('type') == 'boolean' && node.containsKey('const')) {
node.remove('const')
consts[0]++
}
new ArrayList(node.values()).each { dropBooleanConst(it) }
} else if (node instanceof List) {
node.each { dropBooleanConst(it) }
}
}
dropBooleanConst(doc)
// (4) `oneOf: [X, {type: null}]` 는 OpenAPI 3.1 이 nullable 을 적는 방식이다. 그대로 두면
// 생성기가 분기들을 병합한 <부모><필드> 래퍼 클래스를 새로 만들고(예: DocumentSummary.project 가
// DisplayTarget 이 아니라 PublicRenderModelBaseProject 가 된다), 같은 모양의 타입이 여러 벌
// 생겨 매핑 코드가 그 사이를 오가게 된다. Java 참조는 어차피 nullable 이라 손실이 없으므로
// null 분기를 지우고 남은 하나로 접는다.
int[] nullable = [0]
def collapseNullableOneOf
collapseNullableOneOf = { Object node ->
if (node instanceof Map) {
for (Object key : new ArrayList(node.keySet())) {
def value = node.get(key)
if (value instanceof Map && value.get('oneOf') instanceof List
&& !value.containsKey('discriminator')) {
def branches = value.get('oneOf')
def nulls = branches.findAll { it instanceof Map && it.get('type') == 'null' }
def rest = branches - nulls
if (!nulls.isEmpty() && rest.size() == 1 && rest[0] instanceof Map) {
def folded = new LinkedHashMap(rest[0])
if (value.containsKey('description') && !folded.containsKey('description')) {
folded.put('description', value.get('description'))
}
node.put(key, folded)
nullable[0]++
continue
}
}
collapseNullableOneOf(value)
}
} else if (node instanceof List) {
node.each { collapseNullableOneOf(it) }
}
}
collapseNullableOneOf(doc)
// (4b) `type: [X, "null"]` 인 필드는 required 목록에서 뺀다.
//
// 계약이 이 필드들을 required 로 두는 뜻은 "키가 있어야 한다"이지 "값이 있어야 한다"가
// 아니다 — WorkingCopyInputBase 의 주석이 그렇게 못박고 있다("불완전한 초안도 저장할 수
// 있어야 하므로 필드는 required 이되 빈 값과 null 을 허용한다"). 그런데 생성기는 required
// 를 그대로 @NotNull 로 옮긴다. 그래서 topicId/projectId/lastVerifiedOn/verifiedOn/
// decidedOn/decisionStatus/questionStatus 가 전부 non-null 강제가 되고, 초안 저장이
// 400 NOT_NULL 로 거부됐다(실측: {"projectId": null} → NOT_NULL "Required value is missing").
//
// 원본 계약은 건드리지 않는다 — 프론트엔드가 같은 파일을 읽고, 그쪽 해석은 옳다. 코드젠
// 사본에서만 required 를 벗겨 @NotNull 이 붙지 않게 한다. 값 제약(형식·길이·enum)은
// 그대로 남는다.
int[] relaxed = [0]
def relaxNullableRequired
relaxNullableRequired = { Object node ->
if (node instanceof Map) {
def props = node.get('properties')
def required = node.get('required')
if (props instanceof Map && required instanceof List) {
def drop = []
props.each { Object name, Object schema ->
if (!(schema instanceof Map)) return
def type = schema.get('type')
if (type instanceof List && type.contains('null') && required.contains(name)) {
drop << name
}
}
if (!drop.isEmpty()) {
required.removeAll(drop)
relaxed[0] += drop.size()
if (required.isEmpty()) node.remove('required')
}
}
new ArrayList(node.values()).each { relaxNullableRequired(it) }
} else if (node instanceof List) {
node.each { relaxNullableRequired(it) }
}
}
relaxNullableRequired(doc)
logger.lifecycle("${label}: nullable required 해제 ${relaxed[0]}건")
// (1) x-implements 주입 + union 목록 수집
def unions = [:]
schemas.each { String name, Object schema ->
if (!(schema instanceof Map)) return
def disc = schema.get('discriminator')
if (!(schema.get('oneOf') instanceof List) || !(disc instanceof Map)) return
def property = disc.get('propertyName')
def mapping = disc.get('mapping')
if (!property || !(mapping instanceof Map) || mapping.isEmpty()) {
throw new GradleException(
"union ${name} 에 discriminator.propertyName 과 mapping 이 모두 있어야 한다 " +
"— 없으면 Jackson @JsonSubTypes 의 type id 를 계약에서 유도할 수 없다.")
}
def variants = new LinkedHashMap()
mapping.each { String typeId, String ref ->
def variant = ref.tokenize('/').last()
if (!schemas.containsKey(variant)) {
throw new GradleException("union ${name} 의 mapping 이 없는 스키마 ${variant} 를 가리킨다.")
}
variants.put(typeId, variant)
}
schema.get('oneOf').each { branch ->
if (branch instanceof Map && branch.get('$ref')) {
def variant = branch.get('$ref').tokenize('/').last()
if (!variants.containsValue(variant)) {
throw new GradleException(
"union ${name} 의 oneOf 분기 ${variant} 가 discriminator.mapping 에 없다 " +
"— type id 를 알 수 없어 Jackson 배선을 파생시킬 수 없다.")
}
}
}
variants.values().toSet().each { String variant ->
def target = schemas.get(variant)
def impls = target.get('x-implements')
if (impls == null) {
target.put('x-implements', [name])
} else if (!impls.contains(name)) {
target.put('x-implements', impls + [name])
}
}
unions.put(name, [property: property, variants: variants])
}
// 이 가드의 목적은 "union 이 있어야 한다"가 아니라 "계약에 있는 union 을 하나도 빠뜨리지
// 않았다"이다. public-v1 처럼 union 이 애초에 없는 계약도 있으므로 개수를 계약에서 세어
// 대조한다. 원래 studio 전용으로 "0개면 실패"로 썼다가 public-v1 에서 걸렸다.
int declaredUnions = schemas.count { String name, Object schema ->
schema instanceof Map && schema.get('oneOf') instanceof List &&
schema.get('discriminator') instanceof Map
}
if (unions.size() != declaredUnions) {
throw new GradleException(
"계약의 discriminator union ${declaredUnions}개 중 ${unions.size()}개만 파생했다 — " +
"파생 규칙이 계약을 따라가지 못한다.")
}
// 파생 계약 쓰기
//
// deep copy 가 반드시 선행한다. 위 변환들이 같은 Map/List 인스턴스를 여러 위치에
// 재사용하면 snakeyaml 이 그 지점을 YAML anchor/alias(&id001 / *id001)로 덤프한다.
// swagger-parser 는 alias 노드를 해석하지 못해 그 스키마를
// "is not of type `object`" 로 거부하고, validateSpec 을 끄면 generator 가 해당
// property 를 **조용히 누락한 채** 모델을 만든다(publishedAt, matchedFields 등이
// 실제로 사라졌다). 노드 identity 를 전부 끊어 alias 자체를 원천 차단한다.
def deepCopy
deepCopy = { Object node ->
if (node instanceof Map) {
def copy = new LinkedHashMap<String, Object>()
node.each { k, v -> copy.put(k, deepCopy(v)) }
return copy
}
if (node instanceof List) {
return node.collect { deepCopy(it) }
}
return node
}
def dumperOptions = new org.yaml.snakeyaml.DumperOptions()
dumperOptions.defaultFlowStyle = org.yaml.snakeyaml.DumperOptions.FlowStyle.BLOCK
dumperOptions.width = 8192
def specFile = specTarget
specFile.parentFile.mkdirs()
def rendered = new org.yaml.snakeyaml.Yaml(dumperOptions).dump(deepCopy(doc))
// fail-closed: alias 가 하나라도 남으면 생성물이 조용히 불완전해진다.
def aliasLines = rendered.readLines().findAll { it =~ /(?:&|\*)id\d{3}\b/ }
if (!aliasLines.isEmpty()) {
throw new GradleException(
"${label}: 파생 계약에 YAML alias 가 남았다 — swagger-parser 가 해당 스키마를 " +
"거부하고 property 가 조용히 누락된다. 위반 ${aliasLines.size()}줄, 예: " +
aliasLines.take(3).join(' | '))
}
specFile.setText(rendered, 'UTF-8')
// union 클래스 생성 억제
def ignoreFile = ignoreTarget
ignoreFile.setText(
(["# ${label} 가 생성한다 — 손으로 고치지 않는다.",
'# 이 파일들은 같은 package 의 Java interface 로 대체된다.']
+ unions.keySet().collect { "**/${it}.java" }).join('\n') + '\n',
'UTF-8')
// union interface 쓰기
def packageDir = new File(unionDir, modelPackage.replace('.', '/'))
project.delete(unionDir)
packageDir.mkdirs()
unions.each { String name, Object spec ->
def subtypes = spec.variants.collect { String typeId, String variant ->
" @JsonSubTypes.Type(value = ${variant}.class, name = \"${typeId}\")"
}.join(',\n')
def source = """package ${modelPackage};
import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
/**
* {@code ${name}} — 계약의 discriminator union. ${label} 가 계약의
* {@code oneOf} + {@code discriminator.mapping} 에서 파생한다. 손으로 고치지 않는다.
*
* <p>{@code As.EXISTING_PROPERTY} 다 — 하위 타입이 {@code ${spec.property}} 를 자기 필드로
* 이미 직렬화하므로 Jackson 이 판별 필드를 한 번 더 쓰면 키가 중복된다.
*/
@JsonTypeInfo(
use = JsonTypeInfo.Id.NAME,
include = JsonTypeInfo.As.EXISTING_PROPERTY,
property = "${spec.property}",
visible = true)
@JsonSubTypes({
${subtypes}
})
public interface ${name} {}
"""
new File(packageDir, "${name}.java").setText(source, 'UTF-8')
}
logger.lifecycle(
"${label}: union ${unions.size()}개 파생(${unions.keySet().join(', ')}), boolean const ${consts[0]}건 제거, " +
"string oneOf ${collapsed[0]}건 · nullable oneOf ${nullable[0]}건 접음")
}
ext.studioModelPackage = 'dev.caskeleton.adapter.inbound.web.techlog.studio.api.model'
ext.studioCodegenSpecFile = layout.buildDirectory.file('openapi/studio-v1-codegen.yaml')
ext.studioCodegenIgnoreFile = layout.buildDirectory.file('openapi/.openapi-generator-ignore')
ext.studioUnionSrcDir = layout.buildDirectory.dir('generated/openapi-unions/src/main/java')
// `public` 은 Java 예약어라 패키지 조각으로 쓸 수 없다 — publicapi 로 둔다.
ext.publicModelPackage = 'dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model'
ext.publicCodegenSpecFile = layout.buildDirectory.file('openapi/public-v1-codegen.yaml')
ext.publicCodegenIgnoreFile = layout.buildDirectory.file('openapi/.openapi-generator-ignore-public')
ext.publicUnionSrcDir = layout.buildDirectory.dir('generated/openapi-public-unions/src/main/java')
tasks.register('prepareStudioCodegenSpec') {
description = 'studio-v1 계약에서 생성기 입력을 파생시킨다.'
def specSource = file("${rootDir}/config/openapi/studio-v1.yaml")
def specOut = studioCodegenSpecFile
def ignoreOut = studioCodegenIgnoreFile
def unionDir = studioUnionSrcDir
def modelPackage = studioModelPackage
def prepare = prepareTechLogCodegenSpec
inputs.file(specSource)
outputs.file(specOut)
outputs.file(ignoreOut)
outputs.dir(unionDir)
doLast {
prepare('prepareStudioCodegenSpec', specSource, specOut.get().asFile,
ignoreOut.get().asFile, unionDir.get().asFile, modelPackage)
}
}
tasks.register('preparePublicCodegenSpec') {
description = 'public-v1 계약에서 생성기 입력을 파생시킨다.'
def specSource = file("${rootDir}/config/openapi/public-v1.yaml")
def specOut = publicCodegenSpecFile
def ignoreOut = publicCodegenIgnoreFile
def unionDir = publicUnionSrcDir
def modelPackage = publicModelPackage
def prepare = prepareTechLogCodegenSpec
inputs.file(specSource)
outputs.file(specOut)
outputs.file(ignoreOut)
outputs.dir(unionDir)
doLast {
prepare('preparePublicCodegenSpec', specSource, specOut.get().asFile,
ignoreOut.get().asFile, unionDir.get().asFile, modelPackage)
}
}
// public-v1 생성. openApiGenerate 확장은 계약 하나만 다루므로 두 번째 계약은 GenerateTask 를
// 직접 등록한다. 설정은 studio 쪽과 같은 근거를 따른다(model 만 생성, oneOf interface 미사용,
// openApiNullable=false) — 그 근거는 위 openApiGenerate 블록의 주석에 있다.
tasks.register('openApiGeneratePublic',
org.openapitools.generator.gradle.plugin.tasks.GenerateTask) {
dependsOn tasks.named('preparePublicCodegenSpec')
generatorName = 'spring'
inputSpec = publicCodegenSpecFile.get().asFile.path
ignoreFileOverride = publicCodegenIgnoreFile.get().asFile.path
outputDir = layout.buildDirectory.dir('generated/openapi-public').get().asFile.path
modelPackage = publicModelPackage
// 검증을 켠 채로 둔다. 한때 swagger-parser 가 이 문서의 스키마 15개를
// "is not of type `object`" 로 거절했는데, 원인은 계약이 아니라 파생 단계였다.
// preparePublicCodegenSpec 의 변환이 같은 Map 인스턴스를 여러 property 에 재사용해
// snakeyaml 이 YAML alias(*id001)로 덤프했고, swagger-parser 가 alias 노드를
// 해석하지 못해 그 스키마 전체를 거절했다. validateSpec 을 끄면 generator 는 문서를
// 받아들이되 alias 였던 property 를 **조용히 누락**한다 — publishedAt, updatedAt,
// matchedFields, changeTypes 가 실제로 모델에서 사라졌다. 파생 단계에서 deep copy 로
// alias 를 원천 차단했으므로 검증을 다시 켠다.
validateSpec = true
globalProperties.set(['models': ''])
generateModelTests = false
generateModelDocumentation = false
configOptions = [
useSpringBoot3: 'true',
useJakartaEe: 'true',
openApiNullable: 'false',
useOneOfInterfaces: 'false',
]
// 생성기는 outputDir 를 비우지 않는다 — 계약에서 사라진 스키마의 .java 가 남아 드리프트를
// 가린다(studio 쪽에서 실제로 겪었다).
doFirst { project.delete(layout.buildDirectory.dir('generated/openapi-public')) }
}
// 생성기가 스키마나 property 를 조용히 빠뜨려도 컴파일은 그대로 통과한다(그 타입을 아직
// 아무도 안 쓰니까) — 나중에 컨트롤러를 쓸 때서야 드러난다. 실제로 파생 계약의 YAML alias
// 때문에 publishedAt / updatedAt / matchedFields / changeTypes 가 모델에서 사라진 채로
// 빌드가 성공한 적이 있고, 그때 이 게이트가 schema 이름만 봐서 놓쳤다. 그래서 property 까지
// 대조한다.
tasks.register('verifyPublicGeneratedModels') {
group = 'verification'
description = 'public-v1 계약의 schema 와 property 가 전부 모델로 생성됐는지 대조한다.'
dependsOn tasks.named('openApiGeneratePublic')
def specFile = publicCodegenSpecFile
def modelDirProvider = layout.buildDirectory.dir('generated/openapi-public/src/main/java')
def modelPackage = publicModelPackage
doLast {
def doc = new org.yaml.snakeyaml.Yaml().load(specFile.get().asFile.getText('UTF-8'))
Set<String> declared = new TreeSet<>(((Map) doc.components.schemas).keySet())
File packageDir = new File(modelDirProvider.get().asFile, modelPackage.replace('.', '/'))
Set<String> generated = new TreeSet<>()
if (packageDir.isDirectory()) {
packageDir.eachFile { File f ->
if (f.name.endsWith('.java')) generated << f.name[0..-6]
}
}
// 생성기는 이름 없는 중첩 object 에 <부모><필드> 형태의 모델을 더 만든다. 그건 초과분이라
// 문제가 아니고, 부족분만 문제다.
Set<String> missing = new TreeSet<>(declared - generated)
if (!missing.isEmpty()) {
throw new GradleException(
"public-v1 계약의 schema ${missing.size()}개가 모델로 생성되지 않았다: ${missing}")
}
// property 대조. 생성기는 @JsonProperty 에 계약의 원래 이름을 그대로 쓰므로
// 그 문자열 리터럴이 파일에 있는지로 판정한다.
int checkedProps = 0
List<String> lost = []
((Map) doc.components.schemas).each { String name, Object schema ->
if (!(schema instanceof Map)) return
Object props = ((Map) schema).get('properties')
if (!(props instanceof Map)) return
File modelFile = new File(packageDir, "${name}.java")
if (!modelFile.isFile()) return
String body = modelFile.getText('UTF-8')
((Map) props).keySet().each { Object prop ->
checkedProps++
if (!body.contains("\"${prop}\"")) lost << "${name}.${prop}"
}
}
if (!lost.isEmpty()) {
throw new GradleException(
"public-v1 계약의 property ${lost.size()}개가 모델에서 빠졌다 " +
"(생성기가 조용히 누락한다): ${lost.take(20)}")
}
logger.lifecycle(
"verifyPublicGeneratedModels: 계약 schema ${declared.size()}개 · " +
"property ${checkedProps}개 전부 생성 (생성 모델 ${generated.size()}개)")
}
}
tasks.named('check') {
dependsOn tasks.named('verifyPublicGeneratedModels')
}
tasks.named('compileGeneratedOpenapiJava') {
dependsOn tasks.named('openApiGeneratePublic')
}
// openApiGenerate 는 확장(extension) 이름이자 태스크 이름이다 — 위 블록은 확장 설정이라
// dependsOn 을 받지 못한다. 태스크 쪽에 건다.
tasks.named('openApiGenerate') {
dependsOn tasks.named('prepareStudioCodegenSpec')
// openapi-generator 는 outputDir 를 비우지 않는다 — 계약에서 스키마가 사라져도 직전
// 실행의 .java 가 그대로 남아 컴파일에 성공하고, 그래서 드리프트가 아니라 정상으로 보인다
// (이 배선을 넣는 과정에서 실제로 겪음: 삭제됐어야 할 union 6개가 남아 있었다).
doFirst { project.delete(layout.buildDirectory.dir('generated/openapi')) }
}
// ---------------------------------------------------------------------------
// 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'
// 원본이 아니라 prepareStudioCodegenSpec 가 파생한 사본을 먹인다 — 이유는 그 태스크의 주석 참고.
inputSpec = studioCodegenSpecFile.get().asFile.path
ignoreFileOverride = studioCodegenIgnoreFile.get().asFile.path
outputDir = layout.buildDirectory.dir('generated/openapi').get().asFile.path
modelPackage = studioModelPackage
globalProperties.set(['models': ''])
generateModelTests = false
generateModelDocumentation = false
configOptions = [
useSpringBoot3: 'true',
useJakartaEe: 'true',
// false 다. openApiNullable=true 는 nullable 필드를 JsonNullable<T> 로 만드는데,
// 그 타입을 읽는 모듈(org.openapitools:jackson-databind-nullable)은 Jackson 2 용이고
// 이 앱의 HTTP 변환기는 Jackson 3(tools.jackson, Spring Boot 4 기본)다 — 모듈이
// 등록될 수 없어 JsonNullable 이 그냥 POJO 로 직렬화되고 역직렬화는 깨진다.
// 계약이 이 필드들을 required 로 두므로(예: questionStatus, decisionStatus)
// "없음"과 "null" 을 구분할 필요도 없다. 평범한 nullable 필드로 생성한다.
openApiNullable: 'false',
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/**') } }
+108 -103
View File
@@ -1,65 +1,65 @@
# This is a Gradle generated file for dependency locking. # This is a Gradle generated file for dependency locking.
# Manual edits can break the build and are not advised. # Manual edits can break the build and are not advised.
# This file is expected to be part of source control. # This file is expected to be part of source control.
biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=compileClasspath,testCompileClasspath biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=compileClasspath,generatedOpenapiCompileClasspath,testCompileClasspath
ch.qos.logback:logback-classic:1.5.21=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath ch.qos.logback:logback-classic:1.5.21=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
ch.qos.logback:logback-core:1.5.21=compileClasspath,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,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,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,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,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,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,runtimeClasspath,testCompileClasspath,testRuntimeClasspath com.fasterxml.jackson:jackson-bom:2.20.1=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
com.fasterxml:classmate:1.7.1=compileClasspath,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,testAnnotationProcessor com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,generatedOpenapiAnnotationProcessor,testAnnotationProcessor
com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,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.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.spotbugs:spotbugs:4.10.2=spotbugs
com.github.stephenc.jcip:jcip-annotations:1.0-1=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.service:auto-service-annotations:1.0.1=annotationProcessor,generatedOpenapiAnnotationProcessor,testAnnotationProcessor
com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,testAnnotationProcessor com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,generatedOpenapiAnnotationProcessor,testAnnotationProcessor
com.google.auto:auto-common:1.2.2=annotationProcessor,testAnnotationProcessor com.google.auto:auto-common:1.2.2=annotationProcessor,generatedOpenapiAnnotationProcessor,testAnnotationProcessor
com.google.code.findbugs:jsr305:3.0.2=checkstyle,compileClasspath,spotbugs,testCompileClasspath com.google.code.findbugs:jsr305:3.0.2=checkstyle,compileClasspath,generatedOpenapiCompileClasspath,spotbugs,testCompileClasspath
com.google.code.gson:gson:2.13.2=spotbugs 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_annotation:2.49.0=annotationProcessor,generatedOpenapiAnnotationProcessor,testAnnotationProcessor
com.google.errorprone:error_prone_annotations:2.38.0=compileClasspath,testCompileClasspath 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.41.0=spotbugs
com.google.errorprone:error_prone_annotations:2.47.0=checkstyle 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_annotations:2.49.0=annotationProcessor,generatedOpenapiAnnotationProcessor,testAnnotationProcessor
com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,testAnnotationProcessor com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,generatedOpenapiAnnotationProcessor,testAnnotationProcessor
com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,testAnnotationProcessor com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,generatedOpenapiAnnotationProcessor,testAnnotationProcessor
com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,testAnnotationProcessor com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,generatedOpenapiAnnotationProcessor,testAnnotationProcessor
com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,testAnnotationProcessor com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,generatedOpenapiAnnotationProcessor,testAnnotationProcessor
com.google.guava:guava:33.5.0-jre=annotationProcessor,testAnnotationProcessor com.google.guava:guava:33.5.0-jre=annotationProcessor,generatedOpenapiAnnotationProcessor,testAnnotationProcessor
com.google.guava:guava:33.6.0-jre=checkstyle 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.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,generatedOpenapiAnnotationProcessor,testAnnotationProcessor
com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,testAnnotationProcessor com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,generatedOpenapiAnnotationProcessor,testAnnotationProcessor
com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,testAnnotationProcessor com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,generatedOpenapiAnnotationProcessor,testAnnotationProcessor
com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins
com.jayway.jsonpath:json-path:2.9.0=testCompileClasspath,testRuntimeClasspath 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.puppycrawl.tools:checkstyle:13.5.0=checkstyle
com.vaadin.external.google:android-json:0.0.20131108.vaadin1=testCompileClasspath,testRuntimeClasspath com.vaadin.external.google:android-json:0.0.20131108.vaadin1=testCompileClasspath,testRuntimeClasspath
commons-beanutils:commons-beanutils:1.11.0=checkstyle commons-beanutils:commons-beanutils:1.11.0=checkstyle
commons-collections:commons-collections:3.2.2=checkstyle commons-collections:commons-collections:3.2.2=checkstyle
commons-io:commons-io:2.21.0=spotbugs 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 info.picocli:picocli:4.7.7=checkstyle
io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,generatedOpenapiAnnotationProcessor,testAnnotationProcessor
io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,generatedOpenapiAnnotationProcessor,testAnnotationProcessor
io.micrometer:micrometer-commons:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath io.micrometer:micrometer-commons:1.16.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.micrometer:micrometer-observation:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath io.micrometer:micrometer-observation:1.16.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.projectreactor:reactor-core:3.8.0=compileClasspath,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.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-annotations-jakarta:2.2.38=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.swagger.core.v3:swagger-core-jakarta:2.2.38=compileClasspath,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,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,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,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,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,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,testAnnotationProcessor javax.inject:javax.inject:1=annotationProcessor,generatedOpenapiAnnotationProcessor,testAnnotationProcessor
jaxen:jaxen:2.0.0=spotbugs jaxen:jaxen:2.0.0=spotbugs
net.bytebuddy:byte-buddy-agent:1.17.8=testCompileClasspath,testRuntimeClasspath net.bytebuddy:byte-buddy-agent:1.17.8=testCompileClasspath,testRuntimeClasspath
net.bytebuddy:byte-buddy: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 net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs
org.antlr:antlr4-runtime:4.13.2=checkstyle org.antlr:antlr4-runtime:4.13.2=checkstyle
org.apache.bcel:bcel:6.12.0=spotbugs 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.15.0=spotbugs
org.apache.commons:commons-text:1.3=checkstyle org.apache.commons:commons-text:1.3=checkstyle
org.apache.httpcomponents:httpclient:4.5.13=checkstyle org.apache.httpcomponents:httpclient:4.5.13=checkstyle
org.apache.httpcomponents:httpcore:4.4.16=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-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-core:1.12.0=checkstyle
org.apache.maven.doxia:doxia-logging-api: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-module-xdoc:1.12.0=checkstyle
org.apache.maven.doxia:doxia-sink-api: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-core:11.0.14=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.apache.tomcat.embed:tomcat-embed-el:11.0.14=compileClasspath,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,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.apache.xbean:xbean-reflect:3.7=checkstyle
org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath
org.assertj:assertj-core:3.27.6=testCompileClasspath,testRuntimeClasspath org.assertj:assertj-core:3.27.6=testCompileClasspath,testRuntimeClasspath
@@ -91,12 +91,16 @@ org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle
org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle
org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle
org.codehaus.plexus:plexus-utils:3.3.0=checkstyle org.codehaus.plexus:plexus-utils:3.3.0=checkstyle
org.commonmark:commonmark-ext-autolink:0.21.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.commonmark:commonmark-ext-gfm-strikethrough:0.21.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.commonmark:commonmark-ext-gfm-tables:0.21.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.commonmark:commonmark:0.21.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.dom4j:dom4j:2.2.0=spotbugs org.dom4j:dom4j:2.2.0=spotbugs
org.hamcrest:hamcrest:3.0=testCompileClasspath,testRuntimeClasspath 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.javassist:javassist:3.28.0-GA=checkstyle
org.jboss.logging:jboss-logging:3.6.1.Final=compileClasspath,runtimeClasspath,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,runtimeClasspath,testAnnotationProcessor,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-api:6.0.1=testCompileClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter-engine:6.0.1=testRuntimeClasspath org.junit.jupiter:junit-jupiter-engine:6.0.1=testRuntimeClasspath
org.junit.jupiter:junit-jupiter-params:6.0.1=testCompileClasspath,testRuntimeClasspath org.junit.jupiter:junit-jupiter-params:6.0.1=testCompileClasspath,testRuntimeClasspath
@@ -108,81 +112,82 @@ org.junit:junit-bom:6.0.1=testCompileClasspath,testRuntimeClasspath
org.junit:junit-bom:6.1.0=spotbugs org.junit:junit-bom:6.1.0=spotbugs
org.mockito:mockito-core:5.20.0=mockitoAgent,testCompileClasspath,testRuntimeClasspath org.mockito:mockito-core:5.20.0=mockitoAgent,testCompileClasspath,testRuntimeClasspath
org.mockito:mockito-junit-jupiter:5.20.0=testCompileClasspath,testRuntimeClasspath org.mockito:mockito-junit-jupiter:5.20.0=testCompileClasspath,testRuntimeClasspath
org.nibor.autolink:autolink:0.10.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.objenesis:objenesis:3.3=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.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath
org.osgi:org.osgi.annotation.bundle:2.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,testCompileClasspath org.osgi:org.osgi.annotation.versioning:1.1.2=compileClasspath,generatedOpenapiCompileClasspath,testCompileClasspath
org.osgi:org.osgi.resource:1.0.0=compileClasspath,testCompileClasspath org.osgi:org.osgi.resource:1.0.0=compileClasspath,generatedOpenapiCompileClasspath,testCompileClasspath
org.osgi:org.osgi.service.serviceloader:1.0.0=compileClasspath,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-analysis:9.10.1=spotbugs
org.ow2.asm:asm-commons: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-tree:9.10.1=spotbugs
org.ow2.asm:asm-util: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.10.1=spotbugs
org.ow2.asm:asm:9.7.1=testCompileClasspath,testRuntimeClasspath org.ow2.asm:asm:9.7.1=testCompileClasspath,testRuntimeClasspath
org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor org.pcollections:pcollections:4.0.1=annotationProcessor,generatedOpenapiAnnotationProcessor,testAnnotationProcessor
org.reactivestreams:reactive-streams:1.0.4=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.reactivestreams:reactive-streams:1.0.4=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.reflections:reflections:0.10.2=checkstyle org.reflections:reflections:0.10.2=checkstyle
org.skyscreamer:jsonassert:1.5.3=testCompileClasspath,testRuntimeClasspath org.skyscreamer:jsonassert:1.5.3=testCompileClasspath,testRuntimeClasspath
org.slf4j:jul-to-slf4j:2.0.17=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.slf4j:jul-to-slf4j:2.0.17=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.slf4j:slf4j-api:2.0.17=compileClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,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.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-common:3.0.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springdoc:springdoc-openapi-starter-webmvc-api:3.0.0=compileClasspath,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,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-configuration-processor:4.0.0=annotationProcessor
org.springframework.boot:spring-boot-http-client:4.0.0=testCompileClasspath,testRuntimeClasspath 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-http-converter:4.0.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-jackson:4.0.0=compileClasspath,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-restclient:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-resttestclient: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-oauth2-resource-server:4.0.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-security:4.0.0=compileClasspath,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,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-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-jackson:4.0.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-logging:4.0.0=compileClasspath,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,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,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-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-runtime:4.0.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-tomcat:4.0.0=compileClasspath,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,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,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-test:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-webmvc: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-autoconfigure:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-test: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-tomcat:4.0.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-validation:4.0.0=compileClasspath,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,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-test:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-webmvc:4.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,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,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,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,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,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,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,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-test:7.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.security:spring-security-web:7.0.0=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,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,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework:spring-aop:7.0.1=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-beans:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework:spring-beans:7.0.1=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-context:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework:spring-context:7.0.1=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-core:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework:spring-core:7.0.1=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-expression:7.0.1=compileClasspath,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-test:7.0.1=testCompileClasspath,testRuntimeClasspath
org.springframework:spring-web: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,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework:spring-webflux:7.0.1=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-webmvc:7.0.1=compileClasspath,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.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs
org.xmlunit:xmlunit-core:2.10.4=testCompileClasspath,testRuntimeClasspath org.xmlunit:xmlunit-core:2.10.4=testCompileClasspath,testRuntimeClasspath
org.yaml:snakeyaml:2.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.yaml:snakeyaml:2.5=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
tools.jackson.core:jackson-core:3.0.2=compileClasspath,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,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,runtimeClasspath,testCompileClasspath,testRuntimeClasspath tools.jackson:jackson-bom:3.0.2=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
empty= empty=
@@ -70,7 +70,10 @@ public class SecurityConfig {
AccessDeniedHandler accessDeniedHandler, AccessDeniedHandler accessDeniedHandler,
org.springframework.beans.factory.ObjectProvider<PrimitiveSessionSecurityContextRepository> org.springframework.beans.factory.ObjectProvider<PrimitiveSessionSecurityContextRepository>
sessionSecurityContextRepository, sessionSecurityContextRepository,
org.springframework.beans.factory.ObjectProvider<RestrictedPathRule> restrictedPaths) org.springframework.beans.factory.ObjectProvider<RestrictedPathRule> restrictedPaths,
org.springframework.beans.factory.ObjectProvider<
org.springframework.security.web.authentication.AuthenticationSuccessHandler>
loginSuccessHandler)
throws Exception { throws Exception {
String[] publicPaths = securitySettings.publicPaths().toArray(new String[0]); String[] publicPaths = securitySettings.publicPaths().toArray(new String[0]);
java.util.List<RestrictedPathRule> restricted = restrictedPaths.orderedStream().toList(); java.util.List<RestrictedPathRule> restricted = restrictedPaths.orderedStream().toList();
@@ -137,6 +140,29 @@ public class SecurityConfig {
securityContext securityContext
.securityContextRepository(sessionSecurityContextRepository.getObject()) .securityContextRepository(sessionSecurityContextRepository.getObject())
.requireExplicitSave(false)); .requireExplicitSave(false));
// BFF 로그인. 세션을 만들 수 있는 유일한 경로다 — 이것이 없으면 auth-mode=redis-session 은
// 아무도 인증할 수 없는 모드가 된다. SPA 는 401 을 받으면 브라우저를 /oauth2/authorization/{id}
// 로 이동시키고, 콜백이 세션 쿠키를 심은 뒤 SPA 진입점으로 되돌린다.
//
// 진입점은 바꾸지 않는다: API 요청이 302 로 답하면 XHR 이 따라갈 수 없으므로, 미인증 API 호출은
// 그대로 봉투 401 이어야 한다. 아래 defaultSuccessUrl 대신 주입된 핸들러를 쓰는 이유는
// OidcUser 를 세션이 담을 수 있는 AuthenticatedPrincipal 로 바꿔야 하기 때문이다.
org.springframework.security.web.authentication.AuthenticationSuccessHandler onSuccess =
loginSuccessHandler.getIfAvailable();
if (onSuccess != null) {
http.oauth2Login(login -> login.successHandler(onSuccess));
}
http.logout(
logout ->
logout
.logoutUrl("/logout")
.invalidateHttpSession(true)
.deleteCookies(securitySettings.session().cookieName())
.logoutSuccessHandler(
(request, response, authentication) ->
response.setStatus(
jakarta.servlet.http.HttpServletResponse.SC_NO_CONTENT)));
} }
return http.build(); return http.build();
} }
@@ -0,0 +1,52 @@
package dev.caskeleton.adapter.inbound.web.techlog;
import dev.caskeleton.application.techlog.error.StudioError;
/**
* Studio 실패의 client-safe {@code error.message} 단일 출처.
*
* <p>{@code StudioException#getMessage()}는 Studio use case/facade가 진단용으로 채우는 원문이라 {@code
* ApiErrorCarrier} javadoc이 경고하는 대로 SQLState나 upstream detail을 실을 수 있다. 그래서 응답에는 절대 흘리지 않고, 이 클래스가
* code별 고정 문구만 내보낸다 — 스켈레톤의 {@code ClientSafeErrorMessages}가 {@code OperationalError}에 대해 하는 것과 같은
* 역할을 {@link StudioError}에 대해 한다.
*
* <p>문구는 {@code docs/registries/error-codes.yaml}의 각 row {@code client_safe_message}와 정확히 같아야 한다 —
* {@code StudioErrorRegistryTest}가 그 일치를 고정한다. {@code PAYLOAD_TOO_LARGE}/{@code
* UNSUPPORTED_MEDIA_TYPE}은 Studio 전용 row가 없고 {@code feature-api-contract-baseline}이 이미 등록한 row를
* 재사용하므로(Task 5 report 5절) 그 row의 영문 문구를 그대로 따른다 — 나머지는 Studio 전용 한국어 문구다.
*
* <p>{@link StudioError}를 exhaustive switch로 매핑하므로(default 없음) 새 상수를 추가하면 이 파일도 컴파일 타임에 고쳐야 한다 — 문구
* 누락이 생길 수 없다.
*/
public final class StudioClientSafeMessages {
private StudioClientSafeMessages() {}
public static String forError(StudioError error) {
return switch (error) {
case AUTHENTICATION_REQUIRED -> "Studio 인증이 필요합니다";
case STUDIO_ACCESS_DENIED -> "이 Studio 리소스에 접근할 권한이 없습니다";
case DOCUMENT_NOT_FOUND -> "요청한 문서를 찾을 수 없습니다";
case VERSION_CONFLICT -> "저장된 version이 더 최신입니다";
case REQUEST_VALIDATION_FAILED -> "요청 형식이 올바르지 않습니다";
case DOCUMENT_VALIDATION_FAILED -> "문서 검증에 실패했습니다";
case VALIDATION_STALE -> "검증 결과가 최신 문서 기준이 아닙니다. 다시 검증해 주세요";
case PREVIEW_NOT_FOUND -> "요청한 미리보기를 찾을 수 없습니다";
case PREVIEW_STALE -> "미리보기가 최신 문서 기준이 아닙니다. 다시 생성해 주세요";
case PREVIEW_EXPIRED -> "미리보기가 만료되었습니다. 다시 생성해 주세요";
case PUBLICATION_NOT_FOUND -> "요청한 게시물을 찾을 수 없습니다";
case PUBLICATION_CONFLICT -> "게시 작업이 다른 변경과 충돌했습니다";
case PUBLICATION_EVENT_NOT_FOUND -> "요청한 게시 이벤트를 찾을 수 없습니다";
case PUBLICATION_SNAPSHOT_NOT_FOUND -> "요청한 게시 스냅샷을 찾을 수 없습니다";
case WARNING_ACKNOWLEDGEMENT_REQUIRED -> "경고 확인이 필요합니다. 확인 후 다시 시도해 주세요";
case IDEMPOTENCY_KEY_REUSED -> "Idempotency 키가 다른 요청에 재사용되었습니다";
case ASSET_NOT_FOUND -> "요청한 자산을 찾을 수 없습니다";
case ASSET_NOT_READY -> "자산 처리가 아직 완료되지 않았습니다";
case ASSET_IN_USE -> "자산이 사용 중이라 이 작업을 수행할 수 없습니다";
case ASSET_QUARANTINED -> "자산이 격리 처리되어 사용할 수 없습니다";
case PAYLOAD_TOO_LARGE -> "Request payload is too large";
case UNSUPPORTED_MEDIA_TYPE -> "Request Content-Type is not supported";
case STUDIO_UNAVAILABLE -> "Studio 서비스를 일시적으로 사용할 수 없습니다. 잠시 후 다시 시도해 주세요";
};
}
}
@@ -0,0 +1,138 @@
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 java.util.stream.Collectors;
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.security.authorization.AuthorizationDeniedException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.MissingServletRequestParameterException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
/**
* Studio 실패를 스켈레톤 봉투로 옮긴다. 템플릿의 {@code GlobalExceptionHandler}를 수정하지 않기 위해 별도 advice로 둔다 — 그 파일은
* template sync 대상이다.
*
* <p>{@code error.message}에는 {@link StudioClientSafeMessages}가 주는 code별 고정 문구만 싣는다 — {@link
* StudioException#getMessage()}(진단용 원문)는 SQLState·upstream detail을 실을 수 있어 client-unsafe하다({@code
* ApiErrorCarrier} javadoc). 원문은 버리지 않고 서버 로그에만 남긴다 — {@code GlobalExceptionHandler}의 {@code
* handlePersistenceFailure}/{@code handleDependencyFailure}가 분류된 하위 계층 실패를 로깅하는 것과 같은 패턴이다.
*
* <p><b>{@code basePackages} 스코프 (final whole-branch review B4).</b> 이 advice는 {@code
* dev.caskeleton.adapter.inbound.web.techlog.studio} 아래의 컨트롤러(studio 컨트롤러 전부가 여기 산다, {@code
* studio.controller})에만 적용된다. 원래는 한 단계 위인 {@code ...web.techlog}였는데, 공개 조회 컨트롤러가 {@code
* ...web.techlog.publicapi}에 들어오면서 그 스코프가 남의 기능까지 덮게 되었다 — 아래 바인딩 예외 처리기들이 공개 조회의 파라미터 오류를 Studio
* 계약 코드로 바꿔 내보냈을 것이고, 그 코드는 public-v1 계약의 enum 에 없어서 프론트엔드의 응답 파싱을 깨뜨린다. 그래서 studio 로 좁혔다. {@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.studio")
public class StudioExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(StudioExceptionHandler.class);
@ExceptionHandler(StudioException.class)
public ResponseEntity<Envelope<Void>> handleStudio(StudioException ex) {
StudioError error = ex.studioError();
log.error(
"studio failure classified as {} (category={}, retryable={}): {}",
error.code(),
error.category(),
error.retryable(),
ex.getMessage(),
ex);
return ErrorResponseFactory.envelope(
error, StudioClientSafeMessages.forError(error), ex.details());
}
/**
* 필수 쿼리 파라미터 누락(예: {@code GET /api/v1/studio/catalog}의 {@code type}). {@code
* GlobalExceptionHandler}는 이 예외를 오버라이드하지 않으므로 부모 {@code ResponseEntityExceptionHandler}가 그대로 bare
* {@code ProblemDetail}(content-type {@code application/problem+json})을 만들고, {@code
* EnvelopeBodyAdvice}의 JSON 미디어타입 검사에 걸려 봉투를 못 씌운다 — ADR-006이 쓰지 않기로 한 RFC 7807이 그대로 나간다(final
* whole-branch review B4). studio 스코프에서 계약 코드 {@link StudioError#REQUEST_VALIDATION_FAILED}(422)로
* 옮긴다.
*/
@ExceptionHandler(MissingServletRequestParameterException.class)
public ResponseEntity<Envelope<Void>> handleMissingParameter(
MissingServletRequestParameterException ex) {
return requestValidationFailed(ex.getParameterName(), "Required parameter is missing");
}
/**
* 쿼리 파라미터 타입 불일치(예: {@code type=BOGUS}, {@code limit=abc}). {@code GlobalExceptionHandler}도 이 예외를
* 처리하지만 {@code OperationalError.BAD_PARAMETER}를 낸다 — Studio 계약 23종에 없는 코드다. studio 스코프에서 계약 코드로
* 옮긴다.
*/
@ExceptionHandler(MethodArgumentTypeMismatchException.class)
public ResponseEntity<Envelope<Void>> handleTypeMismatch(MethodArgumentTypeMismatchException ex) {
return requestValidationFailed(ex.getName(), "Parameter value is invalid");
}
/**
* 요청 본문 bean validation 실패(예: {@code title} 120자 초과). {@code GlobalExceptionHandler}도 이 예외를 처리하지만
* 400 {@code OperationalError.VALIDATION_FAILED}를 낸다 — Studio 계약에 없는 코드이고 (계약이 아는 것은 {@code
* REQUEST_VALIDATION_FAILED}와 {@code DOCUMENT_VALIDATION_FAILED}뿐이다), 상태도 계약이 본문 검증 실패에 배정한 422가
* 아니다. 프론트엔드는 봉투의 {@code code}를 enum으로 검증하므로 계약 밖 코드는 응답 파싱 자체를 깨뜨린다. studio 스코프에서 계약 코드로 옮긴다.
*/
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<Envelope<Void>> handleBodyValidation(MethodArgumentNotValidException ex) {
List<Map<String, Object>> fieldErrors =
ex.getBindingResult().getFieldErrors().stream()
.map(
error ->
Map.<String, Object>of(
"path",
"/" + error.getField(),
"message",
error.getDefaultMessage() == null
? "Value is invalid"
: error.getDefaultMessage()))
.collect(Collectors.toList());
return ErrorResponseFactory.envelope(
StudioError.REQUEST_VALIDATION_FAILED,
StudioClientSafeMessages.forError(StudioError.REQUEST_VALIDATION_FAILED),
Map.of("fieldErrors", fieldErrors));
}
/**
* 권한 부족. 스켈레톤의 분류기는 {@code AUTHZ_INSUFFICIENT_PERMISSION}을 내지만 계약이 403에 배정한 코드는 {@code
* STUDIO_ACCESS_DENIED}다({@code responses.AccessDenied.x-error-codes}). 상태는 그대로 403이고 코드만 계약 쪽으로
* 옮긴다.
*/
@ExceptionHandler(AuthorizationDeniedException.class)
public ResponseEntity<Envelope<Void>> handleAccessDenied(AuthorizationDeniedException ex) {
log.warn("studio access denied: {}", ex.getMessage());
return ErrorResponseFactory.envelope(
StudioError.STUDIO_ACCESS_DENIED,
StudioClientSafeMessages.forError(StudioError.STUDIO_ACCESS_DENIED),
null);
}
/**
* {@code details}를 계약의 {@code ValidationErrorDetails}({@code fieldErrors: [{path, message}]}) 모양에
* 맞춰 싣는다 — 자유형 {@code Object}로 아무 모양이나 실으면 계약의 {@code oneOf} 제약을 위반한다.
*/
private static ResponseEntity<Envelope<Void>> requestValidationFailed(
String parameterName, String message) {
Map<String, Object> fieldError = Map.of("path", "/" + parameterName, "message", message);
Map<String, Object> details = Map.of("fieldErrors", List.of(fieldError));
return ErrorResponseFactory.envelope(
StudioError.REQUEST_VALIDATION_FAILED,
StudioClientSafeMessages.forError(StudioError.REQUEST_VALIDATION_FAILED),
details);
}
}
@@ -0,0 +1,107 @@
package dev.caskeleton.adapter.inbound.web.techlog.auth;
import dev.caskeleton.adapter.inbound.web.auth.AuthenticatedPrincipal;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler;
import org.springframework.stereotype.Component;
/**
* OIDC 로그인 결과를 세션이 담을 수 있는 형태로 바꾼다.
*
* <p>{@code oauth2Login} 이 만드는 {@code OAuth2AuthenticationToken} 의 principal 은 {@code OidcUser} 다.
* 그런데 {@code PrimitiveSessionSecurityContextRepository#saveContext} 는 principal 이 {@link
* AuthenticatedPrincipal} 이 아니면 거부한다 — 자격증명·토큰·프레임워크 객체 그래프가 세션 직렬화 경계를 넘지 못하게 하는 의도적인 제약이다. 그래서
* 로그인 직후 여기서 claim 만 뽑아 {@code AuthenticatedPrincipal} 로 갈아끼운다. 세션에 남는 것은 sub·email·role 뿐이고
* ID/Access 토큰은 남지 않는다.
*
* <p>역할 추출은 {@code JwtToAuthenticatedPrincipalConverter} 와 같은 규칙이다 — Keycloak 의 {@code
* realm_access.roles} 와 {@code resource_access[*].roles} 를 합집합으로 본다. 두 경로(JWT 검증과 세션 로그인)가 같은 역할
* 집합을 만들어야 {@code studio:read}/{@code studio:write} 매핑이 모드와 무관하게 동일하게 걸린다.
*/
@Component
@ConditionalOnProperty(name = "ca-skeleton.security.auth-mode", havingValue = "redis-session")
public class StudioOidcLoginSuccessHandler implements AuthenticationSuccessHandler {
private final SimpleUrlAuthenticationSuccessHandler redirect =
new SimpleUrlAuthenticationSuccessHandler();
public StudioOidcLoginSuccessHandler(
@Value("${app.studio.post-login-redirect:/}") String defaultTargetUrl) {
redirect.setDefaultTargetUrl(defaultTargetUrl);
// SPA 가 라우팅을 소유한다. 프레임워크의 SavedRequest 는 SecurityConfig 가 이미 꺼두었으므로
// 로그인 후에는 항상 SPA 진입점으로 보내고, 원래 가려던 화면 복원은 SPA 가 한다.
redirect.setAlwaysUseDefaultTargetUrl(true);
}
@Override
public void onAuthenticationSuccess(
HttpServletRequest request, HttpServletResponse response, Authentication authentication)
throws IOException, ServletException {
if (authentication.getPrincipal() instanceof OidcUser user) {
Set<String> roles = extractRoles(user);
AuthenticatedPrincipal principal =
new AuthenticatedPrincipal(user.getSubject(), user.getEmail(), roles);
Collection<GrantedAuthority> authorities =
roles.stream()
.map(
r ->
(GrantedAuthority)
new SimpleGrantedAuthority("ROLE_" + r.toUpperCase(Locale.ROOT)))
.collect(java.util.stream.Collectors.toCollection(ArrayList::new));
SecurityContext context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(
UsernamePasswordAuthenticationToken.authenticated(principal, null, authorities));
SecurityContextHolder.setContext(context);
// requireExplicitSave(false) 이므로 SecurityContextHolderFilter 가 응답 커밋 시 저장한다.
authentication = context.getAuthentication();
}
redirect.onAuthenticationSuccess(request, response, authentication);
}
private static Set<String> extractRoles(OidcUser user) {
Set<String> roles = new HashSet<>();
addRoles(roles, user.getClaimAsMap("realm_access"));
Map<String, Object> resourceAccess = user.getClaimAsMap("resource_access");
if (resourceAccess != null) {
for (Object client : resourceAccess.values()) {
if (client instanceof Map<?, ?> map) {
addRoles(roles, map);
}
}
}
List<String> generic = user.getClaimAsStringList("roles");
if (generic != null) {
roles.addAll(generic);
}
return Set.copyOf(roles);
}
private static void addRoles(Set<String> sink, Map<?, ?> holder) {
if (holder == null) {
return;
}
if (holder.get("roles") instanceof Collection<?> values) {
values.forEach(value -> sink.add(String.valueOf(value)));
}
}
}
@@ -0,0 +1,28 @@
package dev.caskeleton.adapter.inbound.web.techlog.publicapi;
import dev.caskeleton.application.techlog.publicsite.error.PublicError;
/**
* 공개 조회 실패의 client-safe {@code error.message} 단일 출처.
*
* <p>{@code PublicException#getMessage()}는 use case 가 진단용으로 채우는 원문이라 {@code ApiErrorCarrier}
* javadoc 이 경고하는 대로 저장소 내부 사정을 실을 수 있다. 그래서 응답에는 절대 흘리지 않고 이 클래스가 code 별 고정 문구만 내보낸다 — {@code
* StudioClientSafeMessages}가 {@code StudioError}에 대해 하는 것과 같은 역할이다.
*
* <p>문구는 {@code docs/registries/error-codes.yaml}의 각 row {@code client_safe_message}와 정확히 같아야 한다 —
* {@code PublicErrorRegistryTest}가 그 일치를 고정한다.
*
* <p>{@link PublicError}를 exhaustive switch 로 매핑하므로(default 없음) 새 상수를 추가하면 이 파일도 컴파일 타임에 고쳐야 한다 —
* 문구 누락이 생길 수 없다.
*/
public final class PublicClientSafeMessages {
private PublicClientSafeMessages() {}
public static String forError(PublicError error) {
return switch (error) {
case PUBLIC_REQUEST_INVALID -> "요청 값이 올바르지 않습니다";
case PUBLIC_RESOURCE_NOT_FOUND -> "요청한 자료를 찾을 수 없습니다";
};
}
}
@@ -0,0 +1,94 @@
package dev.caskeleton.adapter.inbound.web.techlog.publicapi;
import dev.caskeleton.adapter.inbound.web.error.ErrorResponseFactory;
import dev.caskeleton.application.techlog.publicsite.error.PublicError;
import dev.caskeleton.application.techlog.publicsite.error.PublicException;
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;
/**
* 공개 조회 실패를 스켈레톤 봉투로 옮긴다. 템플릿의 {@code GlobalExceptionHandler}를 수정하지 않기 위해 별도 advice 로 둔다 — 그 파일은
* template sync 대상이다.
*
* <p><b>{@code basePackages} 스코프.</b> 이 advice 는 {@code
* dev.caskeleton.adapter.inbound.web.techlog.publicapi} 아래의 컨트롤러에만 적용된다. 형제인 {@code
* StudioExceptionHandler}가 원래 {@code ...web.techlog} 전체를 잡고 있었는데, 그 스코프는 이 패키지까지 포함하므로 공개 조회의 파라미터
* 오류가 Studio 계약의 {@code REQUEST_VALIDATION_FAILED}(422)로 나갔을 것이다 — public-v1 계약의 {@code
* ApiError.code} enum 에 없는 코드라 프론트엔드의 응답 파싱 자체가 깨진다. 그래서 이 advice 를 추가하면서 Studio 쪽 스코프를 {@code
* ...web.techlog.studio}로 좁혔다. 두 스코프는 이제 겹치지 않는다.
*
* <p>{@code error.message}에는 {@link PublicClientSafeMessages}가 주는 code 별 고정 문구만 싣는다 — {@link
* PublicException#getMessage()}(진단용 원문)는 그대로 내보내지 않는다({@code ApiErrorCarrier} javadoc). 원문은 버리지 않고
* 서버 로그에만 남긴다.
*/
@Order(Ordered.HIGHEST_PRECEDENCE)
@RestControllerAdvice(basePackages = "dev.caskeleton.adapter.inbound.web.techlog.publicapi")
public class PublicExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(PublicExceptionHandler.class);
/**
* 공개 조회는 인증이 없고 열람자가 익명이다. 없는 slug 하나하나를 ERROR 로 남기면 크롤러가 만드는 404 가 로그를 덮어 실제 장애를 가린다 — {@code
* NOT_FOUND}는 WARN 이하로 남기고 나머지만 ERROR 로 올린다.
*/
@ExceptionHandler(PublicException.class)
public ResponseEntity<Envelope<Void>> handlePublic(PublicException ex) {
PublicError error = ex.publicError();
if (error == PublicError.PUBLIC_RESOURCE_NOT_FOUND) {
log.debug("public resource not found: {}", ex.getMessage());
} else {
log.warn(
"public request rejected as {} (category={}): {}",
error.code(),
error.category(),
ex.getMessage());
}
return ErrorResponseFactory.envelope(error, PublicClientSafeMessages.forError(error), null);
}
/**
* 필수 쿼리 파라미터 누락 — 계약에서 {@code GET /v1/public/search}의 {@code q}가 유일하다. 이 예외를 그냥 두면 부모 {@code
* ResponseEntityExceptionHandler}가 bare {@code ProblemDetail}(content-type {@code
* application/problem+json})을 만들고, {@code EnvelopeBodyAdvice}의 JSON 미디어타입 검사에 걸려 봉투를 못 씌운다 —
* ADR-006 이 쓰지 않기로 한 RFC 7807 이 그대로 나간다.
*/
@ExceptionHandler(MissingServletRequestParameterException.class)
public ResponseEntity<Envelope<Void>> handleMissingParameter(
MissingServletRequestParameterException ex) {
return requestInvalid(ex.getParameterName(), "REQUIRED", "Required parameter is missing");
}
/**
* 쿼리 파라미터 타입 불일치(예: {@code page=abc}, {@code year=x}). {@code GlobalExceptionHandler}도 이 예외를
* 처리하지만 {@code OperationalError.BAD_PARAMETER}를 낸다 — public-v1 계약의 세 코드에 없다.
*/
@ExceptionHandler(MethodArgumentTypeMismatchException.class)
public ResponseEntity<Envelope<Void>> handleTypeMismatch(MethodArgumentTypeMismatchException ex) {
return requestInvalid(ex.getName(), "TYPE_MISMATCH", "Parameter value is invalid");
}
/**
* {@code details}를 계약의 {@code ValidationErrorDetails}({@code fieldErrors: [{field, code,
* message}]}) 모양에 맞춰 싣는다. 세 필드 전부 {@code required}이므로 하나라도 빠지면 계약 위반이다 — Studio 계약의 {@code {path,
* message}}와 모양이 다르니 그 코드를 복사해 오면 안 된다.
*/
private static ResponseEntity<Envelope<Void>> requestInvalid(
String field, String code, String message) {
Map<String, Object> fieldError = Map.of("field", field, "code", code, "message", message);
Map<String, Object> details = Map.of("fieldErrors", List.of(fieldError));
return ErrorResponseFactory.envelope(
PublicError.PUBLIC_REQUEST_INVALID,
PublicClientSafeMessages.forError(PublicError.PUBLIC_REQUEST_INVALID),
details);
}
}
@@ -0,0 +1,48 @@
package dev.caskeleton.adapter.inbound.web.techlog.publicapi.controller;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.CaseDetailResponse;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.QuestionDetailResponse;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ReferenceDetailResponse;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.mapper.DocumentResponseMapper;
import dev.caskeleton.application.techlog.publicsite.query.SlugQuery;
import dev.caskeleton.application.techlog.publicsite.service.GetPublicCaseUseCase;
import dev.caskeleton.application.techlog.publicsite.service.GetPublicQuestionUseCase;
import dev.caskeleton.application.techlog.publicsite.service.GetPublicReferenceUseCase;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
/**
* 문서 상세 세 종류. 계약 {@code getPublicCase} / {@code getPublicReference} / {@code getPublicQuestion}.
*/
@RestController
public class PublicDocumentController {
private final GetPublicCaseUseCase getCase;
private final GetPublicReferenceUseCase getReference;
private final GetPublicQuestionUseCase getQuestion;
public PublicDocumentController(
GetPublicCaseUseCase getCase,
GetPublicReferenceUseCase getReference,
GetPublicQuestionUseCase getQuestion) {
this.getCase = getCase;
this.getReference = getReference;
this.getQuestion = getQuestion;
}
@GetMapping("/v1/public/cases/{slug}")
public CaseDetailResponse getPublicCase(@PathVariable("slug") String slug) {
return DocumentResponseMapper.caseDetail(getCase.handle(new SlugQuery(slug)));
}
@GetMapping("/v1/public/references/{slug}")
public ReferenceDetailResponse getPublicReference(@PathVariable("slug") String slug) {
return DocumentResponseMapper.referenceDetail(getReference.handle(new SlugQuery(slug)));
}
@GetMapping("/v1/public/questions/{slug}")
public QuestionDetailResponse getPublicQuestion(@PathVariable("slug") String slug) {
return DocumentResponseMapper.questionDetail(getQuestion.handle(new SlugQuery(slug)));
}
}
@@ -0,0 +1,106 @@
package dev.caskeleton.adapter.inbound.web.techlog.publicapi.controller;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.KnowledgePage;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.QuestionPage;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.SearchResultPage;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.mapper.ExploreResponseMapper;
import dev.caskeleton.application.techlog.publicsite.query.ExploreKnowledgeQuery;
import dev.caskeleton.application.techlog.publicsite.query.ExploreQuestionsQuery;
import dev.caskeleton.application.techlog.publicsite.query.SearchQuery;
import dev.caskeleton.application.techlog.publicsite.service.ExploreKnowledgeUseCase;
import dev.caskeleton.application.techlog.publicsite.service.ExploreQuestionsUseCase;
import dev.caskeleton.application.techlog.publicsite.service.SearchPublicResourcesUseCase;
import java.util.Set;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* 탐색과 검색. 계약 {@code exploreKnowledge} / {@code exploreQuestions} / {@code searchPublicResources}.
*
* <p>계약이 enum 을 선언한 파라미터는 {@link PublicRequestParams} 로 검사한다 — 이유는 그 클래스 javadoc.
*/
@RestController
public class PublicExploreController {
private static final Set<String> KNOWLEDGE_TYPES = Set.of("CASE", "REFERENCE");
private static final Set<String> KNOWLEDGE_SORTS =
Set.of("PUBLISHED_DESC", "UPDATED_DESC", "VERIFIED_DESC");
private static final Set<String> QUESTION_STATUSES =
Set.of("OPEN", "INVESTIGATING", "PAUSED", "RESOLVED");
private static final Set<String> QUESTION_SORTS =
Set.of("UPDATED_DESC", "OPENED_DESC", "RESOLVED_DESC");
private static final Set<String> SEARCH_TYPES =
Set.of("CASE", "REFERENCE", "QUESTION", "PROJECT", "RELEASE");
private final ExploreKnowledgeUseCase exploreKnowledge;
private final ExploreQuestionsUseCase exploreQuestions;
private final SearchPublicResourcesUseCase search;
public PublicExploreController(
ExploreKnowledgeUseCase exploreKnowledge,
ExploreQuestionsUseCase exploreQuestions,
SearchPublicResourcesUseCase search) {
this.exploreKnowledge = exploreKnowledge;
this.exploreQuestions = exploreQuestions;
this.search = search;
}
@GetMapping("/v1/public/explore/knowledge")
public KnowledgePage exploreKnowledge(
@RequestParam(value = "type", required = false) String type,
@RequestParam(value = "topic", required = false) String topic,
@RequestParam(value = "project", required = false) String project,
@RequestParam(value = "tag", required = false) String tag,
@RequestParam(value = "year", required = false) Integer year,
@RequestParam(value = "sort", required = false) String sort,
@RequestParam(value = "page", defaultValue = "1") int page,
@RequestParam(value = "size", defaultValue = "20") int size) {
ExploreKnowledgeQuery query =
new ExploreKnowledgeQuery(
PublicRequestParams.oneOf("type", type, KNOWLEDGE_TYPES),
topic,
project,
tag,
PublicRequestParams.year(year),
PublicRequestParams.sort("sort", sort, "PUBLISHED_DESC", KNOWLEDGE_SORTS),
PublicRequestParams.page(page, size));
return ExploreResponseMapper.knowledge(exploreKnowledge.handle(query));
}
@GetMapping("/v1/public/explore/questions")
public QuestionPage exploreQuestions(
@RequestParam(value = "status", required = false) String status,
@RequestParam(value = "topic", required = false) String topic,
@RequestParam(value = "project", required = false) String project,
@RequestParam(value = "tag", required = false) String tag,
@RequestParam(value = "sort", required = false) String sort,
@RequestParam(value = "page", defaultValue = "1") int page,
@RequestParam(value = "size", defaultValue = "20") int size) {
ExploreQuestionsQuery query =
new ExploreQuestionsQuery(
PublicRequestParams.oneOf("status", status, QUESTION_STATUSES),
topic,
project,
tag,
PublicRequestParams.sort("sort", sort, "UPDATED_DESC", QUESTION_SORTS),
PublicRequestParams.page(page, size));
return ExploreResponseMapper.questions(exploreQuestions.handle(query));
}
@GetMapping("/v1/public/search")
public SearchResultPage searchPublicResources(
@RequestParam("q") String q,
@RequestParam(value = "type", required = false) String type,
@RequestParam(value = "topic", required = false) String topic,
@RequestParam(value = "page", defaultValue = "1") int page,
@RequestParam(value = "size", defaultValue = "20") int size) {
SearchQuery query =
new SearchQuery(
PublicRequestParams.searchTerm(q),
PublicRequestParams.oneOf("type", type, SEARCH_TYPES),
topic,
PublicRequestParams.page(page, size));
return ExploreResponseMapper.search(search.handle(query));
}
}
@@ -0,0 +1,103 @@
package dev.caskeleton.adapter.inbound.web.techlog.publicapi.controller;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ProjectActivityPage;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ProjectDecisionPage;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ProjectDetailResponse;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ProjectListResponse;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ProjectRecordPage;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.mapper.ProjectResponseMapper;
import dev.caskeleton.application.techlog.publicsite.query.EmptyQuery;
import dev.caskeleton.application.techlog.publicsite.query.ProjectDecisionPageQuery;
import dev.caskeleton.application.techlog.publicsite.query.ProjectPageQuery;
import dev.caskeleton.application.techlog.publicsite.query.ProjectRecordPageQuery;
import dev.caskeleton.application.techlog.publicsite.query.SlugQuery;
import dev.caskeleton.application.techlog.publicsite.service.GetPublicProjectUseCase;
import dev.caskeleton.application.techlog.publicsite.service.ListPublicProjectActivitiesUseCase;
import dev.caskeleton.application.techlog.publicsite.service.ListPublicProjectDecisionsUseCase;
import dev.caskeleton.application.techlog.publicsite.service.ListPublicProjectRecordsUseCase;
import dev.caskeleton.application.techlog.publicsite.service.ListPublicProjectsUseCase;
import java.util.Set;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* 프로젝트 허브. 계약 {@code listPublicProjects} / {@code getPublicProject} 와 하위 목록 셋({@code
* listPublicProjectDecisions} / {@code listPublicProjectRecords} / {@code
* listPublicProjectActivities}).
*
* <p>하위 목록은 프로젝트 자체가 공개가 아니면 빈 페이지가 아니라 404 다 — 비공개 프로젝트의 존재가 "결정이 0건인 프로젝트"로 새어 나가면 안 된다. 그 구분은
* port 가 {@code Optional} 로 표현하고 use case 가 404 로 옮긴다.
*/
@RestController
public class PublicProjectController {
private static final Set<String> RECORD_TYPES = Set.of("CASE", "REFERENCE", "QUESTION");
private static final Set<String> RECORD_RELATIONS = Set.of("PRIMARY", "RELATED");
private final ListPublicProjectsUseCase listProjects;
private final GetPublicProjectUseCase getProject;
private final ListPublicProjectDecisionsUseCase listDecisions;
private final ListPublicProjectRecordsUseCase listRecords;
private final ListPublicProjectActivitiesUseCase listActivities;
public PublicProjectController(
ListPublicProjectsUseCase listProjects,
GetPublicProjectUseCase getProject,
ListPublicProjectDecisionsUseCase listDecisions,
ListPublicProjectRecordsUseCase listRecords,
ListPublicProjectActivitiesUseCase listActivities) {
this.listProjects = listProjects;
this.getProject = getProject;
this.listDecisions = listDecisions;
this.listRecords = listRecords;
this.listActivities = listActivities;
}
@GetMapping("/v1/public/projects")
public ProjectListResponse listPublicProjects() {
return ProjectResponseMapper.list(listProjects.handle(new EmptyQuery()));
}
@GetMapping("/v1/public/projects/{slug}")
public ProjectDetailResponse getPublicProject(@PathVariable("slug") String slug) {
return ProjectResponseMapper.detail(getProject.handle(new SlugQuery(slug)));
}
@GetMapping("/v1/public/projects/{slug}/decisions")
public ProjectDecisionPage listPublicProjectDecisions(
@PathVariable("slug") String slug,
@RequestParam(value = "status", required = false) String status,
@RequestParam(value = "page", defaultValue = "1") int page,
@RequestParam(value = "size", defaultValue = "20") int size) {
return ProjectResponseMapper.decisions(
listDecisions.handle(
new ProjectDecisionPageQuery(slug, status, PublicRequestParams.page(page, size))));
}
@GetMapping("/v1/public/projects/{slug}/records")
public ProjectRecordPage listPublicProjectRecords(
@PathVariable("slug") String slug,
@RequestParam(value = "type", required = false) String type,
@RequestParam(value = "relation", required = false) String relation,
@RequestParam(value = "page", defaultValue = "1") int page,
@RequestParam(value = "size", defaultValue = "20") int size) {
ProjectRecordPageQuery query =
new ProjectRecordPageQuery(
slug,
PublicRequestParams.oneOf("type", type, RECORD_TYPES),
PublicRequestParams.oneOf("relation", relation, RECORD_RELATIONS),
PublicRequestParams.page(page, size));
return ProjectResponseMapper.records(listRecords.handle(query));
}
@GetMapping("/v1/public/projects/{slug}/activities")
public ProjectActivityPage listPublicProjectActivities(
@PathVariable("slug") String slug,
@RequestParam(value = "page", defaultValue = "1") int page,
@RequestParam(value = "size", defaultValue = "20") int size) {
return ProjectResponseMapper.activities(
listActivities.handle(new ProjectPageQuery(slug, PublicRequestParams.page(page, size))));
}
}
@@ -0,0 +1,42 @@
package dev.caskeleton.adapter.inbound.web.techlog.publicapi.controller;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ReleaseDetailResponse;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ReleaseListResponse;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.mapper.ReleaseResponseMapper;
import dev.caskeleton.application.techlog.publicsite.query.EmptyQuery;
import dev.caskeleton.application.techlog.publicsite.query.SlugQuery;
import dev.caskeleton.application.techlog.publicsite.service.GetPublicReleaseUseCase;
import dev.caskeleton.application.techlog.publicsite.service.ListPublicReleasesUseCase;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
/**
* Tech Log 자체 변경 기록. 계약 {@code listPublicReleases} / {@code getPublicRelease}.
*
* <p>{@code getPublicRelease} 의 path 변수는 slug 가 아니라 {@code version} 이다 — {@code SlugQuery} 를 그대로 쓰되
* 어댑터가 {@code release.version} 으로 조회한다({@code PublicReleaseQueryPort#findByVersion}). 값의 의미가 다르므로
* 이름을 그대로 옮겨 적는다.
*/
@RestController
public class PublicReleaseController {
private final ListPublicReleasesUseCase listReleases;
private final GetPublicReleaseUseCase getRelease;
public PublicReleaseController(
ListPublicReleasesUseCase listReleases, GetPublicReleaseUseCase getRelease) {
this.listReleases = listReleases;
this.getRelease = getRelease;
}
@GetMapping("/v1/public/releases")
public ReleaseListResponse listPublicReleases() {
return ReleaseResponseMapper.list(listReleases.handle(new EmptyQuery()));
}
@GetMapping("/v1/public/releases/{version}")
public ReleaseDetailResponse getPublicRelease(@PathVariable("version") String version) {
return ReleaseResponseMapper.detail(getRelease.handle(new SlugQuery(version)));
}
}
@@ -0,0 +1,65 @@
package dev.caskeleton.adapter.inbound.web.techlog.publicapi.controller;
import dev.caskeleton.application.techlog.publicsite.error.PublicError;
import dev.caskeleton.application.techlog.publicsite.error.PublicException;
import dev.caskeleton.application.techlog.publicsite.query.PublicPageRequest;
import java.util.List;
import java.util.Set;
/**
* 계약이 쿼리 파라미터에 건 제약을 요청 경계에서 강제한다.
*
* <p>enum 값을 검사하지 않고 그대로 SQL 필터로 넘기면 오타(`type=CASES`)가 오류가 아니라 "결과 0건"으로 보인다 — 소비자는 자기 요청이 틀렸다는 사실을
* 영영 알 수 없다. 계약이 enum 을 선언한 자리는 계약 밖 값을 {@code PUBLIC_REQUEST_INVALID} 로 거절한다.
*
* <p>파라미터를 생성 DTO 의 enum 타입으로 바인딩하지 않는 이유는, 그 경우 Spring 이 던지는 {@code
* MethodArgumentTypeMismatchException} 이 "어떤 값이 허용되는지"를 응답에 남기지 못하고 스택 상위에서 잡히기 때문이다. 여기서 검사하면 거절
* 이유를 계약의 {@code fieldErrors} 모양으로 정확히 실을 수 있다.
*/
final class PublicRequestParams {
private PublicRequestParams() {}
static PublicPageRequest page(int page, int size) {
return new PublicPageRequest(page, size);
}
/** null(=필터 없음)은 통과시키고, 값이 있으면 계약의 허용 집합에 있어야 한다. */
static String oneOf(String field, String value, Set<String> allowed) {
if (value == null) {
return null;
}
if (!allowed.contains(value)) {
throw PublicException.of(
PublicError.PUBLIC_REQUEST_INVALID,
field + " must be one of " + List.copyOf(allowed) + " but was '" + value + "'");
}
return value;
}
/** 값이 없으면 계약의 default 를 쓴다 — 정렬은 optional 이지만 항상 하나로 정해져야 한다. */
static String sort(String field, String value, String fallback, Set<String> allowed) {
return value == null ? fallback : oneOf(field, value, allowed);
}
/** 계약 {@code searchPublicResources.q}: minLength 1 / maxLength 100. */
static String searchTerm(String q) {
String trimmed = q == null ? "" : q.strip();
if (trimmed.isEmpty()) {
throw PublicException.of(PublicError.PUBLIC_REQUEST_INVALID, "q must not be blank");
}
if (trimmed.length() > 100) {
throw PublicException.of(
PublicError.PUBLIC_REQUEST_INVALID, "q must be at most 100 characters");
}
return trimmed;
}
/** 계약 {@code exploreKnowledge.year}: minimum 2000. */
static Integer year(Integer year) {
if (year != null && year < 2000) {
throw PublicException.of(PublicError.PUBLIC_REQUEST_INVALID, "year must be 2000 or later");
}
return year;
}
}
@@ -0,0 +1,55 @@
package dev.caskeleton.adapter.inbound.web.techlog.publicapi.controller;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.HomeResponse;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ProfileResponse;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.SiteResponse;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.mapper.SiteResponseMapper;
import dev.caskeleton.application.techlog.publicsite.query.EmptyQuery;
import dev.caskeleton.application.techlog.publicsite.service.GetPublicHomeUseCase;
import dev.caskeleton.application.techlog.publicsite.service.GetPublicProfileUseCase;
import dev.caskeleton.application.techlog.publicsite.service.GetPublicSiteUseCase;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* 사이트 껍데기 · 홈 · 운영자 프로필. 계약 {@code getPublicSite} / {@code getPublicHome} / {@code
* getPublicProfile}.
*
* <p>반환값을 Envelope 로 감싸지 않는다 — {@code EnvelopeBodyAdvice} 가 감싼다. 계약의 {@code <Payload>Envelope} 스키마로
* 생성된 DTO 는 쓰지 않는다(그걸 반환하면 봉투가 두 번 씌워진다).
*
* <p>경로에 {@code /api} 를 쓰지 않는다 — {@code PresentationWebConfig} 가 {@code
* ca-skeleton.presentation.api-base-path}("/api")를 모든 컨트롤러 매핑에 붙인다. 계약의 {@code servers} 가 {@code
* /api/v1/public} 이므로 여기 매핑은 {@code /v1/public/...} 이어야 최종 주소가 계약과 같아진다.
*/
@RestController
public class PublicSiteController {
private final GetPublicSiteUseCase getSite;
private final GetPublicHomeUseCase getHome;
private final GetPublicProfileUseCase getProfile;
public PublicSiteController(
GetPublicSiteUseCase getSite,
GetPublicHomeUseCase getHome,
GetPublicProfileUseCase getProfile) {
this.getSite = getSite;
this.getHome = getHome;
this.getProfile = getProfile;
}
@GetMapping("/v1/public/site")
public SiteResponse getPublicSite() {
return SiteResponseMapper.site(getSite.handle(new EmptyQuery()));
}
@GetMapping("/v1/public/home")
public HomeResponse getPublicHome() {
return SiteResponseMapper.home(getHome.handle(new EmptyQuery()));
}
@GetMapping("/v1/public/profile")
public ProfileResponse getPublicProfile() {
return SiteResponseMapper.profile(getProfile.handle(new EmptyQuery()));
}
}
@@ -0,0 +1,35 @@
package dev.caskeleton.adapter.inbound.web.techlog.publicapi.controller;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.TopicDetailResponse;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.TopicListResponse;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.mapper.TopicResponseMapper;
import dev.caskeleton.application.techlog.publicsite.query.EmptyQuery;
import dev.caskeleton.application.techlog.publicsite.query.SlugQuery;
import dev.caskeleton.application.techlog.publicsite.service.GetPublicTopicUseCase;
import dev.caskeleton.application.techlog.publicsite.service.ListPublicTopicsUseCase;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
/** 주제 목록과 상세. 계약 {@code listPublicTopics} / {@code getPublicTopic}. */
@RestController
public class PublicTopicController {
private final ListPublicTopicsUseCase listTopics;
private final GetPublicTopicUseCase getTopic;
public PublicTopicController(ListPublicTopicsUseCase listTopics, GetPublicTopicUseCase getTopic) {
this.listTopics = listTopics;
this.getTopic = getTopic;
}
@GetMapping("/v1/public/topics")
public TopicListResponse listPublicTopics() {
return TopicResponseMapper.list(listTopics.handle(new EmptyQuery()));
}
@GetMapping("/v1/public/topics/{topicSlug}")
public TopicDetailResponse getPublicTopic(@PathVariable("topicSlug") String topicSlug) {
return TopicResponseMapper.detail(getTopic.handle(new SlugQuery(topicSlug)));
}
}
@@ -0,0 +1,169 @@
package dev.caskeleton.adapter.inbound.web.techlog.publicapi.mapper;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.CaseDetailResponse;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.CaseDetailResponseCase;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.CaseDetailResponseRelations;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.QuestionDetailResponse;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.QuestionDetailResponseQuestion;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.QuestionDetailResponseQuestionResolution;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.QuestionDetailResponseRelations;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.QuestionPointGroup;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.QuestionUpdatePublic;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ReferenceDetailResponse;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ReferenceDetailResponseReference;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ReferenceDetailResponseRelations;
import dev.caskeleton.application.techlog.publicsite.model.CaseDetailView;
import dev.caskeleton.application.techlog.publicsite.model.PublishedDocumentView;
import dev.caskeleton.application.techlog.publicsite.model.PublishedQuestionView;
import dev.caskeleton.application.techlog.publicsite.model.QuestionDetailView;
import dev.caskeleton.application.techlog.publicsite.model.QuestionPointGroupView;
import dev.caskeleton.application.techlog.publicsite.model.QuestionUpdateView;
import dev.caskeleton.application.techlog.publicsite.model.ReferenceDetailView;
/**
* {@code getPublicCase} / {@code getPublicReference} / {@code getPublicQuestion} 의 응답 조립.
*
* <p>Case 와 Reference 는 같은 {@link PublishedDocumentView} 를 읽지만 계약이 요약 필드를 서로 다르게 이름 붙였다 — Case 는
* {@code problemSummary}/{@code conclusionSummary}, Reference 는 {@code scopeSummary} 다. view 는
* {@code primarySummary}/{@code secondarySummary} 라는 중립 이름을 쓰고 그 매핑을 여기서 한 번만 한다. ADR-003 이 말하는
* "API 용어와 Domain 용어 분리"가 이 자리다.
*/
public final class DocumentResponseMapper {
private DocumentResponseMapper() {}
public static CaseDetailResponse caseDetail(CaseDetailView view) {
PublishedDocumentView doc = view.document();
CaseDetailResponseCase body = new CaseDetailResponseCase();
body.setTitle(doc.title());
body.setProblemSummary(doc.primarySummary());
body.setConclusionSummary(doc.secondarySummary());
body.setEnvironmentSummary(doc.environmentSummary());
body.setContent(doc.content());
body.setContentFormat(CaseDetailResponseCase.ContentFormatEnum.fromValue(doc.contentFormat()));
body.setContentFormatVersion(doc.contentFormatVersion());
body.setPrimaryTopic(PublicResponseMapper.topic(doc.primaryTopic()));
body.setTags(PublicResponseMapper.map(doc.tags(), PublicResponseMapper::tag));
body.setPrimaryProject(PublicResponseMapper.project(doc.primaryProject()));
body.setCoverAsset(PublicResponseMapper.asset(doc.coverAsset()));
body.setPublishedAt(PublicResponseMapper.at(doc.publishedAt()));
body.setUpdatedAt(PublicResponseMapper.at(doc.updatedAt()));
body.setLastVerifiedAt(PublicResponseMapper.at(doc.lastVerifiedAt()));
CaseDetailResponseRelations relations = new CaseDetailResponseRelations();
relations.setOriginQuestion(PublicResponseMapper.related(view.relations().originQuestion()));
relations.setProjectDecisions(
PublicResponseMapper.relatedList(view.relations().projectDecisions()));
relations.setDerivedReferences(
PublicResponseMapper.relatedList(view.relations().derivedReferences()));
relations.setRelatedCases(PublicResponseMapper.relatedList(view.relations().relatedCases()));
CaseDetailResponse dto = new CaseDetailResponse();
dto.setCanonicalPath(view.canonicalPath());
dto.setIndexable(view.indexable());
dto.setCase(body);
dto.setRelations(relations);
return dto;
}
public static ReferenceDetailResponse referenceDetail(ReferenceDetailView view) {
PublishedDocumentView doc = view.document();
ReferenceDetailResponseReference body = new ReferenceDetailResponseReference();
body.setTitle(doc.title());
body.setScopeSummary(doc.primarySummary());
body.setAppliesTo(doc.appliesTo());
body.setExcludedScope(doc.excludedScope());
body.setFreshnessStatus(
ReferenceDetailResponseReference.FreshnessStatusEnum.fromValue(doc.freshnessStatus()));
body.setContent(doc.content());
body.setContentFormat(
ReferenceDetailResponseReference.ContentFormatEnum.fromValue(doc.contentFormat()));
body.setContentFormatVersion(doc.contentFormatVersion());
body.setPrimaryTopic(PublicResponseMapper.topic(doc.primaryTopic()));
body.setTags(PublicResponseMapper.map(doc.tags(), PublicResponseMapper::tag));
body.setPrimaryProject(PublicResponseMapper.project(doc.primaryProject()));
body.setCoverAsset(PublicResponseMapper.asset(doc.coverAsset()));
body.setPublishedAt(PublicResponseMapper.at(doc.publishedAt()));
body.setUpdatedAt(PublicResponseMapper.at(doc.updatedAt()));
body.setLastVerifiedAt(PublicResponseMapper.at(doc.lastVerifiedAt()));
ReferenceDetailResponseRelations relations = new ReferenceDetailResponseRelations();
relations.setSupportingCases(
PublicResponseMapper.relatedList(view.relations().supportingCases()));
relations.setRelatedDecisions(
PublicResponseMapper.relatedList(view.relations().relatedDecisions()));
relations.setRelatedReferences(
PublicResponseMapper.relatedList(view.relations().relatedReferences()));
ReferenceDetailResponse dto = new ReferenceDetailResponse();
dto.setCanonicalPath(view.canonicalPath());
dto.setIndexable(view.indexable());
dto.setReference(body);
dto.setRelations(relations);
return dto;
}
public static QuestionDetailResponse questionDetail(QuestionDetailView view) {
PublishedQuestionView q = view.question();
QuestionDetailResponseQuestion body = new QuestionDetailResponseQuestion();
body.setQuestion(q.question());
body.setSummary(q.summary());
body.setContext(q.context());
body.setImportance(q.importance());
body.setStatus(QuestionDetailResponseQuestion.StatusEnum.fromValue(q.status()));
body.setNextVerification(q.nextVerification());
body.setPoints(points(q.points()));
body.setUpdates(PublicResponseMapper.map(q.updates(), DocumentResponseMapper::update));
body.setResolution(resolution(q));
body.setOpenedAt(PublicResponseMapper.at(q.openedAt()));
body.setUpdatedAt(PublicResponseMapper.at(q.updatedAt()));
QuestionDetailResponseRelations relations = new QuestionDetailResponseRelations();
relations.setPrimaryProject(PublicResponseMapper.related(view.relations().primaryProject()));
relations.setResultCase(PublicResponseMapper.related(view.relations().resultCase()));
relations.setProducedDecision(
PublicResponseMapper.related(view.relations().producedDecision()));
relations.setDerivedReferences(
PublicResponseMapper.relatedList(view.relations().derivedReferences()));
QuestionDetailResponse dto = new QuestionDetailResponse();
dto.setCanonicalPath(view.canonicalPath());
dto.setIndexable(view.indexable());
dto.setQuestion(body);
dto.setRelations(relations);
return dto;
}
private static QuestionPointGroup points(QuestionPointGroupView view) {
QuestionPointGroup dto = new QuestionPointGroup();
dto.setFacts(view.facts());
dto.setAssumptions(view.assumptions());
dto.setUnknowns(view.unknowns());
dto.setConstraints(view.constraints());
return dto;
}
private static QuestionUpdatePublic update(QuestionUpdateView view) {
QuestionUpdatePublic dto = new QuestionUpdatePublic();
dto.setType(view.type());
dto.setTitle(view.title());
dto.setBodyMarkdown(view.bodyMarkdown());
dto.setOccurredAt(PublicResponseMapper.at(view.occurredAt()));
return dto;
}
/**
* 계약은 해결 정보를 별도 nullable object 로 묶었고 view 는 평평하게 들고 있다. 세 값이 전부 비어 있으면 빈 껍데기 object 대신 아예 내보내지
* 않는다 — 미해결 질문에 {@code resolution: {}} 이 붙으면 소비자가 "해결됐지만 내용이 없다"로 읽는다.
*/
private static QuestionDetailResponseQuestionResolution resolution(PublishedQuestionView q) {
if (q.resolutionType() == null && q.resolutionSummary() == null && q.resolvedAt() == null) {
return null;
}
QuestionDetailResponseQuestionResolution dto = new QuestionDetailResponseQuestionResolution();
dto.setType(q.resolutionType());
dto.setSummary(q.resolutionSummary());
dto.setResolvedAt(PublicResponseMapper.at(q.resolvedAt()));
return dto;
}
}
@@ -0,0 +1,90 @@
package dev.caskeleton.adapter.inbound.web.techlog.publicapi.mapper;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.KnowledgeListItem;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.KnowledgePage;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.QuestionListItem;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.QuestionPage;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.SearchResultItem;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.SearchResultPage;
import dev.caskeleton.application.techlog.publicsite.model.KnowledgeListItemView;
import dev.caskeleton.application.techlog.publicsite.model.KnowledgePageView;
import dev.caskeleton.application.techlog.publicsite.model.QuestionListItemView;
import dev.caskeleton.application.techlog.publicsite.model.QuestionPageView;
import dev.caskeleton.application.techlog.publicsite.model.SearchResultItemView;
import dev.caskeleton.application.techlog.publicsite.model.SearchResultPageView;
/**
* {@code exploreKnowledge} / {@code exploreQuestions} / {@code searchPublicResources} 의 응답 조립.
*
* <p>{@code fromValue} 는 계약 밖 값을 만나면 예외를 던진다. 그대로 둔다 — 여기서 조용히 null 을 넣으면 required 필드가 빈 채로 나가 소비자
* 쪽에서 더 늦게, 더 알기 어려운 모양으로 깨진다. 공개 projection 이 계약 밖 상태값을 담고 있다면 그건 데이터 결함이고 500 으로 드러나야 한다({@code
* INTERNAL_ERROR} 는 계약이 열거한 코드다).
*/
public final class ExploreResponseMapper {
private ExploreResponseMapper() {}
public static KnowledgePage knowledge(KnowledgePageView view) {
KnowledgePage dto = new KnowledgePage();
dto.setItems(PublicResponseMapper.map(view.items(), ExploreResponseMapper::knowledgeItem));
dto.setPage(PublicResponseMapper.page(view.page()));
return dto;
}
private static KnowledgeListItem knowledgeItem(KnowledgeListItemView view) {
KnowledgeListItem dto = new KnowledgeListItem();
dto.setType(KnowledgeListItem.TypeEnum.fromValue(view.type()));
dto.setTitle(view.title());
dto.setPath(view.path());
dto.setPrimarySummary(view.primarySummary());
dto.setSecondarySummary(view.secondarySummary());
dto.setPrimaryTopic(PublicResponseMapper.topic(view.primaryTopic()));
dto.setPrimaryProject(PublicResponseMapper.project(view.primaryProject()));
dto.setPublishedAt(PublicResponseMapper.at(view.publishedAt()));
dto.setLastVerifiedAt(PublicResponseMapper.at(view.lastVerifiedAt()));
dto.setFreshnessStatus(view.freshnessStatus());
return dto;
}
public static QuestionPage questions(QuestionPageView view) {
QuestionPage dto = new QuestionPage();
dto.setItems(PublicResponseMapper.map(view.items(), ExploreResponseMapper::questionItem));
dto.setPage(PublicResponseMapper.page(view.page()));
return dto;
}
private static QuestionListItem questionItem(QuestionListItemView view) {
QuestionListItem dto = new QuestionListItem();
dto.setQuestion(view.question());
dto.setPath(view.path());
dto.setStatus(QuestionListItem.StatusEnum.fromValue(view.status()));
dto.setSummary(view.summary());
dto.setCurrentUnderstanding(view.currentUnderstanding());
dto.setNextVerification(view.nextVerification());
dto.setPrimaryProject(PublicResponseMapper.project(view.primaryProject()));
dto.setUpdatedAt(PublicResponseMapper.at(view.updatedAt()));
return dto;
}
public static SearchResultPage search(SearchResultPageView view) {
SearchResultPage dto = new SearchResultPage();
dto.setQuery(view.query());
dto.setItems(PublicResponseMapper.map(view.items(), ExploreResponseMapper::searchItem));
dto.setPage(PublicResponseMapper.page(view.page()));
return dto;
}
private static SearchResultItem searchItem(SearchResultItemView view) {
SearchResultItem dto = new SearchResultItem();
dto.setContentType(view.contentType());
dto.setTitle(view.title());
dto.setPath(view.path());
dto.setSnippet(view.snippet());
dto.setMatchedFields(view.matchedFields());
dto.setPrimaryTopic(PublicResponseMapper.topic(view.primaryTopic()));
dto.setPrimaryProject(PublicResponseMapper.project(view.primaryProject()));
dto.setPublishedAt(PublicResponseMapper.at(view.publishedAt()));
dto.setUpdatedAt(PublicResponseMapper.at(view.updatedAt()));
return dto;
}
}
@@ -0,0 +1,113 @@
package dev.caskeleton.adapter.inbound.web.techlog.publicapi.mapper;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ProjectActivityItem;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ProjectActivityPage;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ProjectDecisionItem;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ProjectDecisionPage;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ProjectDetailResponse;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ProjectDetailResponseProject;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ProjectListItem;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ProjectListResponse;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ProjectRecordPage;
import dev.caskeleton.application.techlog.publicsite.model.ProjectActivityItemView;
import dev.caskeleton.application.techlog.publicsite.model.ProjectActivityPageView;
import dev.caskeleton.application.techlog.publicsite.model.ProjectDecisionItemView;
import dev.caskeleton.application.techlog.publicsite.model.ProjectDecisionPageView;
import dev.caskeleton.application.techlog.publicsite.model.ProjectDetailView;
import dev.caskeleton.application.techlog.publicsite.model.ProjectListItemView;
import dev.caskeleton.application.techlog.publicsite.model.ProjectRecordPageView;
import dev.caskeleton.application.techlog.publicsite.model.PublishedProjectView;
import java.util.List;
/** {@code listPublicProjects} 와 프로젝트 상세·하위 목록 세 개의 응답 조립. */
public final class ProjectResponseMapper {
private ProjectResponseMapper() {}
public static ProjectListResponse list(List<ProjectListItemView> views) {
ProjectListResponse dto = new ProjectListResponse();
dto.setItems(PublicResponseMapper.map(views, ProjectResponseMapper::listItem));
return dto;
}
private static ProjectListItem listItem(ProjectListItemView view) {
ProjectListItem dto = new ProjectListItem();
dto.setName(view.name());
dto.setSlug(view.slug());
dto.setPath(view.path());
dto.setOneLinePurpose(view.oneLinePurpose());
dto.setPhase(view.phase());
dto.setCurrentObjective(view.currentObjective());
dto.setNextStep(view.nextStep());
dto.setUpdatedAt(PublicResponseMapper.at(view.updatedAt()));
return dto;
}
public static ProjectDetailResponse detail(ProjectDetailView view) {
PublishedProjectView p = view.project();
ProjectDetailResponseProject body = new ProjectDetailResponseProject();
body.setName(p.name());
body.setSlug(p.slug());
body.setOneLinePurpose(p.oneLinePurpose());
body.setPurpose(p.purpose());
body.setBoundary(p.boundary());
body.setPhase(p.phase());
body.setCurrentObjective(p.currentObjective());
body.setNextStep(p.nextStep());
body.setSystemOverviewMarkdown(p.systemOverviewMarkdown());
body.setTechnologies(p.technologies());
body.setUpdatedAt(PublicResponseMapper.at(p.updatedAt()));
ProjectDetailResponse dto = new ProjectDetailResponse();
dto.setCanonicalPath(view.canonicalPath());
dto.setIndexable(view.indexable());
dto.setProject(body);
dto.setFeaturedDecision(PublicResponseMapper.related(view.featuredDecision()));
dto.setActiveQuestion(PublicResponseMapper.related(view.activeQuestion()));
dto.setSelectedRecords(PublicResponseMapper.relatedList(view.selectedRecords()));
return dto;
}
public static ProjectDecisionPage decisions(ProjectDecisionPageView view) {
ProjectDecisionPage dto = new ProjectDecisionPage();
dto.setItems(PublicResponseMapper.map(view.items(), ProjectResponseMapper::decision));
dto.setPage(PublicResponseMapper.page(view.page()));
return dto;
}
private static ProjectDecisionItem decision(ProjectDecisionItemView view) {
ProjectDecisionItem dto = new ProjectDecisionItem();
dto.setId(view.id());
dto.setStatement(view.statement());
dto.setStatus(view.status());
dto.setRationaleSummary(view.rationaleSummary());
dto.setDecidedAt(PublicResponseMapper.at(view.decidedAt()));
dto.setSourceQuestion(PublicResponseMapper.related(view.sourceQuestion()));
dto.setSourceCase(PublicResponseMapper.related(view.sourceCase()));
return dto;
}
public static ProjectRecordPage records(ProjectRecordPageView view) {
ProjectRecordPage dto = new ProjectRecordPage();
dto.setItems(PublicResponseMapper.relatedList(view.items()));
dto.setPage(PublicResponseMapper.page(view.page()));
return dto;
}
public static ProjectActivityPage activities(ProjectActivityPageView view) {
ProjectActivityPage dto = new ProjectActivityPage();
dto.setItems(PublicResponseMapper.map(view.items(), ProjectResponseMapper::activity));
dto.setPage(PublicResponseMapper.page(view.page()));
return dto;
}
private static ProjectActivityItem activity(ProjectActivityItemView view) {
ProjectActivityItem dto = new ProjectActivityItem();
dto.setType(view.type());
dto.setTitle(view.title());
dto.setSummary(view.summary());
dto.setOccurredAt(PublicResponseMapper.at(view.occurredAt()));
dto.setRelatedPath(view.relatedPath());
return dto;
}
}
@@ -0,0 +1,142 @@
package dev.caskeleton.adapter.inbound.web.techlog.publicapi.mapper;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.AssetReference;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ContactLink;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.LatestEntry;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.PageMetadata;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ProjectSummary;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.RelatedEntry;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.TagSummary;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.TopicSummary;
import dev.caskeleton.application.techlog.publicsite.model.AssetReferenceView;
import dev.caskeleton.application.techlog.publicsite.model.ContactLinkView;
import dev.caskeleton.application.techlog.publicsite.model.LatestEntryView;
import dev.caskeleton.application.techlog.publicsite.model.PageMetadataView;
import dev.caskeleton.application.techlog.publicsite.model.ProjectSummaryView;
import dev.caskeleton.application.techlog.publicsite.model.RelatedEntryView;
import dev.caskeleton.application.techlog.publicsite.model.TagSummaryView;
import dev.caskeleton.application.techlog.publicsite.model.TopicSummaryView;
import java.net.URI;
import java.time.Instant;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.List;
import java.util.function.Function;
/** 여러 응답이 함께 쓰는 조각의 매핑. */
public final class PublicResponseMapper {
private PublicResponseMapper() {}
public static OffsetDateTime at(Instant instant) {
return instant == null ? null : instant.atOffset(ZoneOffset.UTC);
}
public static TopicSummary topic(TopicSummaryView view) {
if (view == null) {
return null;
}
TopicSummary dto = new TopicSummary();
dto.setName(view.name());
dto.setSlug(view.slug());
return dto;
}
public static TagSummary tag(TagSummaryView view) {
TagSummary dto = new TagSummary();
dto.setName(view.name());
dto.setSlug(view.slug());
return dto;
}
public static ProjectSummary project(ProjectSummaryView view) {
if (view == null) {
return null;
}
ProjectSummary dto = new ProjectSummary();
dto.setName(view.name());
dto.setSlug(view.slug());
dto.setPath(view.path());
return dto;
}
public static RelatedEntry related(RelatedEntryView view) {
if (view == null) {
return null;
}
RelatedEntry dto = new RelatedEntry();
dto.setType(RelatedEntry.TypeEnum.fromValue(view.type()));
dto.setTitle(view.title());
dto.setSummary(view.summary());
dto.setPath(view.path());
return dto;
}
public static AssetReference asset(AssetReferenceView view) {
if (view == null) {
return null;
}
AssetReference dto = new AssetReference();
dto.setAssetId(view.assetId());
dto.setUrl(view.url());
dto.setAltText(view.altText());
dto.setWidth(view.width());
dto.setHeight(view.height());
dto.setContentType(view.contentType());
return dto;
}
public static ContactLink contact(ContactLinkView view) {
ContactLink dto = new ContactLink();
dto.setType(view.type());
dto.setLabel(view.label());
dto.setUrl(uri(view.url()));
return dto;
}
public static LatestEntry latest(LatestEntryView view) {
LatestEntry dto = new LatestEntry();
dto.setEntryType(LatestEntry.EntryTypeEnum.fromValue(view.entryType()));
dto.setTitle(view.title());
dto.setSummary(view.summary());
dto.setPath(view.path());
dto.setPrimaryTopic(topic(view.primaryTopic()));
dto.setPrimaryProject(project(view.primaryProject()));
dto.setPublishedAt(at(view.publishedAt()));
return dto;
}
public static PageMetadata page(PageMetadataView view) {
PageMetadata dto = new PageMetadata();
dto.setNumber(view.number());
dto.setSize(view.size());
dto.setTotalElements(view.totalElements());
dto.setTotalPages(view.totalPages());
dto.setHasPrevious(view.hasPrevious());
dto.setHasNext(view.hasNext());
return dto;
}
/**
* 계약이 {@code format: uri} 로 선언한 자리. 저장된 값이 URI 로 파싱되지 않으면 그 링크를 내보내지 않는다 — 깨진 주소를 넣는 것보다 없는 편이
* 낫고, 소비자는 이 필드가 optional 임을 안다.
*/
static URI uri(String value) {
if (value == null || value.isBlank()) {
return null;
}
try {
return URI.create(value);
} catch (IllegalArgumentException e) {
return null;
}
}
public static <S, T> List<T> map(List<S> source, Function<S, T> mapper) {
return source == null ? List.of() : source.stream().map(mapper).toList();
}
public static List<RelatedEntry> relatedList(List<RelatedEntryView> views) {
return map(views, PublicResponseMapper::related);
}
}
@@ -0,0 +1,48 @@
package dev.caskeleton.adapter.inbound.web.techlog.publicapi.mapper;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ReleaseDetailResponse;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ReleaseListItem;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ReleaseListResponse;
import dev.caskeleton.application.techlog.publicsite.model.ReleaseDetailView;
import dev.caskeleton.application.techlog.publicsite.model.ReleaseListItemView;
import java.util.List;
/** {@code listPublicReleases} / {@code getPublicRelease} 의 응답 조립. */
public final class ReleaseResponseMapper {
private ReleaseResponseMapper() {}
public static ReleaseListResponse list(List<ReleaseListItemView> views) {
ReleaseListResponse dto = new ReleaseListResponse();
dto.setItems(PublicResponseMapper.map(views, ReleaseResponseMapper::item));
return dto;
}
private static ReleaseListItem item(ReleaseListItemView view) {
ReleaseListItem dto = new ReleaseListItem();
dto.setVersion(view.version());
dto.setTitle(view.title());
dto.setSummary(view.summary());
dto.setReleasedOn(view.releasedOn());
dto.setChangeTypes(view.changeTypes());
dto.setPath(view.path());
return dto;
}
public static ReleaseDetailResponse detail(ReleaseDetailView view) {
ReleaseDetailResponse dto = new ReleaseDetailResponse();
dto.setVersion(view.version());
dto.setTitle(view.title());
dto.setSummary(view.summary());
dto.setReleasedOn(view.releasedOn());
dto.setChangeTypes(view.changeTypes());
dto.setReasonMarkdown(view.reasonMarkdown());
dto.setChangesMarkdown(view.changesMarkdown());
dto.setUserImpactMarkdown(view.userImpactMarkdown());
dto.setImplementationImpactMarkdown(view.implementationImpactMarkdown());
dto.setVerificationMarkdown(view.verificationMarkdown());
dto.setKnownLimitationsMarkdown(view.knownLimitationsMarkdown());
dto.setRelatedRecords(PublicResponseMapper.relatedList(view.relatedRecords()));
return dto;
}
}
@@ -0,0 +1,145 @@
package dev.caskeleton.adapter.inbound.web.techlog.publicapi.mapper;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.CurrentWorkFocus;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.HomeResponse;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.HomeResponseFocus;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.OpenQuestionFocus;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ProfileResponse;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ProfileResponsePosition;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ProfileResponseTerritoriesInner;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ProfileResponseTrajectoryInner;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ProfileResponseWorkingModelInner;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.RecentDecisionFocus;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.SiteResponse;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.SiteResponseBrand;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.SiteResponseOperator;
import dev.caskeleton.application.techlog.publicsite.model.HomeFocusView;
import dev.caskeleton.application.techlog.publicsite.model.HomeView;
import dev.caskeleton.application.techlog.publicsite.model.ProfileView;
import dev.caskeleton.application.techlog.publicsite.model.SiteView;
/** {@code getPublicSite} / {@code getPublicHome} / {@code getPublicProfile} 의 응답 조립. */
public final class SiteResponseMapper {
private SiteResponseMapper() {}
public static SiteResponse site(SiteView view) {
SiteResponseBrand brand = new SiteResponseBrand();
brand.setTitle(view.brandTitle());
brand.setIdentityStatement(view.identityStatement());
SiteResponseOperator operator = new SiteResponseOperator();
operator.setDisplayName(view.operatorDisplayName());
operator.setShortIdentity(view.operatorShortIdentity());
operator.setAvatar(PublicResponseMapper.asset(view.operatorAvatar()));
operator.setProfilePath(view.operatorProfilePath());
SiteResponse dto = new SiteResponse();
dto.setBrand(brand);
dto.setOperator(operator);
dto.setContacts(PublicResponseMapper.map(view.contacts(), PublicResponseMapper::contact));
return dto;
}
public static HomeResponse home(HomeView view) {
HomeResponse dto = new HomeResponse();
dto.setFocus(focus(view.focus()));
dto.setLatestEntries(
PublicResponseMapper.map(view.latestEntries(), PublicResponseMapper::latest));
return dto;
}
private static HomeResponseFocus focus(HomeFocusView view) {
HomeResponseFocus dto = new HomeResponseFocus();
dto.setDefaultType(HomeResponseFocus.DefaultTypeEnum.fromValue(view.defaultType()));
dto.setCurrentWork(currentWork(view.currentWork()));
dto.setOpenQuestion(openQuestion(view.openQuestion()));
dto.setRecentDecision(recentDecision(view.recentDecision()));
return dto;
}
private static CurrentWorkFocus currentWork(HomeFocusView.CurrentWork view) {
if (view == null) {
return null;
}
CurrentWorkFocus dto = new CurrentWorkFocus();
dto.setProjectName(view.projectName());
dto.setProjectPath(view.projectPath());
dto.setPurpose(view.purpose());
dto.setPhase(CurrentWorkFocus.PhaseEnum.fromValue(view.phase()));
dto.setCurrentObjective(view.currentObjective());
dto.setNextStep(view.nextStep());
dto.setUpdatedAt(PublicResponseMapper.at(view.updatedAt()));
return dto;
}
private static OpenQuestionFocus openQuestion(HomeFocusView.OpenQuestion view) {
if (view == null) {
return null;
}
OpenQuestionFocus dto = new OpenQuestionFocus();
dto.setQuestion(view.question());
dto.setQuestionPath(view.questionPath());
dto.setSummary(view.summary());
dto.setKnownFacts(view.knownFacts());
dto.setUnresolvedPoints(view.unresolvedPoints());
dto.setNextVerification(view.nextVerification());
dto.setUpdatedAt(PublicResponseMapper.at(view.updatedAt()));
return dto;
}
private static RecentDecisionFocus recentDecision(HomeFocusView.RecentDecision view) {
if (view == null) {
return null;
}
RecentDecisionFocus dto = new RecentDecisionFocus();
dto.setStatement(view.statement());
dto.setDecisionPath(view.decisionPath());
dto.setRationale(view.rationale());
dto.setConsequences(view.consequences());
dto.setDecidedAt(PublicResponseMapper.at(view.decidedAt()));
return dto;
}
public static ProfileResponse profile(ProfileView view) {
ProfileResponsePosition position = new ProfileResponsePosition();
position.setHeadline(view.headline());
position.setDescription(view.description());
ProfileResponse dto = new ProfileResponse();
dto.setPosition(position);
dto.setWorkingModel(
PublicResponseMapper.map(view.workingModel(), SiteResponseMapper::workingModel));
dto.setTerritories(PublicResponseMapper.map(view.territories(), SiteResponseMapper::territory));
dto.setSelectedEvidence(PublicResponseMapper.relatedList(view.selectedEvidence()));
dto.setTrajectory(PublicResponseMapper.map(view.trajectory(), SiteResponseMapper::trajectory));
dto.setContacts(PublicResponseMapper.map(view.contacts(), PublicResponseMapper::contact));
return dto;
}
private static ProfileResponseWorkingModelInner workingModel(ProfileView.NamedDescription view) {
ProfileResponseWorkingModelInner dto = new ProfileResponseWorkingModelInner();
dto.setName(view.name());
dto.setDescription(view.description());
return dto;
}
private static ProfileResponseTerritoriesInner territory(ProfileView.Territory view) {
ProfileResponseTerritoriesInner dto = new ProfileResponseTerritoriesInner();
dto.setName(view.name());
dto.setCurrentQuestion(view.currentQuestion());
dto.setTopicPath(view.topicPath());
return dto;
}
/**
* {@code trajectory} 의 계약 필드는 {@code title} 인데 view 는 {@code workingModel} 과 같은 {@code
* NamedDescription} 을 재사용한다 — 두 목록이 도메인적으로 같은 모양이라 record 를 나누지 않았고, 이름 차이는 여기서 흡수한다.
*/
private static ProfileResponseTrajectoryInner trajectory(ProfileView.NamedDescription view) {
ProfileResponseTrajectoryInner dto = new ProfileResponseTrajectoryInner();
dto.setTitle(view.name());
dto.setDescription(view.description());
return dto;
}
}
@@ -0,0 +1,48 @@
package dev.caskeleton.adapter.inbound.web.techlog.publicapi.mapper;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.TopicDetailResponse;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.TopicDetailResponseTopic;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.TopicListItem;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.TopicListResponse;
import dev.caskeleton.application.techlog.publicsite.model.TopicDetailView;
import dev.caskeleton.application.techlog.publicsite.model.TopicListItemView;
import java.util.List;
/** {@code listPublicTopics} / {@code getPublicTopic} 의 응답 조립. */
public final class TopicResponseMapper {
private TopicResponseMapper() {}
public static TopicListResponse list(List<TopicListItemView> views) {
TopicListResponse dto = new TopicListResponse();
dto.setItems(PublicResponseMapper.map(views, TopicResponseMapper::item));
return dto;
}
private static TopicListItem item(TopicListItemView view) {
TopicListItem dto = new TopicListItem();
dto.setName(view.name());
dto.setSlug(view.slug());
dto.setDescription(view.description());
dto.setRecordCount(view.recordCount());
return dto;
}
public static TopicDetailResponse detail(TopicDetailView view) {
TopicDetailResponseTopic topic = new TopicDetailResponseTopic();
topic.setName(view.name());
topic.setSlug(view.slug());
topic.setDescription(view.description());
topic.setScope(view.scope());
TopicDetailResponse dto = new TopicDetailResponse();
dto.setTopic(topic);
dto.setFeaturedReference(PublicResponseMapper.related(view.featuredReference()));
dto.setFeaturedCases(PublicResponseMapper.relatedList(view.featuredCases()));
dto.setActiveQuestions(PublicResponseMapper.relatedList(view.activeQuestions()));
dto.setRelatedProjects(PublicResponseMapper.relatedList(view.relatedProjects()));
dto.setLatestRecords(
PublicResponseMapper.map(view.latestRecords(), PublicResponseMapper::latest));
return dto;
}
}
@@ -0,0 +1,220 @@
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.Asset;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.AssetDetail;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.AssetKind;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.AssetManagementStatus;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.AssetPage;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.UpdateAssetCommand;
import dev.caskeleton.adapter.inbound.web.techlog.studio.mapper.StudioDetailMapper;
import dev.caskeleton.adapter.inbound.web.techlog.studio.support.StudioCursors;
import dev.caskeleton.adapter.inbound.web.techlog.studio.support.StudioIdempotency;
import dev.caskeleton.adapter.inbound.web.techlog.studio.support.StudioPrincipals;
import dev.caskeleton.application.techlog.error.StudioError;
import dev.caskeleton.application.techlog.error.StudioException;
import dev.caskeleton.application.techlog.studio.command.DeleteAssetCommand;
import dev.caskeleton.application.techlog.studio.command.UploadAssetCommand;
import dev.caskeleton.application.techlog.studio.model.AssetKindView;
import dev.caskeleton.application.techlog.studio.model.AssetManagementStatusView;
import dev.caskeleton.application.techlog.studio.query.DocumentCursorPosition;
import dev.caskeleton.application.techlog.studio.query.GetAssetQuery;
import dev.caskeleton.application.techlog.studio.query.ListAssetsQuery;
import dev.caskeleton.application.techlog.studio.service.DeleteStudioAssetUseCase;
import dev.caskeleton.application.techlog.studio.service.GetStudioAssetUseCase;
import dev.caskeleton.application.techlog.studio.service.ListStudioAssetsUseCase;
import dev.caskeleton.application.techlog.studio.service.UpdateStudioAssetUseCase;
import dev.caskeleton.application.techlog.studio.service.UploadStudioAssetUseCase;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import java.io.IOException;
import java.util.UUID;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
/**
* 계약 {@code studio-v1.yaml}의 Assets 다섯 operation.
*
* <p>메서드 이름이 곧 {@code operationId}다 — {@code StudioContractDriftTest}가 대조한다.
*/
@RestController
public class StudioAssetController {
private final ListStudioAssetsUseCase listAssets;
private final UploadStudioAssetUseCase uploadAsset;
private final GetStudioAssetUseCase getAsset;
private final UpdateStudioAssetUseCase updateAsset;
private final DeleteStudioAssetUseCase deleteAsset;
private final StudioDetailMapper detailMapper;
private final StudioCursors cursors;
private final StudioIdempotency idempotency;
public StudioAssetController(
ListStudioAssetsUseCase listAssets,
UploadStudioAssetUseCase uploadAsset,
GetStudioAssetUseCase getAsset,
UpdateStudioAssetUseCase updateAsset,
DeleteStudioAssetUseCase deleteAsset,
StudioDetailMapper detailMapper,
StudioCursors cursors,
StudioIdempotency idempotency) {
this.listAssets = listAssets;
this.uploadAsset = uploadAsset;
this.getAsset = getAsset;
this.updateAsset = updateAsset;
this.deleteAsset = deleteAsset;
this.detailMapper = detailMapper;
this.cursors = cursors;
this.idempotency = idempotency;
}
@GetMapping("/v1/studio/assets")
public AssetPage listStudioAssets(
@RequestParam(value = "q", required = false) String query,
@RequestParam(value = "kind", required = false) AssetKind kind,
@RequestParam(value = "managementStatus", required = false) AssetManagementStatus status,
@RequestParam(value = "cursor", required = false) String cursor,
@RequestParam(value = "limit", defaultValue = "20") int limit) {
String fingerprint =
StudioCursors.fingerprint(
query,
kind == null ? null : kind.getValue(),
status == null ? null : status.getValue());
DocumentCursorPosition position =
cursor == null || cursor.isBlank() ? null : cursors.decode(cursor, fingerprint, false);
var page =
listAssets.handle(
new ListAssetsQuery(
query,
kind == null ? null : AssetKindView.valueOf(kind.getValue()),
status == null ? null : AssetManagementStatusView.valueOf(status.getValue()),
position == null ? null : position.updatedAt(),
position == null ? null : position.id(),
limit));
AssetPage body = detailMapper.toApi(page);
body.setNextCursor(
page.nextCursor() == null ? null : cursors.encode(page.nextCursor(), fingerprint));
return body;
}
@PostMapping(value = "/v1/studio/assets", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<Asset> uploadStudioAsset(
@RequestParam("file") MultipartFile file,
@RequestParam("kind") AssetKind kind,
@RequestParam(value = "altText", required = false) String altText,
@RequestParam(value = "decorative", defaultValue = "false") boolean decorative,
@AuthenticationPrincipal AuthenticatedPrincipal principal,
HttpServletRequest request) {
byte[] content;
try {
content = file.getBytes();
} catch (IOException e) {
throw StudioException.of(
StudioError.REQUEST_VALIDATION_FAILED, "the uploaded file could not be read");
}
StudioIdempotency.Outcome<Asset> outcome =
idempotency.run(
request,
"uploadStudioAsset",
// 바이트 전체가 아니라 파일명·크기·종류로 지문을 만든다 — 20MB 를 다시 직렬화해 해시하면
// 업로드마다 그만큼을 한 번 더 읽고 쓰는 셈이 된다.
java.util.List.of(
String.valueOf(file.getOriginalFilename()),
String.valueOf(file.getSize()),
kind.getValue()),
Asset.class,
() ->
detailMapper.toApi(
uploadAsset.handle(
new UploadAssetCommand(
file.getOriginalFilename(),
file.getContentType(),
content,
AssetKindView.valueOf(kind.getValue()),
altText,
decorative,
StudioPrincipals.require(principal)))));
return ResponseEntity.status(HttpStatus.CREATED)
.header(StudioIdempotency.IDEMPOTENCY_REPLAYED, Boolean.toString(outcome.replayed()))
.body(outcome.result());
}
@GetMapping("/v1/studio/assets/{assetId}")
public AssetDetail getStudioAsset(@PathVariable("assetId") UUID assetId) {
return detailMapper.toApi(getAsset.handle(new GetAssetQuery(assetId)));
}
@PutMapping("/v1/studio/assets/{assetId}")
public ResponseEntity<Asset> updateStudioAsset(
@PathVariable("assetId") UUID assetId,
@Valid @RequestBody UpdateAssetCommand command,
@AuthenticationPrincipal AuthenticatedPrincipal principal,
HttpServletRequest request) {
StudioIdempotency.Outcome<Asset> outcome =
idempotency.run(
request,
"updateStudioAsset",
command,
Asset.class,
() ->
detailMapper.toApi(
updateAsset.handle(
new dev.caskeleton.application.techlog.studio.command.UpdateAssetCommand(
assetId,
command.getExpectedVersion() == null
? 0L
: command.getExpectedVersion(),
command.getKind() == null
? null
: AssetKindView.valueOf(command.getKind().getValue()),
command.getAltText(),
command.getAltText() != null,
command.getDecorative(),
command.getManagementStatus() == null
? null
: AssetManagementStatusView.valueOf(
command.getManagementStatus().getValue()),
StudioPrincipals.require(principal)))));
return ResponseEntity.ok()
.header(StudioIdempotency.IDEMPOTENCY_REPLAYED, Boolean.toString(outcome.replayed()))
.body(outcome.result());
}
@DeleteMapping("/v1/studio/assets/{assetId}")
public ResponseEntity<Void> deleteStudioAsset(
@PathVariable("assetId") UUID assetId,
@AuthenticationPrincipal AuthenticatedPrincipal principal,
HttpServletRequest request) {
idempotency.run(
request,
"deleteStudioAsset",
assetId.toString(),
Void.class,
() -> {
deleteAsset.handle(new DeleteAssetCommand(assetId, StudioPrincipals.require(principal)));
return null;
});
// 계약은 204 다. 본문이 없으므로 EnvelopeBodyAdvice 도 감쌀 것이 없다.
return ResponseEntity.noContent().build();
}
}
@@ -0,0 +1,65 @@
package dev.caskeleton.adapter.inbound.web.techlog.studio.controller;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.CatalogEntry;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.CatalogPage;
import dev.caskeleton.application.techlog.studio.query.CatalogEntryType;
import dev.caskeleton.application.techlog.studio.query.CatalogEntryView;
import dev.caskeleton.application.techlog.studio.query.CatalogPageView;
import dev.caskeleton.application.techlog.studio.query.ListCatalogQuery;
import dev.caskeleton.application.techlog.studio.service.ListCatalogUseCase;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* 반환값을 Envelope로 감싸지 않는다 — EnvelopeBodyAdvice가 감싼다.
*
* <p>{@code CatalogEntryType}은 application({@code
* dev.caskeleton.application.techlog.studio.query})과 웹 계약 생성 DTO({@code
* dev.caskeleton.adapter.inbound.web.techlog.studio.api.model})에 같은 이름으로 각각 존재한다 — 한 파일에서 둘 다 단일 타입
* import로 쓰면 컴파일이 깨진다. 이 컨트롤러는 application 쪽을 단일 import로 쓰고(요청 파라미터·use case 입력), 웹 계약 쪽은 {@link
* #toApi}에서 FQN으로만 참조한다(응답 DTO 조립).
*/
@RestController
public class StudioCatalogController {
private final ListCatalogUseCase listCatalog;
public StudioCatalogController(ListCatalogUseCase listCatalog) {
this.listCatalog = listCatalog;
}
/**
* 경로에 {@code /api} 를 쓰지 않는다 — {@code PresentationWebConfig} 가 {@code
* ca-skeleton.presentation.api-base-path}("/api") 를 모든 컨트롤러 매핑에 붙인다. 이 저장소의 다른 컨트롤러들(healthcheck,
* /v1/uploads, /v1/files)과 같은 규칙이며, 여기에 {@code /api} 를 다시 쓰면 실제 경로가 {@code /api/api/v1/...} 로 밀려
* 계약(studio-v1.yaml, {@code servers: "/"})이 선언한 주소에서 사라진다.
*/
@GetMapping("/v1/studio/catalog")
public CatalogPage listStudioCatalog(
@RequestParam("type") CatalogEntryType type,
@RequestParam(value = "q", required = false) String q,
@RequestParam(value = "cursor", required = false) String cursor,
@RequestParam(value = "limit", defaultValue = "20") int limit) {
CatalogPageView page = listCatalog.handle(new ListCatalogQuery(type, q, cursor, limit));
CatalogPage body = new CatalogPage();
body.setItems(page.items().stream().map(StudioCatalogController::toApi).toList());
body.setNextCursor(page.nextCursor());
return body;
}
private static CatalogEntry toApi(CatalogEntryView view) {
CatalogEntry entry = new CatalogEntry();
entry.setId(view.id());
entry.setType(
dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.CatalogEntryType.fromValue(
view.type().name()));
entry.setLabel(view.label());
entry.setDependencyRevision(view.dependencyRevision());
if (view.kind() != null) {
entry.setKind(CatalogEntry.KindEnum.fromValue(view.kind()));
}
entry.setPublicPath(view.publicPath());
return entry;
}
}
@@ -0,0 +1,226 @@
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.DocumentPage;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.NextAction;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.PublicationStatus;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.RecordKind;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.SaveDocumentCommand;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.WorkingCopy;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.WorkingCopyDetail;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.WorkingCopyInput;
import dev.caskeleton.adapter.inbound.web.techlog.studio.mapper.StudioDetailMapper;
import dev.caskeleton.adapter.inbound.web.techlog.studio.mapper.StudioRequestMapper;
import dev.caskeleton.adapter.inbound.web.techlog.studio.mapper.StudioResponseMapper;
import dev.caskeleton.adapter.inbound.web.techlog.studio.support.StudioCursors;
import dev.caskeleton.adapter.inbound.web.techlog.studio.support.StudioIdempotency;
import dev.caskeleton.adapter.inbound.web.techlog.studio.support.StudioPrincipals;
import dev.caskeleton.application.techlog.error.StudioError;
import dev.caskeleton.application.techlog.error.StudioException;
import dev.caskeleton.application.techlog.studio.command.CreateDocumentCommand;
import dev.caskeleton.application.techlog.studio.model.DocumentPageView;
import dev.caskeleton.application.techlog.studio.model.DocumentSort;
import dev.caskeleton.application.techlog.studio.model.PublicationStatusView;
import dev.caskeleton.application.techlog.studio.model.WorkingCopyDetailView;
import dev.caskeleton.application.techlog.studio.model.WorkingCopyView;
import dev.caskeleton.application.techlog.studio.query.DocumentCursorPosition;
import dev.caskeleton.application.techlog.studio.query.GetDocumentQuery;
import dev.caskeleton.application.techlog.studio.query.ListDocumentsQuery;
import dev.caskeleton.application.techlog.studio.service.CreateStudioDocumentUseCase;
import dev.caskeleton.application.techlog.studio.service.GetStudioDocumentUseCase;
import dev.caskeleton.application.techlog.studio.service.ListStudioDocumentsUseCase;
import dev.caskeleton.application.techlog.studio.service.SaveOutcome;
import dev.caskeleton.application.techlog.studio.service.SaveStudioDocumentUseCase;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.UUID;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* 계약 {@code studio-v1.yaml}의 Documents 네 operation.
*
* <p>메서드 이름이 곧 {@code operationId}다 — {@code StudioContractDriftTest}가 springdoc이 게시한 이름과 계약을 대조한다.
* 이름을 바꾸면 그 게이트가 빨간불이 된다.
*
* <p>반환값을 봉투로 감싸지 않는다 — {@code EnvelopeBodyAdvice}가 감싼다(ADR-006).
*
* <p>경로에 {@code /api}를 쓰지 않는다 — {@code PresentationWebConfig}가 {@code
* ca-skeleton.presentation.api-base-path}를 모든 매핑에 붙인다.
*/
@RestController
public class StudioDocumentController {
private final ListStudioDocumentsUseCase listDocuments;
private final CreateStudioDocumentUseCase createDocument;
private final GetStudioDocumentUseCase getDocument;
private final SaveStudioDocumentUseCase saveDocument;
private final StudioDetailMapper detailMapper;
private final StudioCursors cursors;
private final StudioIdempotency idempotency;
public StudioDocumentController(
ListStudioDocumentsUseCase listDocuments,
CreateStudioDocumentUseCase createDocument,
GetStudioDocumentUseCase getDocument,
SaveStudioDocumentUseCase saveDocument,
StudioDetailMapper detailMapper,
StudioCursors cursors,
StudioIdempotency idempotency) {
this.listDocuments = listDocuments;
this.createDocument = createDocument;
this.getDocument = getDocument;
this.saveDocument = saveDocument;
this.detailMapper = detailMapper;
this.cursors = cursors;
this.idempotency = idempotency;
}
@GetMapping("/v1/studio/documents")
public DocumentPage listStudioDocuments(
@RequestParam(value = "q", required = false) String query,
@RequestParam(value = "kind", required = false) RecordKind kind,
@RequestParam(value = "publicationStatus", required = false)
PublicationStatus publicationStatus,
@RequestParam(value = "nextAction", required = false) NextAction nextAction,
@RequestParam(value = "projectId", required = false) UUID projectId,
@RequestParam(value = "sort", defaultValue = "UPDATED_DESC") String sort,
@RequestParam(value = "cursor", required = false) String cursor,
@RequestParam(value = "limit", defaultValue = "20") int limit) {
DocumentSort documentSort = documentSort(sort);
// 지문에 정렬까지 넣는다 — 정렬만 바꾸고 커서를 재사용하면 커서가 가리키는 키의 의미가 달라진다.
String fingerprint =
StudioCursors.fingerprint(
query,
kind == null ? null : kind.getValue(),
publicationStatus == null ? null : publicationStatus.getValue(),
nextAction == null ? null : nextAction.getValue(),
projectId == null ? null : projectId.toString(),
documentSort.name());
DocumentCursorPosition position =
cursor == null || cursor.isBlank()
? null
: cursors.decode(cursor, fingerprint, documentSort == DocumentSort.TITLE_ASC);
DocumentPageView page =
listDocuments.handle(
new ListDocumentsQuery(
query,
kind == null
? null
: dev.caskeleton.application.techlog.studio.model.RecordKind.valueOf(
kind.getValue()),
publicationStatus == null
? null
: PublicationStatusView.valueOf(publicationStatus.getValue()),
nextAction == null
? null
: dev.caskeleton.application.techlog.studio.model.NextAction.valueOf(
nextAction.getValue()),
projectId,
documentSort,
position,
limit));
DocumentPage body = StudioResponseMapper.toApi(page);
// 어댑터가 준 것은 다음 쪽의 시작 위치일 뿐이다. 클라이언트에 나가는 것은 서명된 opaque 값이어야 한다.
body.setNextCursor(
page.nextCursor() == null ? null : cursors.encode(page.nextCursor(), fingerprint));
return body;
}
@PostMapping("/v1/studio/documents")
public ResponseEntity<WorkingCopy> createStudioDocument(
@Valid @RequestBody WorkingCopyInput document,
@AuthenticationPrincipal AuthenticatedPrincipal principal,
HttpServletRequest request) {
StudioIdempotency.Outcome<WorkingCopy> outcome =
idempotency.run(
request,
"createStudioDocument",
document,
WorkingCopy.class,
() -> {
WorkingCopyView created =
createDocument.handle(
new CreateDocumentCommand(
StudioRequestMapper.toApplication(document),
StudioPrincipals.require(principal)));
return StudioResponseMapper.toApi(created);
});
return ResponseEntity.status(HttpStatus.CREATED)
.header(StudioIdempotency.IDEMPOTENCY_REPLAYED, Boolean.toString(outcome.replayed()))
.body(outcome.result());
}
@GetMapping("/v1/studio/documents/{documentId}")
public WorkingCopyDetail getStudioDocument(@PathVariable("documentId") UUID documentId) {
return detailMapper.toApi(getDocument.handle(new GetDocumentQuery(documentId)));
}
@PutMapping("/v1/studio/documents/{documentId}")
public ResponseEntity<WorkingCopyDetail> saveStudioDocument(
@PathVariable("documentId") UUID documentId,
@Valid @RequestBody SaveDocumentCommand command,
@AuthenticationPrincipal AuthenticatedPrincipal principal,
HttpServletRequest request) {
StudioIdempotency.Outcome<WorkingCopyDetail> outcome =
idempotency.run(
request,
"saveStudioDocument",
command,
WorkingCopyDetail.class,
() -> {
SaveOutcome saved =
saveDocument.handle(
new dev.caskeleton.application.techlog.studio.command.SaveDocumentCommand(
documentId,
command.getExpectedVersion() == null ? 0 : command.getExpectedVersion(),
StudioRequestMapper.toApplication(command.getDocument()),
StudioPrincipals.require(principal)));
return switch (saved) {
case SaveOutcome.Saved value -> detailMapper.toApi(value.detail());
case SaveOutcome.VersionConflict value -> throw versionConflict(value.latest());
};
});
return ResponseEntity.ok()
.header(StudioIdempotency.IDEMPOTENCY_REPLAYED, Boolean.toString(outcome.replayed()))
.body(outcome.result());
}
/**
* 계약의 {@code VersionConflictDetails}는 {@code latestDocument}로 현재 상태 전체를 함께 준다 — 클라이언트가 다시 조회하지
* 않고도 충돌 화면을 그릴 수 있어야 한다.
*/
private StudioException versionConflict(WorkingCopyDetailView latest) {
Map<String, Object> details = new LinkedHashMap<>();
details.put("latestDocument", detailMapper.toApi(latest));
return StudioException.withDetails(
StudioError.VERSION_CONFLICT, "expectedVersion does not match the stored version", details);
}
private static DocumentSort documentSort(String sort) {
try {
return DocumentSort.valueOf(sort);
} catch (IllegalArgumentException e) {
throw StudioException.of(
StudioError.REQUEST_VALIDATION_FAILED,
"sort must be one of UPDATED_DESC, UPDATED_ASC, TITLE_ASC");
}
}
}
@@ -0,0 +1,121 @@
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.CreatePreviewCommand;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.PreviewDetail;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.PublicPreview;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.ValidateDocumentCommand;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.ValidationReport;
import dev.caskeleton.adapter.inbound.web.techlog.studio.mapper.StudioDetailMapper;
import dev.caskeleton.adapter.inbound.web.techlog.studio.support.StudioIdempotency;
import dev.caskeleton.adapter.inbound.web.techlog.studio.support.StudioPrincipals;
import dev.caskeleton.application.techlog.studio.query.GetPreviewQuery;
import dev.caskeleton.application.techlog.studio.service.CreateStudioPreviewUseCase;
import dev.caskeleton.application.techlog.studio.service.GetCurrentStudioPreviewUseCase;
import dev.caskeleton.application.techlog.studio.service.ValidateStudioDocumentUseCase;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import java.util.UUID;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
/**
* 계약 {@code studio-v1.yaml}의 Validation / Preview 세 operation.
*
* <p>메서드 이름이 곧 {@code operationId}다 — {@code StudioContractDriftTest}가 대조한다.
*/
@RestController
public class StudioPreviewController {
private final ValidateStudioDocumentUseCase validateDocument;
private final CreateStudioPreviewUseCase createPreview;
private final GetCurrentStudioPreviewUseCase getPreview;
private final StudioDetailMapper detailMapper;
private final StudioIdempotency idempotency;
public StudioPreviewController(
ValidateStudioDocumentUseCase validateDocument,
CreateStudioPreviewUseCase createPreview,
GetCurrentStudioPreviewUseCase getPreview,
StudioDetailMapper detailMapper,
StudioIdempotency idempotency) {
this.validateDocument = validateDocument;
this.createPreview = createPreview;
this.getPreview = getPreview;
this.detailMapper = detailMapper;
this.idempotency = idempotency;
}
@PostMapping("/v1/studio/documents/{documentId}/validate")
public ResponseEntity<ValidationReport> validateStudioDocument(
@PathVariable("documentId") UUID documentId,
@Valid @RequestBody ValidateDocumentCommand command,
@AuthenticationPrincipal AuthenticatedPrincipal principal,
HttpServletRequest request) {
StudioIdempotency.Outcome<ValidationReport> outcome =
idempotency.run(
request,
"validateStudioDocument",
command,
ValidationReport.class,
() ->
detailMapper.toApi(
validateDocument.handle(
new dev.caskeleton.application.techlog.studio.command
.ValidateDocumentCommand(
documentId,
expectedVersion(command.getExpectedVersion()),
StudioPrincipals.require(principal)))));
return ResponseEntity.ok()
.header(StudioIdempotency.IDEMPOTENCY_REPLAYED, Boolean.toString(outcome.replayed()))
.body(outcome.result());
}
@GetMapping("/v1/studio/documents/{documentId}/preview")
public PreviewDetail getCurrentStudioPreview(@PathVariable("documentId") UUID documentId) {
return detailMapper.toApi(getPreview.handle(new GetPreviewQuery(documentId)));
}
@PostMapping("/v1/studio/documents/{documentId}/preview")
public ResponseEntity<PublicPreview> createStudioPreview(
@PathVariable("documentId") UUID documentId,
@Valid @RequestBody CreatePreviewCommand command,
@AuthenticationPrincipal AuthenticatedPrincipal principal,
HttpServletRequest request) {
StudioIdempotency.Outcome<PublicPreview> outcome =
idempotency.run(
request,
"createStudioPreview",
command,
PublicPreview.class,
() ->
detailMapper.toApi(
createPreview.handle(
new dev.caskeleton.application.techlog.studio.command.CreatePreviewCommand(
documentId,
expectedVersion(command.getExpectedVersion()),
command.getValidationId(),
StudioPrincipals.require(principal)))));
return ResponseEntity.status(HttpStatus.CREATED)
.header(StudioIdempotency.IDEMPOTENCY_REPLAYED, Boolean.toString(outcome.replayed()))
.body(outcome.result());
}
/**
* 계약상 required 지만 null 을 실어 보내는 클라이언트를 500 으로 떨어뜨리지 않는다 — 0 은 어떤 저장된 버전과도 일치하지 않아
* VERSION_CONFLICT 로 나간다.
*/
private static long expectedVersion(Integer value) {
return value == null ? 0L : value;
}
}
@@ -0,0 +1,177 @@
package dev.caskeleton.adapter.inbound.web.techlog.studio.controller;
import dev.caskeleton.adapter.inbound.web.auth.AuthenticatedPrincipal;
import dev.caskeleton.adapter.inbound.web.http.ApiHeaders;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.PublicationEventType;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.PublicationPage;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.PublicationSnapshot;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.PublishDocumentCommand;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.PublishResult;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.StudioDashboard;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.UnpublishCommand;
import dev.caskeleton.adapter.inbound.web.techlog.studio.mapper.StudioDetailMapper;
import dev.caskeleton.adapter.inbound.web.techlog.studio.support.StudioCursors;
import dev.caskeleton.adapter.inbound.web.techlog.studio.support.StudioIdempotency;
import dev.caskeleton.adapter.inbound.web.techlog.studio.support.StudioPrincipals;
import dev.caskeleton.application.techlog.studio.command.UnpublishPublicationCommand;
import dev.caskeleton.application.techlog.studio.model.PublicationEventTypeView;
import dev.caskeleton.application.techlog.studio.query.DocumentCursorPosition;
import dev.caskeleton.application.techlog.studio.query.GetDashboardQuery;
import dev.caskeleton.application.techlog.studio.query.GetSnapshotQuery;
import dev.caskeleton.application.techlog.studio.query.ListPublicationsQuery;
import dev.caskeleton.application.techlog.studio.service.GetStudioDashboardUseCase;
import dev.caskeleton.application.techlog.studio.service.GetStudioPublicationSnapshotUseCase;
import dev.caskeleton.application.techlog.studio.service.ListStudioPublicationsUseCase;
import dev.caskeleton.application.techlog.studio.service.PublishStudioDocumentUseCase;
import dev.caskeleton.application.techlog.studio.service.UnpublishStudioPublicationUseCase;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import java.util.List;
import java.util.UUID;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* 계약 {@code studio-v1.yaml}의 Publication / Dashboard 다섯 operation.
*
* <p>메서드 이름이 곧 {@code operationId}다 — {@code StudioContractDriftTest}가 대조한다.
*/
@RestController
public class StudioPublicationController {
private final PublishStudioDocumentUseCase publishDocument;
private final UnpublishStudioPublicationUseCase unpublishPublication;
private final ListStudioPublicationsUseCase listPublications;
private final GetStudioPublicationSnapshotUseCase getSnapshot;
private final GetStudioDashboardUseCase getDashboard;
private final StudioDetailMapper detailMapper;
private final StudioCursors cursors;
private final StudioIdempotency idempotency;
public StudioPublicationController(
PublishStudioDocumentUseCase publishDocument,
UnpublishStudioPublicationUseCase unpublishPublication,
ListStudioPublicationsUseCase listPublications,
GetStudioPublicationSnapshotUseCase getSnapshot,
GetStudioDashboardUseCase getDashboard,
StudioDetailMapper detailMapper,
StudioCursors cursors,
StudioIdempotency idempotency) {
this.publishDocument = publishDocument;
this.unpublishPublication = unpublishPublication;
this.listPublications = listPublications;
this.getSnapshot = getSnapshot;
this.getDashboard = getDashboard;
this.detailMapper = detailMapper;
this.cursors = cursors;
this.idempotency = idempotency;
}
@GetMapping("/v1/studio/dashboard")
public StudioDashboard getStudioDashboard() {
return detailMapper.toApi(getDashboard.handle(new GetDashboardQuery()));
}
@PostMapping("/v1/studio/documents/{documentId}/publish")
public ResponseEntity<PublishResult> publishStudioDocument(
@PathVariable("documentId") UUID documentId,
@Valid @RequestBody PublishDocumentCommand command,
@AuthenticationPrincipal AuthenticatedPrincipal principal,
HttpServletRequest request) {
// 게시 Event 에 최초 요청의 key 를 남긴다 — 재시도가 중복 Event 를 만들지 않았음을 이력에서
// 되짚을 수 있어야 한다(V7 publication_event.idempotency_key 주석).
String idempotencyKey = request.getHeader(ApiHeaders.IDEMPOTENCY_KEY);
StudioIdempotency.Outcome<PublishResult> outcome =
idempotency.run(
request,
"publishStudioDocument",
command,
PublishResult.class,
() ->
detailMapper.toApi(
publishDocument.handle(
new dev.caskeleton.application.techlog.studio.command
.PublishDocumentCommand(
documentId,
expectedVersion(command.getExpectedVersion()),
command.getValidationId(),
command.getPreviewId(),
List.copyOf(command.getAcknowledgedWarningCodes()),
idempotencyKey,
StudioPrincipals.require(principal)))));
return ResponseEntity.status(HttpStatus.CREATED)
.header(StudioIdempotency.IDEMPOTENCY_REPLAYED, Boolean.toString(outcome.replayed()))
.body(outcome.result());
}
@GetMapping("/v1/studio/publications")
public PublicationPage listStudioPublications(
@RequestParam(value = "type", required = false) PublicationEventType type,
@RequestParam(value = "cursor", required = false) String cursor,
@RequestParam(value = "limit", defaultValue = "20") int limit) {
String fingerprint = StudioCursors.fingerprint(type == null ? null : type.getValue());
DocumentCursorPosition position =
cursor == null || cursor.isBlank() ? null : cursors.decode(cursor, fingerprint, false);
var page =
listPublications.handle(
new ListPublicationsQuery(
type == null ? null : PublicationEventTypeView.valueOf(type.getValue()),
position == null ? null : position.updatedAt(),
position == null ? null : position.id(),
limit));
PublicationPage body = detailMapper.toApi(page);
body.setNextCursor(
page.nextCursor() == null ? null : cursors.encode(page.nextCursor(), fingerprint));
return body;
}
@PostMapping("/v1/studio/publications/{publicationId}/unpublish")
public ResponseEntity<PublishResult> unpublishStudioPublication(
@PathVariable("publicationId") UUID publicationId,
@Valid @RequestBody UnpublishCommand command,
@AuthenticationPrincipal AuthenticatedPrincipal principal,
HttpServletRequest request) {
StudioIdempotency.Outcome<PublishResult> outcome =
idempotency.run(
request,
"unpublishStudioPublication",
command,
PublishResult.class,
() ->
detailMapper.toApi(
unpublishPublication.handle(
new UnpublishPublicationCommand(
publicationId,
expectedVersion(command.getExpectedPublicationRevision()),
StudioPrincipals.require(principal)))));
return ResponseEntity.ok()
.header(StudioIdempotency.IDEMPOTENCY_REPLAYED, Boolean.toString(outcome.replayed()))
.body(outcome.result());
}
@GetMapping("/v1/studio/publications/{publicationEventId}/preview")
public PublicationSnapshot getStudioPublicationSnapshot(
@PathVariable("publicationEventId") UUID publicationEventId) {
return detailMapper.toApi(getSnapshot.handle(new GetSnapshotQuery(publicationEventId)));
}
/** 계약상 required 지만 null 을 실어 보내는 클라이언트를 500 으로 떨어뜨리지 않는다. */
private static long expectedVersion(Integer value) {
return value == null ? 0L : value;
}
}
@@ -0,0 +1,111 @@
package dev.caskeleton.adapter.inbound.web.techlog.studio.controller;
import dev.caskeleton.adapter.inbound.web.auth.AuthenticatedPrincipal;
import dev.caskeleton.adapter.inbound.web.settings.SecuritySettings;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.StudioSession;
import dev.caskeleton.application.techlog.error.StudioError;
import dev.caskeleton.application.techlog.error.StudioException;
import java.util.Set;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.web.csrf.CsrfToken;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* 세션은 순수 전송 상태다 — principal과 CSRF 토큰뿐이라 도메인 규칙이 없다. application use case를 끼우지 않는 이유이고,
* application-core는 Spring을 볼 수 없어 SecurityContext에 접근할 수도 없다.
*
* <p>반환값을 {@code Envelope}로 감싸지 않는다. {@code EnvelopeBodyAdvice}가 감싼다.
*
* <p><b>CSRF가 꺼진 auth-mode.</b> {@code SecurityConfig.filterChain}의 JWT 분기는 {@code csrf(csrf ->
* csrf.disable())}로 {@code CsrfConfigurer} 자체를 제거한다 — {@code CsrfFilter}가 돌지 않고 request attribute를
* 아무도 채우지 않는다. 그런데 Spring Security 7.0.0의 {@code CsrfTokenArgumentResolver}는
* {@code @EnableWebSecurity}만 있으면 무조건 등록되고, {@code resolveArgument}는 request attribute를 캐스팅만 할 뿐
* null 체크가 없다 — 그래서 CSRF가 꺼진 모드에서는 {@code csrfToken} 파라미터가 항상 {@code null}이다. Studio는 CSRF 없이 실제로
* 동작할 수 없으므로, 가짜 토큰을 지어내 200을 돌려주는 대신 {@link StudioError#STUDIO_UNAVAILABLE}(503)로 그 사실을 정직하게 보고한다
* — 세션 인프라가 갖춰지면(redis-session + 완전한 CSRF 배선) 코드 변경 없이 200으로 바뀐다. ({@code
* StudioSessionCsrfDisabledTest}가 이 경로를 고정한다.)
*/
@RestController
public class StudioSessionController {
/** studio-v1.yaml {@code StudioSession.csrfHeaderName}은 {@code const} — 이 값만 유효하다. */
private static final String CONTRACT_CSRF_HEADER_NAME = "X-CSRF-TOKEN";
/** studio-v1.yaml {@code StudioSession.roles.maxItems}. */
private static final int CONTRACT_MAX_ROLES = 20;
private final String csrfHeaderName;
/**
* {@code csrf-header-name}을 하드코드하지 않고 설정에서 읽되, 계약이 고정한 값과 다르면 부팅 시점에 즉시 실패한다 — 계약의 {@code const}와
* 설정이 갈라지는 건 배포 오류이지 런타임에 조용히 넘어갈 문제가 아니다.
*/
public StudioSessionController(SecuritySettings securitySettings) {
String configured = securitySettings.session().csrfHeaderName();
if (!CONTRACT_CSRF_HEADER_NAME.equals(configured)) {
throw new IllegalStateException(
"ca-skeleton.security.session.csrf-header-name must be \""
+ CONTRACT_CSRF_HEADER_NAME
+ "\" (studio-v1.yaml StudioSession.csrfHeaderName is a contract const) but was"
+ " configured as \""
+ configured
+ "\"");
}
this.csrfHeaderName = configured;
}
/**
* 경로에 {@code /api} 를 쓰지 않는다 — {@code PresentationWebConfig} 가 {@code
* ca-skeleton.presentation.api-base-path}("/api") 를 모든 컨트롤러 매핑에 붙인다. 이 저장소의 다른 컨트롤러들(healthcheck,
* /v1/uploads, /v1/files)과 같은 규칙이며, 여기에 {@code /api} 를 다시 쓰면 실제 경로가 {@code /api/api/v1/...} 로 밀려
* 계약(studio-v1.yaml, {@code servers: "/"})이 선언한 주소에서 사라진다.
*/
@GetMapping("/v1/studio/session")
public StudioSession getStudioSession(
@AuthenticationPrincipal AuthenticatedPrincipal principal, CsrfToken csrfToken) {
if (csrfToken == null) {
throw StudioException.of(
StudioError.STUDIO_UNAVAILABLE,
"CSRF token unavailable: CSRF protection is disabled for the active auth-mode");
}
Set<String> roles = Set.copyOf(principal.roles());
if (roles.size() > CONTRACT_MAX_ROLES) {
// 조용히 잘라내면 클라이언트가 실제 권한과 다른 role 집합을 받는다 — IdP 쪽 role 매핑이 잘못됐다는
// 신호를 숨기는 셈이라, 잘라내는 대신 실패시켜 드러낸다.
throw StudioException.of(
StudioError.STUDIO_UNAVAILABLE,
"principal role count "
+ roles.size()
+ " exceeds contract max "
+ CONTRACT_MAX_ROLES
+ " (studio-v1.yaml StudioSession.roles.maxItems)");
}
String displayName = displayNameOf(principal);
if (displayName == null || displayName.isBlank()) {
throw StudioException.of(
StudioError.STUDIO_UNAVAILABLE,
"principal has neither a usable email nor idpUserId; cannot satisfy"
+ " StudioSession.displayName minLength 1");
}
StudioSession session = new StudioSession();
session.setAuthenticated(true);
session.setDisplayName(displayName);
session.setRoles(roles);
session.setCsrfToken(csrfToken.getToken());
session.setCsrfHeaderName(csrfHeaderName);
return session;
}
/**
* `displayName`은 계약상 1자 이상이다. profile capability(identity 모듈)가 들어오기 전까지 email을 쓰고, 없으면 IdP
* subject로 대체한다. 둘 다 비어 있으면 {@code getStudioSession}이 실패시킨다(위 참조).
*/
private static String displayNameOf(AuthenticatedPrincipal principal) {
String email = principal.email();
return (email == null || email.isBlank()) ? principal.idpUserId() : email;
}
}
@@ -0,0 +1,262 @@
package dev.caskeleton.adapter.inbound.web.techlog.studio.mapper;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.Asset;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.AssetDetail;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.AssetKind;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.AssetManagementStatus;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.AssetPage;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.AssetUsage;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.DashboardTotals;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.NextAction;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.PreviewDetail;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.PublicPreview;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.PublicRenderModel;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.PublicationAction;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.PublicationAggregate;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.PublicationEvent;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.PublicationEventType;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.PublicationListItem;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.PublicationPage;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.PublicationSnapshot;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.PublishResult;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.RecordKind;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.StudioDashboard;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.ValidationIssue;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.ValidationReport;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.WorkingCopyDetail;
import dev.caskeleton.application.techlog.studio.model.AssetDetailView;
import dev.caskeleton.application.techlog.studio.model.AssetPageView;
import dev.caskeleton.application.techlog.studio.model.AssetView;
import dev.caskeleton.application.techlog.studio.model.DashboardView;
import dev.caskeleton.application.techlog.studio.model.PreviewDetailView;
import dev.caskeleton.application.techlog.studio.model.PublicPreviewView;
import dev.caskeleton.application.techlog.studio.model.PublicationAggregateView;
import dev.caskeleton.application.techlog.studio.model.PublicationEventView;
import dev.caskeleton.application.techlog.studio.model.PublicationListItemView;
import dev.caskeleton.application.techlog.studio.model.PublicationPageView;
import dev.caskeleton.application.techlog.studio.model.PublicationSnapshotView;
import dev.caskeleton.application.techlog.studio.model.PublishResultView;
import dev.caskeleton.application.techlog.studio.model.ValidationReportView;
import dev.caskeleton.application.techlog.studio.model.WorkingCopyDetailView;
import dev.caskeleton.shared.error.MappingException;
import org.springframework.stereotype.Component;
import tools.jackson.core.JacksonException;
import tools.jackson.databind.ObjectMapper;
/**
* {@code WorkingCopyDetail} 조립. {@code getStudioDocument}, {@code saveStudioDocument}, 그리고 낙관적 잠금
* 충돌의 {@code VersionConflictDetails.latestDocument}가 모두 이 결과를 쓴다.
*
* <p>Spring 컴포넌트인 이유는 하나뿐이다 — 미리보기의 {@code renderModel}이 DB에 문자열로 저장되어 있어 계약 DTO로 되살리려면 매퍼가 필요하다.
*/
@Component
public class StudioDetailMapper {
private final ObjectMapper objectMapper;
public StudioDetailMapper(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
public WorkingCopyDetail toApi(WorkingCopyDetailView view) {
WorkingCopyDetail detail = new WorkingCopyDetail();
detail.setDocument(StudioResponseMapper.toApi(view.document()));
detail.setCurrentValidation(toApi(view.currentValidation()));
detail.setLatestPreview(toApi(view.latestPreview()));
detail.setCurrentPublication(toApi(view.currentPublication()));
detail.setDependencyRevision(view.dependencyRevision());
detail.setNextAction(NextAction.fromValue(view.nextAction().name()));
return detail;
}
public ValidationReport toApi(ValidationReportView view) {
if (view == null) {
return null;
}
ValidationReport report = new ValidationReport();
report.setValidationId(view.validationId());
report.setDocumentId(view.documentId());
report.setValidatedVersion(Math.toIntExact(view.validatedVersion()));
report.setStatus(ValidationReport.StatusEnum.fromValue(view.status().name()));
report.setIssues(
view.issues().stream()
.map(
issue -> {
ValidationIssue dto = new ValidationIssue();
dto.setCode(issue.code());
dto.setSeverity(ValidationIssue.SeverityEnum.fromValue(issue.severity().name()));
dto.setPath(issue.path());
dto.setMessage(issue.message());
return dto;
})
.toList());
report.setValidatedAt(StudioResponseMapper.offsetDateTime(view.validatedAt()));
report.setValidUntil(StudioResponseMapper.offsetDateTime(view.validUntil()));
report.setDependencyRevision(view.dependencyRevision());
return report;
}
public PublicPreview toApi(PublicPreviewView view) {
if (view == null) {
return null;
}
PublicPreview preview = new PublicPreview();
preview.setPreviewId(view.previewId());
preview.setDocumentId(view.documentId());
preview.setPreviewVersion(Math.toIntExact(view.previewVersion()));
preview.setValidationId(view.validationId());
preview.setDependencyRevision(view.dependencyRevision());
preview.setCreatedAt(StudioResponseMapper.offsetDateTime(view.createdAt()));
preview.setExpiresAt(StudioResponseMapper.offsetDateTime(view.expiresAt()));
preview.setRenderModel(renderModel(view.renderModelJson()));
return preview;
}
public PublicationAggregate toApi(PublicationAggregateView view) {
if (view == null) {
return null;
}
PublicationAggregate aggregate = new PublicationAggregate();
aggregate.setPublicationId(view.publicationId());
aggregate.setDocumentId(view.documentId());
aggregate.setStatus(PublicationAggregate.StatusEnum.fromValue(view.status().name()));
aggregate.setPublishedVersion(Math.toIntExact(view.publishedVersion()));
aggregate.setPublicationRevision(Math.toIntExact(view.publicationRevision()));
aggregate.setLatestEventId(view.latestEventId());
aggregate.setPublicPath(view.publicPath());
aggregate.setUpdatedAt(StudioResponseMapper.offsetDateTime(view.updatedAt()));
return aggregate;
}
public PreviewDetail toApi(PreviewDetailView view) {
PreviewDetail detail = new PreviewDetail();
detail.setPreview(toApi(view.preview()));
detail.setState(PreviewDetail.StateEnum.fromValue(view.state().name()));
detail.setCurrentDocumentVersion(Math.toIntExact(view.currentDocumentVersion()));
detail.setCurrentValidationId(view.currentValidationId());
return detail;
}
public PublicationEvent toApi(PublicationEventView view) {
PublicationEvent event = new PublicationEvent();
event.setPublicationEventId(view.publicationEventId());
event.setPublicationId(view.publicationId());
event.setDocumentId(view.documentId());
event.setType(PublicationEventType.fromValue(view.type().name()));
event.setOccurredAt(StudioResponseMapper.offsetDateTime(view.occurredAt()));
event.setPublishedVersion(Math.toIntExact(view.publishedVersion()));
event.setSourcePublishedEventId(view.sourcePublishedEventId());
event.setSnapshotAvailable(view.snapshotAvailable());
return event;
}
public PublishResult toApi(PublishResultView view) {
PublishResult result = new PublishResult();
result.setPublication(toApi(view.publication()));
result.setEvent(toApi(view.event()));
return result;
}
public PublicationListItem toApi(PublicationListItemView view) {
PublicationListItem item = new PublicationListItem();
item.setEvent(toApi(view.event()));
item.setPublication(toApi(view.publication()));
item.setDocument(view.document() == null ? null : StudioResponseMapper.toApi(view.document()));
item.setAvailableActions(
view.availableActions().stream()
.map(action -> PublicationAction.fromValue(action.name()))
.collect(java.util.stream.Collectors.toCollection(java.util.LinkedHashSet::new)));
return item;
}
public PublicationPage toApi(PublicationPageView view) {
PublicationPage page = new PublicationPage();
page.setItems(view.items().stream().map(this::toApi).toList());
page.setNextCursor(view.nextCursor());
return page;
}
public PublicationSnapshot toApi(PublicationSnapshotView view) {
PublicationSnapshot snapshot = new PublicationSnapshot();
snapshot.setEvent(toApi(view.event()));
snapshot.setRenderModel(renderModel(view.renderModelJson()));
snapshot.setContentFormatVersion(view.contentFormatVersion());
snapshot.setRendererContractVersion(view.rendererContractVersion());
return snapshot;
}
public StudioDashboard toApi(DashboardView view) {
StudioDashboard dashboard = new StudioDashboard();
dashboard.setContinueWriting(
view.continueWriting().stream().map(StudioResponseMapper::toApi).toList());
dashboard.setReadyToPublish(
view.readyToPublish().stream().map(StudioResponseMapper::toApi).toList());
dashboard.setRecentPublications(view.recentPublications().stream().map(this::toApi).toList());
DashboardTotals totals = new DashboardTotals();
totals.setDocuments(view.totals().documents());
totals.setNeedsValidation(view.totals().needsValidation());
totals.setReadyToPublish(view.totals().readyToPublish());
totals.setPublications(view.totals().publications());
dashboard.setTotals(totals);
return dashboard;
}
public Asset toApi(AssetView view) {
Asset asset = new Asset();
asset.setId(view.id());
asset.setAssetKey(view.assetKey());
asset.setKind(AssetKind.fromValue(view.kind().name()));
asset.setMediaType(view.mediaType());
asset.setOriginalFilename(view.originalFilename());
asset.setByteSize(Math.toIntExact(view.byteSize()));
asset.setWidth(view.width());
asset.setHeight(view.height());
asset.setAltText(view.altText());
asset.setDecorative(view.decorative());
asset.setManagementStatus(AssetManagementStatus.fromValue(view.managementStatus().name()));
asset.setPublicPath(view.publicPath());
asset.setUsageCount(view.usageCount());
asset.setVersion(Math.toIntExact(view.version()));
asset.setCreatedAt(StudioResponseMapper.offsetDateTime(view.createdAt()));
asset.setUpdatedAt(StudioResponseMapper.offsetDateTime(view.updatedAt()));
return asset;
}
public AssetPage toApi(AssetPageView view) {
AssetPage page = new AssetPage();
page.setItems(view.items().stream().map(this::toApi).toList());
page.setNextCursor(view.nextCursor());
return page;
}
public AssetDetail toApi(AssetDetailView view) {
AssetDetail detail = new AssetDetail();
detail.setAsset(toApi(view.asset()));
detail.setUsages(
view.usages().stream()
.map(
usage -> {
AssetUsage dto = new AssetUsage();
dto.setDocumentId(usage.documentId());
dto.setDocumentKind(RecordKind.fromValue(usage.documentKind().name()));
dto.setTitle(usage.title());
dto.setPublished(usage.published());
return dto;
})
.toList());
detail.setHasPublicationHistory(view.hasPublicationHistory());
return detail;
}
/** 저장된 렌더 모델 JSON을 계약 union 타입으로 되살린다. */
private PublicRenderModel renderModel(String json) {
if (json == null || json.isBlank()) {
return null;
}
try {
return objectMapper.readValue(json, PublicRenderModel.class);
} catch (JacksonException e) {
throw new MappingException("a stored preview render model no longer matches the contract", e);
}
}
}
@@ -0,0 +1,203 @@
package dev.caskeleton.adapter.inbound.web.techlog.studio.mapper;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.CaseInput;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.OrderedText;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.ProjectDecisionInput;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.QuestionInput;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.QuestionOption;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.QuestionResolution;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.ReferenceInput;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.ReferenceRule;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.RelationInput;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.WorkingCopyInput;
import dev.caskeleton.application.techlog.error.StudioError;
import dev.caskeleton.application.techlog.error.StudioException;
import dev.caskeleton.application.techlog.studio.model.DecisionStatusView;
import dev.caskeleton.application.techlog.studio.model.OrderedTextView;
import dev.caskeleton.application.techlog.studio.model.QuestionOptionView;
import dev.caskeleton.application.techlog.studio.model.QuestionResolutionView;
import dev.caskeleton.application.techlog.studio.model.QuestionStatusView;
import dev.caskeleton.application.techlog.studio.model.RecordKind;
import dev.caskeleton.application.techlog.studio.model.ReferenceRuleView;
import dev.caskeleton.application.techlog.studio.model.RelationView;
import dev.caskeleton.application.techlog.studio.model.WorkingCopyBaseInput;
import dev.caskeleton.application.techlog.studio.model.WorkingCopyInputView;
import java.util.List;
import java.util.UUID;
/**
* 계약 DTO({@code WorkingCopyInput} union) → application 입력 모델.
*
* <p>계약 union 의 네 분기를 {@code switch} 로 남김없이 다룬다 — 계약에 다섯 번째 유형이 생기면 생성 DTO 가 늘어나고 여기서 {@code
* default} 가 없는 채로 컴파일이 깨져 알려준다.
*/
public final class StudioRequestMapper {
private StudioRequestMapper() {}
public static WorkingCopyInputView toApplication(WorkingCopyInput input) {
if (input == null) {
throw StudioException.of(StudioError.REQUEST_VALIDATION_FAILED, "document is required");
}
return switch (input) {
case CaseInput value ->
new WorkingCopyInputView.CaseInputView(
base(
RecordKind.CASE,
value.getTitle(),
value.getSlug(),
value.getSummary(),
value.getTopicId(),
value.getProjectId(),
value.getRelations()),
value.getProblem(),
value.getConclusion(),
value.getEnvironment(),
value.getReproduction(),
value.getLastVerifiedOn(),
value.getBodyMarkdown());
case ReferenceInput value ->
new WorkingCopyInputView.ReferenceInputView(
base(
RecordKind.REFERENCE,
value.getTitle(),
value.getSlug(),
value.getSummary(),
value.getTopicId(),
value.getProjectId(),
value.getRelations()),
value.getPurpose(),
rules(value.getRules()),
orderedText(value.getApplyWhen()),
orderedText(value.getExceptions()),
orderedText(value.getExamples()),
value.getVerifiedOn());
case QuestionInput value ->
new WorkingCopyInputView.QuestionInputView(
base(
RecordKind.QUESTION,
value.getTitle(),
value.getSlug(),
value.getSummary(),
value.getTopicId(),
value.getProjectId(),
value.getRelations()),
questionStatus(value.getQuestionStatus()),
orderedText(value.getFacts()),
orderedText(value.getAssumptions()),
orderedText(value.getUnknowns()),
orderedText(value.getConstraints()),
options(value.getOptions()),
value.getNextValidation(),
resolution(value.getResolution()));
case ProjectDecisionInput value ->
new WorkingCopyInputView.ProjectDecisionInputView(
base(
RecordKind.PROJECT_DECISION,
value.getTitle(),
value.getSlug(),
value.getSummary(),
value.getTopicId(),
value.getProjectId(),
value.getRelations()),
decisionStatus(value.getDecisionStatus()),
value.getDecidedOn(),
value.getStatement(),
value.getRationale(),
orderedText(value.getConsequences()));
default ->
throw StudioException.of(
StudioError.REQUEST_VALIDATION_FAILED,
"unsupported document kind: " + input.getClass().getSimpleName());
};
}
private static WorkingCopyBaseInput base(
RecordKind kind,
String title,
String slug,
String summary,
UUID topicId,
UUID projectId,
List<RelationInput> relations) {
return new WorkingCopyBaseInput(
kind, title, slug, summary, topicId, projectId, relations(relations));
}
private static List<RelationView> relations(List<RelationInput> relations) {
return relations == null
? List.of()
: relations.stream()
.map(
relation ->
new RelationView(
relation.getId(),
relation.getTargetId(),
relation.getReason(),
order(relation.getOrder())))
.toList();
}
private static List<OrderedTextView> orderedText(List<OrderedText> items) {
return items == null
? List.of()
: items.stream()
.map(item -> new OrderedTextView(item.getId(), item.getText(), order(item.getOrder())))
.toList();
}
private static List<ReferenceRuleView> rules(List<ReferenceRule> items) {
return items == null
? List.of()
: items.stream()
.map(
item ->
new ReferenceRuleView(
item.getId(), item.getTitle(), item.getBody(), order(item.getOrder())))
.toList();
}
private static List<QuestionOptionView> options(List<QuestionOption> items) {
return items == null
? List.of()
: items.stream()
.map(
item ->
new QuestionOptionView(
item.getId(),
item.getTitle(),
item.getDescription(),
order(item.getOrder())))
.toList();
}
private static QuestionResolutionView resolution(QuestionResolution resolution) {
return resolution == null
? null
: new QuestionResolutionView(
resolution.getSummary(), resolution.getEvidenceTargetId(), resolution.getLinkLabel());
}
private static QuestionStatusView questionStatus(QuestionInput.QuestionStatusEnum status) {
if (status == null) {
return null;
}
return status == QuestionInput.QuestionStatusEnum.RESOLVED
? QuestionStatusView.RESOLVED
: QuestionStatusView.OPEN;
}
private static DecisionStatusView decisionStatus(ProjectDecisionInput.DecisionStatusEnum status) {
if (status == null) {
return null;
}
return status == ProjectDecisionInput.DecisionStatusEnum.ADOPTED
? DecisionStatusView.ADOPTED
: DecisionStatusView.PROPOSED;
}
/** 계약상 {@code order}는 required지만 null 을 실어 보내는 클라이언트를 500으로 떨어뜨리지 않는다. */
private static int order(Integer order) {
return order == null ? 0 : order;
}
}
@@ -0,0 +1,258 @@
package dev.caskeleton.adapter.inbound.web.techlog.studio.mapper;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.CaseWorkingCopy;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.DisplayTarget;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.DocumentPage;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.DocumentSummary;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.NextAction;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.OrderedText;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.ProjectDecisionWorkingCopy;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.PublicationStatus;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.QuestionOption;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.QuestionResolution;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.QuestionWorkingCopy;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.RecordKind;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.ReferenceRule;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.ReferenceWorkingCopy;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.Relation;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.WorkingCopy;
import dev.caskeleton.application.techlog.studio.model.DisplayTargetView;
import dev.caskeleton.application.techlog.studio.model.DocumentPageView;
import dev.caskeleton.application.techlog.studio.model.DocumentSummaryView;
import dev.caskeleton.application.techlog.studio.model.OrderedTextView;
import dev.caskeleton.application.techlog.studio.model.QuestionOptionView;
import dev.caskeleton.application.techlog.studio.model.QuestionResolutionView;
import dev.caskeleton.application.techlog.studio.model.QuestionStatusView;
import dev.caskeleton.application.techlog.studio.model.ReferenceRuleView;
import dev.caskeleton.application.techlog.studio.model.RelationView;
import dev.caskeleton.application.techlog.studio.model.WorkingCopyBaseInput;
import dev.caskeleton.application.techlog.studio.model.WorkingCopyView;
import java.time.Instant;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.List;
import java.util.function.Function;
/** application 모델 → 계약 DTO. */
public final class StudioResponseMapper {
private StudioResponseMapper() {}
public static WorkingCopy toApi(WorkingCopyView view) {
return switch (view) {
case WorkingCopyView.CaseWorkingCopyView value -> {
CaseWorkingCopy dto = new CaseWorkingCopy();
dto.setKind(CaseWorkingCopy.KindEnum.CASE);
applyBase(value.base(), dto::setTitle, dto::setSlug, dto::setSummary);
dto.setTopicId(value.base().topicId());
dto.setProjectId(value.base().projectId());
dto.setRelations(relations(value.base().relations()));
dto.setId(value.id());
dto.setVersion(version(value.version()));
dto.setUpdatedAt(offsetDateTime(value.updatedAt()));
dto.setProblem(value.problem());
dto.setConclusion(value.conclusion());
dto.setEnvironment(value.environment());
dto.setReproduction(value.reproduction());
dto.setLastVerifiedOn(value.lastVerifiedOn());
dto.setBodyMarkdown(value.bodyMarkdown());
yield dto;
}
case WorkingCopyView.ReferenceWorkingCopyView value -> {
ReferenceWorkingCopy dto = new ReferenceWorkingCopy();
dto.setKind(ReferenceWorkingCopy.KindEnum.REFERENCE);
applyBase(value.base(), dto::setTitle, dto::setSlug, dto::setSummary);
dto.setTopicId(value.base().topicId());
dto.setProjectId(value.base().projectId());
dto.setRelations(relations(value.base().relations()));
dto.setId(value.id());
dto.setVersion(version(value.version()));
dto.setUpdatedAt(offsetDateTime(value.updatedAt()));
dto.setPurpose(value.purpose());
dto.setRules(rulesToApi(value.rules()));
dto.setApplyWhen(orderedTextToApi(value.applyWhen()));
dto.setExceptions(orderedTextToApi(value.exceptions()));
dto.setExamples(orderedTextToApi(value.examples()));
dto.setVerifiedOn(value.verifiedOn());
yield dto;
}
case WorkingCopyView.QuestionWorkingCopyView value -> {
QuestionWorkingCopy dto = new QuestionWorkingCopy();
dto.setKind(QuestionWorkingCopy.KindEnum.QUESTION);
applyBase(value.base(), dto::setTitle, dto::setSlug, dto::setSummary);
dto.setTopicId(value.base().topicId());
dto.setProjectId(value.base().projectId());
dto.setRelations(relations(value.base().relations()));
dto.setId(value.id());
dto.setVersion(version(value.version()));
dto.setUpdatedAt(offsetDateTime(value.updatedAt()));
dto.setQuestionStatus(questionStatus(value.questionStatus()));
dto.setFacts(orderedTextToApi(value.facts()));
dto.setAssumptions(orderedTextToApi(value.assumptions()));
dto.setUnknowns(orderedTextToApi(value.unknowns()));
dto.setConstraints(orderedTextToApi(value.constraints()));
dto.setOptions(optionsToApi(value.options()));
dto.setNextValidation(value.nextValidation());
dto.setResolution(resolution(value.resolution()));
yield dto;
}
case WorkingCopyView.ProjectDecisionWorkingCopyView value -> {
ProjectDecisionWorkingCopy dto = new ProjectDecisionWorkingCopy();
dto.setKind(ProjectDecisionWorkingCopy.KindEnum.PROJECT_DECISION);
applyBase(value.base(), dto::setTitle, dto::setSlug, dto::setSummary);
dto.setTopicId(value.base().topicId());
dto.setProjectId(value.base().projectId());
dto.setRelations(relations(value.base().relations()));
dto.setId(value.id());
dto.setVersion(version(value.version()));
dto.setUpdatedAt(offsetDateTime(value.updatedAt()));
dto.setDecisionStatus(decisionStatus(value.decisionStatus()));
dto.setDecidedOn(value.decidedOn());
dto.setStatement(value.statement());
dto.setRationale(value.rationale());
dto.setConsequences(orderedTextToApi(value.consequences()));
yield dto;
}
};
}
public static DocumentPage toApi(DocumentPageView view) {
DocumentPage page = new DocumentPage();
page.setItems(view.items().stream().map(StudioResponseMapper::toApi).toList());
page.setNextCursor(view.nextCursor());
return page;
}
public static DocumentSummary toApi(DocumentSummaryView view) {
DocumentSummary summary = new DocumentSummary();
summary.setId(view.id());
summary.setTitle(view.title());
summary.setKind(RecordKind.fromValue(view.kind().name()));
summary.setProject(displayTarget(view.project()));
summary.setUpdatedAt(offsetDateTime(view.updatedAt()));
summary.setPublicationStatus(PublicationStatus.fromValue(view.publicationStatus().name()));
summary.setPublishedVersion(
view.publishedVersion() == null ? null : Math.toIntExact(view.publishedVersion()));
summary.setHasUnpublishedChanges(view.hasUnpublishedChanges());
summary.setNextAction(NextAction.fromValue(view.nextAction().name()));
return summary;
}
public static DisplayTarget displayTarget(DisplayTargetView view) {
if (view == null) {
return null;
}
DisplayTarget target = new DisplayTarget();
target.setId(view.id());
target.setLabel(view.label());
target.setPublicPath(view.publicPath());
return target;
}
public static OffsetDateTime offsetDateTime(Instant instant) {
return instant == null ? null : instant.atOffset(ZoneOffset.UTC);
}
/**
* 계약의 {@code version}은 {@code integer}이고 컬럼은 {@code bigint}다. 넘치는 값을 조용히 잘라내면 클라이언트가 보내는 {@code
* expectedVersion}이 영영 맞지 않게 되므로 예외로 드러낸다.
*/
private static Integer version(long version) {
return Math.toIntExact(version);
}
private static void applyBase(
WorkingCopyBaseInput base,
java.util.function.Consumer<String> title,
java.util.function.Consumer<String> slug,
java.util.function.Consumer<String> summary) {
title.accept(base.title());
slug.accept(base.slug());
summary.accept(base.summary());
}
private static List<Relation> relations(List<RelationView> views) {
return map(
views,
view -> {
Relation relation = new Relation();
relation.setId(view.id());
relation.setTargetId(view.targetId());
relation.setReason(view.reason());
relation.setOrder(view.order());
return relation;
});
}
public static List<OrderedText> orderedTextToApi(List<OrderedTextView> views) {
return map(
views,
view -> {
OrderedText text = new OrderedText();
text.setId(view.id());
text.setText(view.text());
text.setOrder(view.order());
return text;
});
}
public static List<ReferenceRule> rulesToApi(List<ReferenceRuleView> views) {
return map(
views,
view -> {
ReferenceRule rule = new ReferenceRule();
rule.setId(view.id());
rule.setTitle(view.title());
rule.setBody(view.body());
rule.setOrder(view.order());
return rule;
});
}
public static List<QuestionOption> optionsToApi(List<QuestionOptionView> views) {
return map(
views,
view -> {
QuestionOption option = new QuestionOption();
option.setId(view.id());
option.setTitle(view.title());
option.setDescription(view.description());
option.setOrder(view.order());
return option;
});
}
private static QuestionResolution resolution(QuestionResolutionView view) {
if (view == null) {
return null;
}
QuestionResolution resolution = new QuestionResolution();
resolution.setSummary(view.summary());
resolution.setEvidenceTargetId(view.evidenceTargetId());
resolution.setLinkLabel(view.linkLabel());
return resolution;
}
private static QuestionWorkingCopy.QuestionStatusEnum questionStatus(QuestionStatusView status) {
if (status == null) {
return null;
}
return status == QuestionStatusView.RESOLVED
? QuestionWorkingCopy.QuestionStatusEnum.RESOLVED
: QuestionWorkingCopy.QuestionStatusEnum.OPEN;
}
private static ProjectDecisionWorkingCopy.DecisionStatusEnum decisionStatus(
dev.caskeleton.application.techlog.studio.model.DecisionStatusView status) {
if (status == null) {
return null;
}
return status == dev.caskeleton.application.techlog.studio.model.DecisionStatusView.ADOPTED
? ProjectDecisionWorkingCopy.DecisionStatusEnum.ADOPTED
: ProjectDecisionWorkingCopy.DecisionStatusEnum.PROPOSED;
}
private static <S, T> List<T> map(List<S> source, Function<S, T> mapper) {
return source == null ? List.of() : source.stream().map(mapper).toList();
}
}
@@ -0,0 +1,236 @@
package dev.caskeleton.adapter.inbound.web.techlog.studio.render;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.BlockquoteBlock;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.CaseRenderBlock;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.CodeBlock;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.DataTableBlock;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.DataTableCell;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.DataTableColumn;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.DataTableRow;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.HeadingBlock;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.Inline;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.ListItem;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.OrderedListBlock;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.ParagraphBlock;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.UnorderedListBlock;
import java.util.ArrayList;
import java.util.List;
import org.commonmark.ext.gfm.tables.TableBlock;
import org.commonmark.ext.gfm.tables.TableBody;
import org.commonmark.ext.gfm.tables.TableCell;
import org.commonmark.ext.gfm.tables.TableHead;
import org.commonmark.ext.gfm.tables.TableRow;
import org.commonmark.node.BlockQuote;
import org.commonmark.node.BulletList;
import org.commonmark.node.FencedCodeBlock;
import org.commonmark.node.Heading;
import org.commonmark.node.IndentedCodeBlock;
import org.commonmark.node.Node;
import org.commonmark.node.OrderedList;
import org.commonmark.node.Paragraph;
import org.commonmark.node.ThematicBreak;
/**
* commonmark 블록 노드를 계약의 {@code CaseRenderBlock} union 으로 옮긴다.
*
* <p>계약이 표현할 수 없는 것은 조용히 다른 것으로 바꾸지 않고 경고로 남긴다(설계 05장 §12) — 렌더러가 지원하지 않는 문법을 그럴듯하게 잘못 해석하면 작성자는 게시
* 결과를 신뢰할 수 없다.
*/
final class BlockRenderer {
/** 계약 {@code HeadingBlock.level} 은 2..4 다. Markdown 의 h1/h5/h6 는 이 범위로 접는다. */
private static final int MIN_HEADING_LEVEL = 2;
private static final int MAX_HEADING_LEVEL = 4;
private final HeadingIds headingIds;
private final List<String> warnings;
private int tableSequence;
private int listItemSequence;
BlockRenderer(HeadingIds headingIds, List<String> warnings) {
this.headingIds = headingIds;
this.warnings = warnings;
}
List<CaseRenderBlock> render(Node document) {
List<CaseRenderBlock> blocks = new ArrayList<>();
for (Node node = document.getFirstChild(); node != null; node = node.getNext()) {
CaseRenderBlock block = renderBlock(node);
if (block != null) {
blocks.add(block);
}
}
return blocks;
}
private CaseRenderBlock renderBlock(Node node) {
return switch (node) {
case Heading value -> heading(value);
case Paragraph value -> paragraph(InlineRenderer.render(value));
case BlockQuote value -> blockquote(value);
case BulletList value -> bulletList(value);
case OrderedList value -> orderedList(value);
case FencedCodeBlock value -> code(value.getLiteral(), value.getInfo());
case IndentedCodeBlock value -> code(value.getLiteral(), null);
case TableBlock value -> table(value);
case ThematicBreak ignored -> {
// 계약의 CaseRenderBlock 에 수평선 타입이 없다. 다른 블록으로 바꿔 넣으면 원문에 없던
// 구조가 생기므로 버리고 경고한다.
warnings.add("THEMATIC_BREAK_NOT_RENDERABLE");
yield null;
}
default -> {
List<Inline> content = InlineRenderer.render(node);
yield content.isEmpty() ? null : paragraph(content);
}
};
}
private CaseRenderBlock heading(Heading value) {
HeadingBlock block = new HeadingBlock();
block.setType(HeadingBlock.TypeEnum.HEADING);
block.setId(headingIds.nextFor(InlineRenderer.plainText(value)));
block.setLevel(Math.clamp(value.getLevel(), MIN_HEADING_LEVEL, MAX_HEADING_LEVEL));
block.setContent(InlineRenderer.render(value));
return block;
}
private static CaseRenderBlock paragraph(List<Inline> content) {
ParagraphBlock block = new ParagraphBlock();
block.setType(ParagraphBlock.TypeEnum.PARAGRAPH);
block.setContent(content);
return block;
}
/** 계약의 {@code BlockquoteBlock.content} 는 블록이 아니라 inline 배열이라 안쪽 문단을 이어 붙인다. */
private CaseRenderBlock blockquote(BlockQuote value) {
BlockquoteBlock block = new BlockquoteBlock();
block.setType(BlockquoteBlock.TypeEnum.BLOCKQUOTE);
List<Inline> content = new ArrayList<>();
for (Node child = value.getFirstChild(); child != null; child = child.getNext()) {
List<Inline> rendered = InlineRenderer.render(child);
if (rendered.isEmpty()) {
continue;
}
if (!content.isEmpty()) {
// 인용 안의 문단 경계. 이어 붙이기만 하면 앞 문단의 마지막 낱말과 다음 문단의 첫 낱말이
// 한 낱말로 붙어 읽힌다.
content.add(InlineRenderer.spacer());
}
content.addAll(rendered);
}
block.setContent(content);
return block;
}
private CaseRenderBlock bulletList(BulletList value) {
UnorderedListBlock block = new UnorderedListBlock();
block.setType(UnorderedListBlock.TypeEnum.UNORDERED_LIST);
block.setItems(listItems(value));
return block;
}
private CaseRenderBlock orderedList(OrderedList value) {
OrderedListBlock block = new OrderedListBlock();
block.setType(OrderedListBlock.TypeEnum.ORDERED_LIST);
block.setItems(listItems(value));
return block;
}
private List<ListItem> listItems(Node list) {
List<ListItem> items = new ArrayList<>();
for (Node child = list.getFirstChild(); child != null; child = child.getNext()) {
ListItem item = new ListItem();
listItemSequence++;
item.setId("li-" + listItemSequence);
List<Inline> content = new ArrayList<>();
for (Node paragraph = child.getFirstChild();
paragraph != null;
paragraph = paragraph.getNext()) {
content.addAll(InlineRenderer.render(paragraph));
}
item.setContent(content);
items.add(item);
}
return items;
}
private static CaseRenderBlock code(String literal, String info) {
CodeBlock block = new CodeBlock();
block.setType(CodeBlock.TypeEnum.CODE_BLOCK);
block.setCode(literal == null ? "" : literal);
block.setLanguage(info == null || info.isBlank() ? null : info.strip());
block.setLabel(null);
return block;
}
private CaseRenderBlock table(TableBlock value) {
DataTableBlock block = new DataTableBlock();
block.setType(DataTableBlock.TypeEnum.DATA_TABLE);
tableSequence++;
block.setId("table-" + tableSequence);
block.setCaption("");
// 계약은 rowHeaderColumn 을 1-based 로 정의한다. GFM 표에는 행 머리글 개념이 없으므로 비운다.
block.setRowHeaderColumn(null);
List<DataTableColumn> columns = new ArrayList<>();
List<DataTableRow> rows = new ArrayList<>();
int rowSequence = 0;
for (Node section = value.getFirstChild(); section != null; section = section.getNext()) {
for (Node row = section.getFirstChild(); row != null; row = row.getNext()) {
if (!(row instanceof TableRow tableRow)) {
continue;
}
if (section instanceof TableHead) {
int index = 0;
for (Node cell = tableRow.getFirstChild(); cell != null; cell = cell.getNext()) {
DataTableColumn column = new DataTableColumn();
index++;
column.setId("col-" + index);
String label = InlineRenderer.plainText(cell);
// 계약의 label 은 minLength 1 이다. 빈 머리글 칸은 열 번호로 대신한다.
column.setLabel(label.isBlank() ? "col-" + index : label);
column.setAlignment(alignment(cell));
columns.add(column);
}
} else if (section instanceof TableBody) {
DataTableRow dataRow = new DataTableRow();
rowSequence++;
dataRow.setId("row-" + rowSequence);
List<DataTableCell> cells = new ArrayList<>();
int index = 0;
for (Node cell = tableRow.getFirstChild(); cell != null; cell = cell.getNext()) {
DataTableCell dataCell = new DataTableCell();
index++;
dataCell.setColumnId("col-" + index);
dataCell.setContent(InlineRenderer.render(cell));
cells.add(dataCell);
}
dataRow.setCells(cells);
rows.add(dataRow);
}
}
}
if (columns.isEmpty()) {
// 계약의 columns 는 minItems 1 이다. 머리글 없는 표는 계약상 표현할 수 없다.
warnings.add("DATA_TABLE_WITHOUT_HEADER_NOT_RENDERABLE");
return null;
}
block.setColumns(columns);
block.setRows(rows);
return block;
}
private static DataTableColumn.AlignmentEnum alignment(Node cell) {
if (!(cell instanceof TableCell tableCell) || tableCell.getAlignment() == null) {
return DataTableColumn.AlignmentEnum.LEFT;
}
return switch (tableCell.getAlignment()) {
case CENTER -> DataTableColumn.AlignmentEnum.CENTER;
case RIGHT -> DataTableColumn.AlignmentEnum.RIGHT;
case LEFT -> DataTableColumn.AlignmentEnum.LEFT;
};
}
}
@@ -0,0 +1,71 @@
package dev.caskeleton.adapter.inbound.web.techlog.studio.render;
import java.text.Normalizer;
import java.util.HashMap;
import java.util.Locale;
import java.util.Map;
/**
* 설계 05장 §6의 heading ID 알고리즘. TOC와 anchor가 Public과 Studio에서 같아야 하므로 구현은 한 곳에만 둔다.
*
* <pre>{@code
* "Authorization Code Flow" -> authorization-code-flow
* "JPA N+1 문제" -> jpa-n-1-문제
* "결론" -> 결론
* "결론" (두 번째) -> 결론-2
* }</pre>
*/
final class HeadingIds {
private static final String FALLBACK = "section";
private final Map<String, Integer> used = new HashMap<>();
/** 같은 문서 안에서 중복되면 {@code -2}, {@code -3} 순으로 suffix 를 붙인다. */
String nextFor(String headingPlainText) {
String base = slugify(headingPlainText);
int seen = used.merge(base, 1, Integer::sum);
return seen == 1 ? base : base + "-" + seen;
}
private static String slugify(String text) {
String normalized =
Normalizer.normalize(text == null ? "" : text, Normalizer.Form.NFKC)
.trim()
.toLowerCase(Locale.ROOT);
StringBuilder out = new StringBuilder(normalized.length());
boolean pendingSeparator = false;
for (int i = 0; i < normalized.length(); i++) {
char ch = normalized.charAt(i);
if (isKept(ch)) {
// 구분자는 실제로 유지 문자가 뒤따를 때만 쓴다 — 그래야 끝에 하이픈이 남지 않는다.
if (pendingSeparator && !out.isEmpty()) {
out.append('-');
}
pendingSeparator = false;
out.append(ch);
} else {
pendingSeparator = true;
}
}
return out.isEmpty() ? FALLBACK : out.toString();
}
/** 한글·영문·숫자·하이픈만 남긴다(설계 05장 §6 6단계). */
private static boolean isKept(char ch) {
if (ch == '-') {
return true;
}
if ((ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9')) {
return true;
}
return isHangul(ch);
}
private static boolean isHangul(char ch) {
return (ch >= 0xAC00 && ch <= 0xD7A3) // 완성형 음절
|| (ch >= 0x1100 && ch <= 0x11FF) // 초·중·종성 자모
|| (ch >= 0x3130 && ch <= 0x318F); // 호환용 자모
}
}
@@ -0,0 +1,117 @@
package dev.caskeleton.adapter.inbound.web.techlog.studio.render;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.Inline;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.InlineCode;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.InlineEmphasis;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.InlineLink;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.InlineStrong;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.InlineText;
import java.util.ArrayList;
import java.util.List;
import org.commonmark.node.Code;
import org.commonmark.node.Emphasis;
import org.commonmark.node.HardLineBreak;
import org.commonmark.node.Image;
import org.commonmark.node.Link;
import org.commonmark.node.Node;
import org.commonmark.node.SoftLineBreak;
import org.commonmark.node.StrongEmphasis;
import org.commonmark.node.Text;
/** commonmark inline 노드를 계약의 {@code Inline} union 으로 옮긴다. */
final class InlineRenderer {
private InlineRenderer() {}
static List<Inline> render(Node parent) {
List<Inline> out = new ArrayList<>();
for (Node child = parent.getFirstChild(); child != null; child = child.getNext()) {
Inline rendered = renderNode(child);
if (rendered != null) {
out.add(rendered);
}
}
return out;
}
/** heading id 계산과 alt 추출에 쓰는 평문. */
static String plainText(Node parent) {
StringBuilder text = new StringBuilder();
appendPlainText(parent, text);
return text.toString();
}
private static void appendPlainText(Node parent, StringBuilder text) {
for (Node child = parent.getFirstChild(); child != null; child = child.getNext()) {
switch (child) {
case Text value -> text.append(value.getLiteral());
case Code value -> text.append(value.getLiteral());
case SoftLineBreak ignored -> text.append(' ');
case HardLineBreak ignored -> text.append(' ');
default -> appendPlainText(child, text);
}
}
}
private static Inline renderNode(Node node) {
return switch (node) {
case Text value -> text(value.getLiteral());
case Code value -> {
// 계약의 InlineCode.code 는 minLength 1 이다. 빈 백틱은 보낼 값이 없으므로 버린다.
if (value.getLiteral().isEmpty()) {
yield null;
}
InlineCode code = new InlineCode();
code.setType(InlineCode.TypeEnum.INLINE_CODE);
code.setCode(value.getLiteral());
yield code;
}
case Emphasis value -> {
InlineEmphasis emphasis = new InlineEmphasis();
emphasis.setType(InlineEmphasis.TypeEnum.EMPHASIS);
emphasis.setChildren(render(value));
yield emphasis;
}
case StrongEmphasis value -> {
InlineStrong strong = new InlineStrong();
strong.setType(InlineStrong.TypeEnum.STRONG);
strong.setChildren(render(value));
yield strong;
}
case Link value -> {
InlineLink link = new InlineLink();
link.setType(InlineLink.TypeEnum.LINK);
String label = plainText(value);
// 계약의 label 은 minLength 1 이다. 라벨 없는 링크는 주소 자체를 라벨로 쓴다 —
// 버리면 사용자가 쓴 링크가 통째로 사라진다.
link.setLabel(label.isBlank() ? value.getDestination() : label);
link.setHref(java.net.URI.create(value.getDestination()));
yield link;
}
// 이미지는 EvidenceFigure 로만 다룬다(설계 05장 §3). 인라인 이미지는 계약의 Inline union 에
// 대응 타입이 없으므로 alt 를 글자로 남긴다 — 조용히 사라지게 두지 않는다.
case Image value -> text(plainText(value));
case SoftLineBreak ignored -> text(" ");
case HardLineBreak ignored -> text(" ");
default -> {
String plain = plainText(node);
yield plain.isEmpty() ? null : text(plain);
}
};
}
/** 블록 경계를 한 칸 띄우는 조각. 계약의 Inline union 에 줄바꿈 타입이 없어 공백으로 표현한다. */
static Inline spacer() {
return text(" ");
}
private static Inline text(String literal) {
if (literal == null || literal.isEmpty()) {
return null;
}
InlineText inlineText = new InlineText();
inlineText.setType(InlineText.TypeEnum.TEXT);
inlineText.setText(literal);
return inlineText;
}
}
@@ -0,0 +1,92 @@
package dev.caskeleton.adapter.inbound.web.techlog.studio.render;
import java.util.ArrayList;
import java.util.List;
/**
* 본문을 "평범한 Markdown" 조각과 "directive" 조각으로 순서대로 자른다.
*
* <p>fenced code block 안의 {@code :::} 는 directive 가 아니다 — 코드 예시로 directive 문법 자체를 적는 문서가 그 자리에서 잘리면
* 안 된다. 그래서 코드 펜스 안에 있는 동안은 directive 를 찾지 않는다.
*/
final class MarkdownSegments {
private MarkdownSegments() {}
/** 조각 하나. {@code directive} 가 null 이면 평범한 Markdown 이다. */
record Segment(String markdown, StudioDirective directive, String directiveBody) {}
static List<Segment> split(String source) {
List<Segment> segments = new ArrayList<>();
if (source == null || source.isBlank()) {
return segments;
}
String[] lines = source.split("\n", -1);
StringBuilder markdown = new StringBuilder();
String codeFence = null;
for (int i = 0; i < lines.length; i++) {
String line = lines[i];
String trimmed = line.strip();
if (codeFence != null) {
markdown.append(line).append('\n');
if (trimmed.startsWith(codeFence)) {
codeFence = null;
}
continue;
}
if (trimmed.startsWith("```") || trimmed.startsWith("~~~")) {
codeFence = trimmed.startsWith("```") ? "```" : "~~~";
markdown.append(line).append('\n');
continue;
}
StudioDirective directive = StudioDirective.parse(line);
if (directive == null || directive.name().isEmpty()) {
markdown.append(line).append('\n');
continue;
}
flush(segments, markdown);
// 컨테이너형(:::name ... 내용 ... :::)인지 leaf형(:::evidence ...)인지는 <b>닫는 줄이
// 실제로 있는지</b>로 정한다. 이름으로 정하면 새 directive 를 더할 때마다 목록을 고쳐야 하고,
// "닫는 줄이 없으면 문서 끝까지 본문"으로 정하면 닫기를 빠뜨린 directive 하나가 뒤 내용을
// 통째로 삼킨다. 닫는 줄은 다음 directive 가 열리기 전까지만 찾는다.
int closing = -1;
for (int j = i + 1; j < lines.length; j++) {
String candidate = lines[j].strip();
if (":::".equals(candidate)) {
closing = j;
break;
}
if (StudioDirective.parse(lines[j]) != null) {
break;
}
}
if (closing < 0) {
segments.add(new Segment(null, directive, ""));
} else {
StringBuilder body = new StringBuilder();
for (int j = i + 1; j < closing; j++) {
body.append(lines[j]).append('\n');
}
segments.add(new Segment(null, directive, body.toString()));
i = closing;
}
}
flush(segments, markdown);
return segments;
}
private static void flush(List<Segment> segments, StringBuilder markdown) {
if (!markdown.isEmpty()) {
String text = markdown.toString();
if (!text.isBlank()) {
segments.add(new Segment(text, null, null));
}
markdown.setLength(0);
}
}
}
@@ -0,0 +1,166 @@
package dev.caskeleton.adapter.inbound.web.techlog.studio.render;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.CasePublicRenderModel;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.DisplayTarget;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.ProjectDecisionPublicRenderModel;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.PublicRenderModel;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.QuestionPublicRenderModel;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.ReferencePublicRenderModel;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.RenderContext;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.ResolvedQuestionResolution;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.ResolvedRelation;
import dev.caskeleton.adapter.inbound.web.techlog.studio.mapper.StudioResponseMapper;
import dev.caskeleton.application.techlog.studio.model.DecisionStatusView;
import dev.caskeleton.application.techlog.studio.model.QuestionStatusView;
import dev.caskeleton.application.techlog.studio.model.RenderInput;
import dev.caskeleton.application.techlog.studio.model.ResolvedRelationView;
import dev.caskeleton.application.techlog.studio.model.WorkingCopyView;
import java.util.List;
import org.springframework.stereotype.Component;
/**
* 편집본 + 해석된 의존 상태 → 계약 {@code PublicRenderModel}.
*
* <p>본문 Markdown 을 블록으로 바꾸는 것은 {@code CASE} 뿐이다 — 나머지 세 유형의 공개 모델은 구조화된 필드로만 이루어져 있다(계약 {@code
* *PublicRenderModel}).
*/
@Component
public class PublicRenderModelFactory {
private final StudioContentRenderer contentRenderer;
public PublicRenderModelFactory(StudioContentRenderer contentRenderer) {
this.contentRenderer = contentRenderer;
}
/**
* 렌더 결과와 그 과정에서 생긴 경고.
*
* @param warnings 계약으로 표현할 수 없어 버리거나 낮춰 처리한 것들(설계 05장 §12)
*/
public record Rendered(PublicRenderModel model, List<String> warnings) {}
public Rendered create(RenderInput input) {
RenderContext context = new RenderContext();
context.setGeneratedAt(StudioResponseMapper.offsetDateTime(input.generatedAt()));
context.setDependencyRevision(input.dependencyRevision());
List<ResolvedRelation> relations =
input.relations().stream().map(PublicRenderModelFactory::relation).toList();
DisplayTarget topic = StudioResponseMapper.displayTarget(input.topic());
DisplayTarget project = StudioResponseMapper.displayTarget(input.project());
return switch (input.document()) {
case WorkingCopyView.CaseWorkingCopyView value -> {
StudioContentRenderer.RenderedContent body =
contentRenderer.render(value.bodyMarkdown(), input.assetsByKey());
CasePublicRenderModel model = new CasePublicRenderModel();
model.setKind(CasePublicRenderModel.KindEnum.CASE);
model.setSlug(value.base().slug());
model.setTitle(value.base().title());
model.setSummary(value.base().summary());
model.setPublicPath(input.publicPath());
model.setTopic(topic);
model.setProject(project);
model.setRelations(relations);
model.setRenderContext(context);
model.setProblem(value.problem());
model.setConclusion(value.conclusion());
model.setEnvironment(value.environment());
model.setReproduction(value.reproduction());
model.setLastVerifiedOn(value.lastVerifiedOn());
model.setBodyBlocks(body.blocks());
yield new Rendered(model, body.warnings());
}
case WorkingCopyView.ReferenceWorkingCopyView value -> {
ReferencePublicRenderModel model = new ReferencePublicRenderModel();
model.setKind(ReferencePublicRenderModel.KindEnum.REFERENCE);
model.setSlug(value.base().slug());
model.setTitle(value.base().title());
model.setSummary(value.base().summary());
model.setPublicPath(input.publicPath());
model.setTopic(topic);
model.setProject(project);
model.setRelations(relations);
model.setRenderContext(context);
model.setPurpose(value.purpose());
model.setRules(StudioResponseMapper.rulesToApi(value.rules()));
model.setApplyWhen(StudioResponseMapper.orderedTextToApi(value.applyWhen()));
model.setExceptions(StudioResponseMapper.orderedTextToApi(value.exceptions()));
model.setExamples(StudioResponseMapper.orderedTextToApi(value.examples()));
model.setVerifiedOn(value.verifiedOn());
yield new Rendered(model, List.of());
}
case WorkingCopyView.QuestionWorkingCopyView value -> {
QuestionPublicRenderModel model = new QuestionPublicRenderModel();
model.setKind(QuestionPublicRenderModel.KindEnum.QUESTION);
model.setSlug(value.base().slug());
model.setTitle(value.base().title());
model.setSummary(value.base().summary());
model.setPublicPath(input.publicPath());
model.setTopic(topic);
model.setProject(project);
model.setRelations(relations);
model.setRenderContext(context);
model.setStatus(
value.questionStatus() == QuestionStatusView.RESOLVED
? QuestionPublicRenderModel.StatusEnum.RESOLVED
: QuestionPublicRenderModel.StatusEnum.OPEN);
model.setFacts(StudioResponseMapper.orderedTextToApi(value.facts()));
model.setAssumptions(StudioResponseMapper.orderedTextToApi(value.assumptions()));
model.setUnknowns(StudioResponseMapper.orderedTextToApi(value.unknowns()));
model.setConstraints(StudioResponseMapper.orderedTextToApi(value.constraints()));
model.setOptions(StudioResponseMapper.optionsToApi(value.options()));
model.setNextValidation(value.nextValidation());
model.setResolution(resolution(value, input));
yield new Rendered(model, List.of());
}
case WorkingCopyView.ProjectDecisionWorkingCopyView value -> {
ProjectDecisionPublicRenderModel model = new ProjectDecisionPublicRenderModel();
model.setKind(ProjectDecisionPublicRenderModel.KindEnum.PROJECT_DECISION);
model.setSlug(value.base().slug());
model.setTitle(value.base().title());
model.setSummary(value.base().summary());
model.setPublicPath(input.publicPath());
model.setTopic(topic);
model.setProject(project);
model.setRelations(relations);
model.setRenderContext(context);
model.setStatus(
value.decisionStatus() == DecisionStatusView.ADOPTED
? ProjectDecisionPublicRenderModel.StatusEnum.ADOPTED
: ProjectDecisionPublicRenderModel.StatusEnum.PROPOSED);
model.setDecidedOn(value.decidedOn());
model.setStatement(value.statement());
model.setRationale(value.rationale());
model.setConsequences(StudioResponseMapper.orderedTextToApi(value.consequences()));
yield new Rendered(model, List.of());
}
};
}
private static ResolvedRelation relation(ResolvedRelationView view) {
ResolvedRelation relation = new ResolvedRelation();
relation.setId(view.id());
relation.setTargetId(view.targetId());
relation.setTargetKind(ResolvedRelation.TargetKindEnum.fromValue(view.targetKind()));
relation.setTitle(view.title());
relation.setPublicPath(view.publicPath());
relation.setReason(view.reason());
relation.setOrder(view.order());
return relation;
}
private static ResolvedQuestionResolution resolution(
WorkingCopyView.QuestionWorkingCopyView value, RenderInput input) {
if (value.resolution() == null) {
return null;
}
ResolvedQuestionResolution resolution = new ResolvedQuestionResolution();
resolution.setSummary(value.resolution().summary());
resolution.setEvidenceTarget(
StudioResponseMapper.displayTarget(input.resolutionEvidenceTarget()));
resolution.setLinkLabel(value.resolution().linkLabel());
return resolution;
}
}
@@ -0,0 +1,47 @@
package dev.caskeleton.adapter.inbound.web.techlog.studio.render;
import dev.caskeleton.application.techlog.studio.model.RenderInput;
import dev.caskeleton.application.techlog.studio.port.out.RenderModelPort;
import dev.caskeleton.shared.error.MappingException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import tools.jackson.core.JacksonException;
import tools.jackson.databind.ObjectMapper;
/**
* 렌더 결과를 {@code studio_preview.render_model} 에 그대로 들어갈 JSON 으로 만든다.
*
* <p>렌더 경고는 여기서 버리지 않고 로그로 남긴다 — 계약의 {@code PublicPreview} 에 경고를 실을 자리가 없지만, 경고가 생겼다는 사실 자체가 "작성자가
* 쓴 문법 일부가 계약으로 표현되지 못했다"는 신호라 흔적 없이 사라지면 안 된다. 게시를 막아야 하는 종류(미해결 Asset 등)는 검증이 별도로 잡는다.
*/
@Component
public class RenderModelJsonAdapter implements RenderModelPort {
private static final Logger log = LoggerFactory.getLogger(RenderModelJsonAdapter.class);
private final PublicRenderModelFactory factory;
private final ObjectMapper objectMapper;
public RenderModelJsonAdapter(PublicRenderModelFactory factory, ObjectMapper objectMapper) {
this.factory = factory;
this.objectMapper = objectMapper;
}
@Override
public String renderToJson(RenderInput input) {
PublicRenderModelFactory.Rendered rendered = factory.create(input);
if (!rendered.warnings().isEmpty()) {
log.warn(
"studio render produced {} warning(s) for document {}: {}",
rendered.warnings().size(),
input.document().id(),
rendered.warnings());
}
try {
return objectMapper.writeValueAsString(rendered.model());
} catch (JacksonException e) {
throw new MappingException("failed to serialise a Studio render model", e);
}
}
}
@@ -0,0 +1,181 @@
package dev.caskeleton.adapter.inbound.web.techlog.studio.render;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.CalloutBlock;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.CaseRenderBlock;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.EvidenceFigureBlock;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.ResolvedAsset;
import dev.caskeleton.application.techlog.studio.model.ResolvedAssetView;
import dev.caskeleton.application.techlog.studio.port.out.ContentAnalyzerPort;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.commonmark.ext.autolink.AutolinkExtension;
import org.commonmark.ext.gfm.strikethrough.StrikethroughExtension;
import org.commonmark.ext.gfm.tables.TablesExtension;
import org.commonmark.parser.Parser;
import org.springframework.stereotype.Component;
/**
* 본문 Markdown → 계약의 {@code CaseRenderBlock} 목록. Preview·공개·Snapshot 세 화면이 이 한 구현을 공유한다(ADR-005) —
* 화면마다 다른 경로를 두면 작성자가 확인한 것과 공개된 것이 달라진다.
*
* <p>Asset 해석은 렌더러가 직접 조회하지 않고 {@code assetsByKey} 로 주입받는다. Snapshot 만 게시 시점에 고정된 manifest 를 넣고
* 나머지는 현재 상태를 넣으며, 그것이 ADR-002 가 요구하는 유일하게 허용된 차이다.
*/
@Component
public class StudioContentRenderer implements ContentAnalyzerPort {
/** 설계 05장 §4가 허용한 callout 종류. 그 밖의 이름은 경고를 만들고 일반 인용으로 처리한다. */
private static final Set<String> INFO_CALLOUTS = Set.of("note", "tip");
private static final Set<String> WARNING_CALLOUTS = Set.of("warning", "danger");
private static final String EVIDENCE = "evidence";
private final Parser parser =
Parser.builder()
.extensions(
List.of(
TablesExtension.create(),
StrikethroughExtension.create(),
AutolinkExtension.create()))
.build();
/**
* 렌더 결과.
*
* @param blocks 계약 모양의 본문 블록
* @param warnings 계약으로 표현할 수 없어 버리거나 낮춰 처리한 것들의 코드
*/
public record RenderedContent(List<CaseRenderBlock> blocks, List<String> warnings) {}
public RenderedContent render(String bodyMarkdown, Map<String, ResolvedAssetView> assetsByKey) {
List<CaseRenderBlock> blocks = new ArrayList<>();
List<String> warnings = new ArrayList<>();
HeadingIds headingIds = new HeadingIds();
for (MarkdownSegments.Segment segment : MarkdownSegments.split(bodyMarkdown)) {
if (segment.directive() == null) {
blocks.addAll(
new BlockRenderer(headingIds, warnings).render(parser.parse(segment.markdown())));
continue;
}
CaseRenderBlock block = renderDirective(segment, assetsByKey, headingIds, warnings);
if (block != null) {
blocks.add(block);
}
}
return new RenderedContent(blocks, warnings);
}
@Override
public ContentAnalysis analyze(String bodyMarkdown) {
List<AssetUsage> usages = new ArrayList<>();
Set<String> unsupported = new LinkedHashSet<>();
for (MarkdownSegments.Segment segment : MarkdownSegments.split(bodyMarkdown)) {
StudioDirective directive = segment.directive();
if (directive == null) {
continue;
}
if (EVIDENCE.equals(directive.name())) {
usages.add(new AssetUsage(directive.attribute("key", ""), directive.attribute("alt", "")));
} else if (!INFO_CALLOUTS.contains(directive.name())
&& !WARNING_CALLOUTS.contains(directive.name())) {
unsupported.add(directive.name());
}
}
return new ContentAnalysis(usages, List.copyOf(unsupported), plainText(bodyMarkdown));
}
/** 검색 색인용 평문. 렌더러가 이미 파싱한 것을 다시 쓴다 — 정규식으로 마크업을 지우는 별도 구현을 두면 두 해석이 갈라져 색인이 본문과 어긋난다. */
private String plainText(String bodyMarkdown) {
StringBuilder text = new StringBuilder();
for (MarkdownSegments.Segment segment : MarkdownSegments.split(bodyMarkdown)) {
String source = segment.directive() == null ? segment.markdown() : segment.directiveBody();
if (source == null || source.isBlank()) {
continue;
}
String plain = InlineRenderer.plainText(parser.parse(source));
if (!plain.isBlank()) {
if (!text.isEmpty()) {
text.append(' ');
}
text.append(plain.strip());
}
}
return text.toString();
}
private CaseRenderBlock renderDirective(
MarkdownSegments.Segment segment,
Map<String, ResolvedAssetView> assetsByKey,
HeadingIds headingIds,
List<String> warnings) {
StudioDirective directive = segment.directive();
if (EVIDENCE.equals(directive.name())) {
return evidence(directive, assetsByKey, warnings);
}
if (INFO_CALLOUTS.contains(directive.name()) || WARNING_CALLOUTS.contains(directive.name())) {
CalloutBlock callout = new CalloutBlock();
callout.setType(CalloutBlock.TypeEnum.CALLOUT);
callout.setTone(
WARNING_CALLOUTS.contains(directive.name())
? CalloutBlock.ToneEnum.WARNING
: CalloutBlock.ToneEnum.INFO);
callout.setLabel(directive.argument());
callout.setContent(
InlineRenderer.render(
parser.parse(segment.directiveBody()).getFirstChild() == null
? parser.parse("")
: parser.parse(segment.directiveBody()).getFirstChild()));
return callout;
}
// 설계 05장 §4: 알 수 없는 종류는 경고를 만들고 일반 blockquote 로 안전하게 처리한다.
warnings.add("UNSUPPORTED_DIRECTIVE:" + directive.name());
List<CaseRenderBlock> fallback =
new BlockRenderer(headingIds, warnings)
.render(parser.parse("> " + segment.directiveBody().replace("\n", "\n> ")));
return fallback.isEmpty() ? null : fallback.getFirst();
}
private static CaseRenderBlock evidence(
StudioDirective directive,
Map<String, ResolvedAssetView> assetsByKey,
List<String> warnings) {
String key = directive.attribute("key", "");
if (key.isBlank()) {
warnings.add("EVIDENCE_WITHOUT_KEY");
return null;
}
ResolvedAssetView resolved = assetsByKey.get(key);
if (resolved == null) {
// 미해결 key 를 임의 경로로 채워 넣지 않는다 — 렌더 결과가 존재하지 않는 파일을 가리키게 된다.
// 게시는 검증이 막고, 미리보기에서는 이 경고가 사용자에게 무엇이 빠졌는지 알려준다.
warnings.add("UNRESOLVED_ASSET_KEY:" + key);
return null;
}
EvidenceFigureBlock block = new EvidenceFigureBlock();
block.setType(EvidenceFigureBlock.TypeEnum.EVIDENCE_FIGURE);
block.setKey(key);
block.setAlt(directive.attribute("alt", ""));
block.setCaption(directive.attribute("caption", ""));
block.setZoom(directive.booleanAttribute("zoom"));
ResolvedAsset asset = new ResolvedAsset();
asset.setAssetId(resolved.assetId());
asset.setAssetKey(resolved.assetKey());
asset.setMediaType(resolved.mediaType());
asset.setPublicPath(resolved.publicPath());
asset.setWidth(resolved.width());
asset.setHeight(resolved.height());
asset.setDecorative(resolved.decorative());
block.setAsset(asset);
return block;
}
}
@@ -0,0 +1,47 @@
package dev.caskeleton.adapter.inbound.web.techlog.studio.render;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* {@code :::name key="value" ...} 한 줄을 이름과 속성으로 나눈다.
*
* <p>directive 를 commonmark 확장이 아니라 줄 단위 스캔으로 다루는 이유: v1 문법에서 directive 는 중첩이 없고 줄 맨 앞에서만 열린다(설계
* 05장 §3.2, §4). 줄 스캔이면 동작이 눈으로 확인되고, 지원하지 않는 directive 를 "조용히 다른 것으로 해석"하는 일이 구조적으로 생기지 않는다.
*/
record StudioDirective(String name, String argument, Map<String, String> attributes) {
private static final Pattern OPENING = Pattern.compile("^:::([A-Za-z][A-Za-z0-9_-]*)\\s*(.*)$");
private static final Pattern ATTRIBUTE = Pattern.compile("([A-Za-z][A-Za-z0-9_-]*)=\"([^\"]*)\"");
static StudioDirective parse(String line) {
Matcher opening = OPENING.matcher(line.strip());
if (!opening.matches()) {
return null;
}
String rest = opening.group(2).strip();
Map<String, String> attributes = new LinkedHashMap<>();
Matcher attribute = ATTRIBUTE.matcher(rest);
int firstAttributeStart = rest.length();
while (attribute.find()) {
if (attribute.start() < firstAttributeStart) {
firstAttributeStart = attribute.start();
}
attributes.put(attribute.group(1), attribute.group(2));
}
String argument = rest.substring(0, firstAttributeStart).strip();
return new StudioDirective(opening.group(1), argument, attributes);
}
String attribute(String name, String fallback) {
String value = attributes.get(name);
return value == null ? fallback : value;
}
boolean booleanAttribute(String name) {
return "true".equalsIgnoreCase(attributes.get(name));
}
}
@@ -0,0 +1,122 @@
package dev.caskeleton.adapter.inbound.web.techlog.studio.support;
import dev.caskeleton.adapter.inbound.web.cursor.CursorCodec;
import dev.caskeleton.adapter.inbound.web.cursor.CursorException;
import dev.caskeleton.application.techlog.error.StudioError;
import dev.caskeleton.application.techlog.error.StudioException;
import dev.caskeleton.application.techlog.studio.query.DocumentCursorPosition;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Clock;
import java.time.Instant;
import java.time.format.DateTimeParseException;
import java.util.Base64;
import java.util.HexFormat;
import java.util.UUID;
import org.springframework.stereotype.Component;
/**
* 계약이 "opaque 하고 정규화된 필터·정렬에 결합된 커서"라고 정한 것을 실제로 그렇게 만든다.
*
* <p>필터 지문을 커서 안에 함께 서명한다 — 그래야 필터를 바꾼 뒤 옛 커서를 재사용하는 요청을 거절할 수 있다. 거절하지 않으면 정렬 키의 의미가 달라진 채로 페이지가
* 이어져 사용자에게는 항목이 조용히 사라지거나 중복돼 보인다.
*/
@Component
public class StudioCursors {
private static final String SEPARATOR = "~";
/**
* 지문 입력의 필드 구분자(ASCII unit separator). 사용자 입력에 나타나지 않는 제어문자라 인접한 필드가 서로 섞여 같은 지문을 만드는 일이 없다 — 예를
* 들어 구분자가 없으면 (kind="A", q="B")와 (kind="AB", q="")가 같은 값이 된다.
*/
private static final char FIELD_SEPARATOR = (char) 0x1f;
private final CursorCodec codec;
private final Clock clock;
public StudioCursors(StudioSettings settings, Clock clock) {
this.codec =
new CursorCodec(
settings.cursorSigningKey().getBytes(StandardCharsets.UTF_8), CursorCodec.DEFAULT_TTL);
this.clock = clock;
}
/** 이 페이지 요청의 필터·정렬을 대표하는 값. 커서에 함께 실린다. */
public static String fingerprint(String... normalizedFilterParts) {
StringBuilder joined = new StringBuilder();
for (String part : normalizedFilterParts) {
joined.append(part == null ? "" : part).append(FIELD_SEPARATOR);
}
try {
byte[] digest =
MessageDigest.getInstance("SHA-256")
.digest(joined.toString().getBytes(StandardCharsets.UTF_8));
return HexFormat.of().formatHex(digest, 0, 8);
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("SHA-256 must be available on every supported JVM", e);
}
}
public String encode(String payload, String fingerprint) {
String body =
fingerprint
+ SEPARATOR
+ Base64.getUrlEncoder()
.withoutPadding()
.encodeToString(payload.getBytes(StandardCharsets.UTF_8));
return codec.encode(body, clock.instant());
}
/**
* 커서를 풀어 페이지 위치로 돌려준다.
*
* @throws StudioException 서명·만료·필터 지문 중 하나라도 맞지 않으면 {@code REQUEST_VALIDATION_FAILED}
*/
public DocumentCursorPosition decode(String cursor, String fingerprint, boolean titleSort) {
String body;
try {
body = codec.decode(cursor, clock.instant());
} catch (CursorException e) {
throw StudioException.of(
StudioError.REQUEST_VALIDATION_FAILED, "cursor is not usable: " + e.getMessage());
}
int separator = body.indexOf(SEPARATOR);
if (separator <= 0) {
throw malformed();
}
if (!fingerprint.equals(body.substring(0, separator))) {
throw StudioException.of(
StudioError.REQUEST_VALIDATION_FAILED,
"cursor was issued for a different filter or sort; start from the first page");
}
String payload =
new String(
Base64.getUrlDecoder().decode(body.substring(separator + SEPARATOR.length())),
StandardCharsets.UTF_8);
int pipe = payload.lastIndexOf('|');
if (pipe <= 0) {
throw malformed();
}
String head = payload.substring(0, pipe);
UUID id;
try {
id = UUID.fromString(payload.substring(pipe + 1));
} catch (IllegalArgumentException e) {
throw malformed();
}
if (titleSort) {
return new DocumentCursorPosition(null, head, id);
}
try {
return new DocumentCursorPosition(Instant.parse(head), null, id);
} catch (DateTimeParseException e) {
throw malformed();
}
}
private static StudioException malformed() {
return StudioException.of(StudioError.REQUEST_VALIDATION_FAILED, "cursor is malformed");
}
}
@@ -0,0 +1,90 @@
package dev.caskeleton.adapter.inbound.web.techlog.studio.support;
import dev.caskeleton.adapter.inbound.web.idempotency.IdempotencyKeySupport;
import dev.caskeleton.application.idempotency.IdempotencyContext;
import dev.caskeleton.application.idempotency.IdempotencyExecutor;
import dev.caskeleton.application.techlog.error.StudioError;
import dev.caskeleton.application.techlog.error.StudioException;
import jakarta.servlet.http.HttpServletRequest;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Supplier;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.stereotype.Component;
/**
* 계약이 모든 mutation 에 요구하는 {@code Idempotency-Key} 처리(spec §8.2).
*
* <p>재생 여부를 스스로 판단하지 않고 <b>동작이 실제로 실행됐는지</b>로 안다 — 저장소를 미리 들여다보고 판정하면 그 사이에 다른 요청이 끼어들 수 있어 헤더가
* 거짓말을 하게 된다. 실행되지 않았다면 결과는 재생된 것이다.
*
* <p>{@code IdempotencyExecutor} 는 provider 가 {@code disabled} 인 배포에는 빈이 없다. 그때는 키의 존재만 계약대로 강제하고
* 실행은 그대로 통과시킨다 — 여기서 빈을 필수로 요구하면 그런 배포는 Studio 컨트롤러 때문에 부팅 자체가 실패한다.
*/
@Component
public class StudioIdempotency {
/**
* 계약 {@code components.headers.IdempotencyReplayed}. {@code ApiHeaders}에 두지 않는 이유는 그 파일이 템플릿
* SSOT({@code wiki/projects/ca-tmpl/registries/headers.yaml}) 소유라 이 기능이 손대면 다음 동기화에서 충돌하기 때문이다.
*/
public static final String IDEMPOTENCY_REPLAYED = "Idempotency-Replayed";
/** 계약 {@code components.parameters.IdempotencyKey.schema.maxLength}. */
private static final int MAX_KEY_LENGTH = 200;
private final ObjectProvider<IdempotencyExecutor> executors;
private final IdempotencyKeySupport keys;
public StudioIdempotency(
ObjectProvider<IdempotencyExecutor> executors, IdempotencyKeySupport keys) {
this.executors = executors;
this.keys = keys;
}
/**
* 결과와 그 결과가 재생된 것인지 여부.
*
* @param <R> 동작의 결과 타입
*/
public record Outcome<R>(R result, boolean replayed) {}
public <R> Outcome<R> run(
HttpServletRequest request,
String operationId,
Object requestPayload,
Class<R> responseType,
Supplier<R> action) {
String key = requireKey(request);
IdempotencyExecutor executor = executors.getIfAvailable();
if (executor == null) {
return new Outcome<>(action.get(), false);
}
AtomicBoolean executed = new AtomicBoolean(false);
R result =
executor.execute(
IdempotencyContext.of(keys.scope(key, operationId), keys.fingerprint(requestPayload)),
() -> {
executed.set(true);
return action.get();
},
keys.codec(responseType));
return new Outcome<>(result, !executed.get());
}
private String requireKey(HttpServletRequest request) {
Optional<String> key = keys.idempotencyKey(request);
if (key.isEmpty()) {
throw StudioException.of(
StudioError.REQUEST_VALIDATION_FAILED, "the Idempotency-Key header is required");
}
if (key.get().length() > MAX_KEY_LENGTH) {
throw StudioException.of(
StudioError.REQUEST_VALIDATION_FAILED,
"the Idempotency-Key header must be at most " + MAX_KEY_LENGTH + " characters");
}
return key.get();
}
}
@@ -0,0 +1,23 @@
package dev.caskeleton.adapter.inbound.web.techlog.studio.support;
import dev.caskeleton.adapter.inbound.web.auth.AuthenticatedPrincipal;
import dev.caskeleton.application.techlog.error.StudioError;
import dev.caskeleton.application.techlog.error.StudioException;
/**
* 감사 컬럼({@code created_by}/{@code updated_by})에 남길 주체를 뽑는다.
*
* <p>{@code idpUserId} 를 쓴다 — 이메일이나 표시 이름은 사용자가 바꿀 수 있어 과거 기록의 주체를 되짚을 수 없게 된다.
*/
public final class StudioPrincipals {
private StudioPrincipals() {}
public static String require(AuthenticatedPrincipal principal) {
if (principal == null || principal.idpUserId() == null || principal.idpUserId().isBlank()) {
throw StudioException.of(
StudioError.AUTHENTICATION_REQUIRED, "the request has no usable authenticated principal");
}
return principal.idpUserId();
}
}
@@ -0,0 +1,44 @@
package dev.caskeleton.adapter.inbound.web.techlog.studio.support;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.context.properties.ConfigurationProperties;
/**
* {@code ca-skeleton.techlog.studio.*}. Studio 고유 설정을 템플릿 소유 파일({@code PresentationSettings} 등)에 섞지
* 않고 여기 모은다 — 그 파일들은 template sync 대상이라 이 기능이 손대면 다음 동기화에서 충돌한다.
*
* @param cursorSigningKey 목록 커서 서명 키. 비어 있으면 개발용 값으로 대체하고 경고한다 — 커서에는 권한이 실리지 않으므로 부팅을 막을 사유는 아니지만,
* 인스턴스마다 값이 다르면 한 인스턴스가 발급한 커서를 다른 인스턴스가 거부한다.
* @param validationTtl 검증 결과가 유효한 기간({@code studio_validation.valid_until})
* @param previewTtl 미리보기가 유효한 기간({@code studio_preview.expires_at})
*/
@ConfigurationProperties(prefix = "ca-skeleton.techlog.studio")
public record StudioSettings(String cursorSigningKey, Duration validationTtl, Duration previewTtl) {
private static final Logger log = LoggerFactory.getLogger(StudioSettings.class);
private static final String DEV_CURSOR_KEY = "__LOCAL_DEV_techlog_studio_cursor_signing_key";
private static final int MIN_KEY_BYTES = 16;
private static final Duration DEFAULT_VALIDATION_TTL = Duration.ofHours(1);
private static final Duration DEFAULT_PREVIEW_TTL = Duration.ofHours(24);
public StudioSettings {
if (cursorSigningKey == null
|| cursorSigningKey.getBytes(StandardCharsets.UTF_8).length < MIN_KEY_BYTES) {
log.warn(
"APP_STUDIO_CURSOR_SIGNING_KEY is missing or shorter than {} bytes; using a development"
+ " key. Cursors issued by one instance verify on another only while every instance"
+ " falls back to the same value.",
MIN_KEY_BYTES);
cursorSigningKey = DEV_CURSOR_KEY;
}
if (validationTtl == null || validationTtl.isZero() || validationTtl.isNegative()) {
validationTtl = DEFAULT_VALIDATION_TTL;
}
if (previewTtl == null || previewTtl.isZero() || previewTtl.isNegative()) {
previewTtl = DEFAULT_PREVIEW_TTL;
}
}
}
@@ -0,0 +1,72 @@
package dev.caskeleton.adapter.inbound.web.error;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import dev.caskeleton.adapter.inbound.web.envelope.EnvelopeBodyAdvice;
import dev.caskeleton.adapter.inbound.web.techlog.StudioExceptionHandler;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.SpringBootConfiguration;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.security.autoconfigure.SecurityAutoConfiguration;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* final whole-branch review B4: proves the negative for {@link StudioExceptionHandler}'s
* {@code @RestControllerAdvice(basePackages = "dev.caskeleton.adapter.inbound.web.techlog")}
* scoping.
*
* <p>{@link Probe} lives in {@code dev.caskeleton.adapter.inbound.web.error} — outside the {@code
* ...web.techlog} tree the advice is scoped to, standing in for a non-Studio feature (fileserver,
* healthcheck). A missing required parameter on it must still fall through to the inherited {@code
* ResponseEntityExceptionHandler} behaviour (bare {@code ProblemDetail}, {@code
* application/problem+json}) exactly as it did before {@code StudioExceptionHandler} grew {@code
* MissingServletRequestParameterException}/{@code MethodArgumentTypeMismatchException} handlers —
* this branch has no mandate to change fileserver/healthcheck's error shape. If the {@code
* basePackages} scope were ever dropped or widened to cover this package, this probe would start
* seeing {@code REQUEST_VALIDATION_FAILED} at 422 instead and this test would fail.
*/
@WebMvcTest(
controllers = StudioExceptionHandlerScopeTest.Probe.class,
excludeAutoConfiguration = SecurityAutoConfiguration.class)
@AutoConfigureMockMvc(addFilters = false)
@Import({
StudioExceptionHandlerScopeTest.Probe.class,
StudioExceptionHandler.class,
GlobalExceptionHandler.class,
EnvelopeBodyAdvice.class
})
class StudioExceptionHandlerScopeTest {
@Autowired private MockMvc mvc;
@Test
void missingParameterOnANonStudioControllerKeepsTheUnenvelopedGlobalHandlerBehaviour()
throws Exception {
mvc.perform(get("/probe/non-studio"))
.andExpect(status().is(400))
.andExpect(
content().contentTypeCompatibleWith(MediaType.valueOf("application/problem+json")));
}
@RestController
static class Probe {
@GetMapping("/probe/non-studio")
String probe(@RequestParam("required") String required) {
return required;
}
}
@SpringBootConfiguration
@EnableAutoConfiguration(exclude = SecurityAutoConfiguration.class)
static class TestBootstrap {}
}
@@ -0,0 +1,62 @@
package dev.caskeleton.adapter.inbound.web.techlog;
import static org.assertj.core.api.Assertions.assertThat;
import dev.caskeleton.application.techlog.error.StudioError;
import dev.caskeleton.application.techlog.error.StudioException;
import dev.caskeleton.shared.response.Envelope;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.springframework.http.ResponseEntity;
class StudioExceptionHandlerTest {
private final StudioExceptionHandler handler = new StudioExceptionHandler();
@Test
void mapsStudioExceptionToFailureEnvelopeWithContractCode() {
ResponseEntity<Envelope<Void>> response =
handler.handleStudio(StudioException.of(StudioError.DOCUMENT_NOT_FOUND, "없음"));
assertThat(response.getStatusCode().value()).isEqualTo(404);
Envelope<Void> body = response.getBody();
assertThat(body).isNotNull();
assertThat(body.success()).isFalse();
assertThat(body.error().code()).isEqualTo("DOCUMENT_NOT_FOUND");
assertThat(body.error().category()).isEqualTo("NOT_FOUND");
assertThat(body.error().retryable()).isFalse();
}
/**
* Regression test for the client-safe message leak: the handler must never surface {@link
* StudioException#getMessage()} (diagnostic-only, may carry SQLState/upstream detail) — only
* {@link StudioClientSafeMessages#forError(StudioError)}'s fixed, per-code text.
*/
@Test
void neverLeaksTheRawExceptionMessageAndUsesTheClientSafeTextInstead() {
String rawDiagnosticMessage = "pg constraint fk_document_project violated for id=42";
ResponseEntity<Envelope<Void>> response =
handler.handleStudio(
StudioException.of(StudioError.DOCUMENT_NOT_FOUND, rawDiagnosticMessage));
String message = response.getBody().error().message();
assertThat(message).isNotEqualTo(rawDiagnosticMessage);
assertThat(message)
.isEqualTo(StudioClientSafeMessages.forError(StudioError.DOCUMENT_NOT_FOUND));
}
@Test
void carriesDetailsForConflicts() {
ResponseEntity<Envelope<Void>> response =
handler.handleStudio(
StudioException.withDetails(
StudioError.VERSION_CONFLICT,
"충돌",
Map.of("latestDocument", Map.of("version", 8))));
assertThat(response.getStatusCode().value()).isEqualTo(409);
assertThat(response.getBody()).isNotNull();
assertThat(response.getBody().error().details()).isNotNull();
}
}
@@ -0,0 +1,142 @@
package dev.caskeleton.adapter.inbound.web.techlog.studio.contract;
import static org.assertj.core.api.Assertions.assertThat;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.CasePublicRenderModel;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.CaseRenderBlock;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.CaseWorkingCopy;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.HeadingBlock;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.Inline;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.InlineText;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.NextAction;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.PublicRenderModel;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.QuestionInput;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.WorkingCopy;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.WorkingCopyDetail;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.WorkingCopyInput;
import java.util.List;
import java.util.UUID;
import org.junit.jupiter.api.Test;
import tools.jackson.databind.ObjectMapper;
/**
* 계약의 discriminator union이 Jackson 양방향으로 계약대로 동작하는지 고정한다.
*
* <p>이 게이트가 필요한 이유는 <b>컴파일이 이걸 못 잡기 때문</b>이다. Plan 01에서 {@code useOneOfInterfaces=false}로 생성한
* union은 컴파일 오류 0개였지만 런타임에는 양방향 모두 계약을 위반했다 — 역직렬화는 {@code InvalidTypeIdException}("CaseInput not
* subtype of WorkingCopyInput"), 직렬화는 판별 필드에 {@code kind} 값 대신 클래스 simple name. 지금은 {@code
* prepareStudioCodegenSpec}이 계약에서 {@code x-implements}와 union interface를 파생시켜 고쳤고, 이 테스트가 그 파생 배선이
* 살아 있는지를 지킨다. 파생이 깨지면 여기서 빨간불이 난다.
*
* <p>{@code new ObjectMapper()}는 이 모듈의 다른 테스트와 같은 관용구다 — Jackson 3({@code tools.jackson})이며 앱의 HTTP
* 변환기와 같은 계열이다. Jackson 2 ({@code com.fasterxml.jackson.databind})로 검증하면 프로덕션에서 실제로 쓰이지 않는 경로를 재는
* 셈이라 의미가 없다.
*/
class StudioContractUnionJacksonTest {
private final ObjectMapper mapper = new ObjectMapper();
private static CaseWorkingCopy caseWorkingCopy() {
CaseWorkingCopy document = new CaseWorkingCopy();
document.setKind(CaseWorkingCopy.KindEnum.CASE);
document.setId(UUID.fromString("00000000-0000-4000-8000-000000000001"));
document.setVersion(1);
document.setTitle("제목");
document.setSlug("some-slug");
document.setSummary("요약");
document.setProblem("문제");
document.setConclusion("결론");
document.setEnvironment("환경");
document.setReproduction("재현");
document.setBodyMarkdown("본문");
return document;
}
@Test
void workingCopyUnionSerializesTheContractDiscriminatorAndRoundTrips() {
WorkingCopyDetail detail = new WorkingCopyDetail();
detail.setDocument(caseWorkingCopy());
detail.setDependencyRevision("rev-1");
detail.setNextAction(NextAction.VALIDATE);
String json = mapper.writeValueAsString(detail);
// 판별 필드는 계약이 정한 값이어야 한다. 클래스 이름("CaseWorkingCopy")이 나가면 프론트가 깨진다.
assertThat(json).contains("\"kind\":\"CASE\"");
assertThat(json).doesNotContain("CaseWorkingCopy");
// 판별 필드가 두 번 나가면 안 된다 — union interface가 As.EXISTING_PROPERTY인 이유다.
assertThat(json.split("\"kind\":", -1)).hasSize(2);
// slug는 문자열이다. 계약의 문자열 oneOf를 접지 않으면 여기서 {} 가 나간다.
assertThat(json).contains("\"slug\":\"some-slug\"");
WorkingCopyDetail back = mapper.readValue(json, WorkingCopyDetail.class);
assertThat(back.getDocument()).isInstanceOf(CaseWorkingCopy.class);
assertThat(((CaseWorkingCopy) back.getDocument()).getProblem()).isEqualTo("문제");
}
@Test
void workingCopyInputUnionRoundTripsThroughTheDeclaredUnionType() {
QuestionInput input = new QuestionInput();
input.setKind(QuestionInput.KindEnum.QUESTION);
input.setTitle("질문");
input.setSlug("");
input.setSummary("요약");
input.setNextValidation("다음 검증");
WorkingCopyInput declared = input;
String json = mapper.writeValueAsString(declared);
assertThat(json).contains("\"kind\":\"QUESTION\"");
WorkingCopyInput back = mapper.readValue(json, WorkingCopyInput.class);
assertThat(back).isInstanceOf(QuestionInput.class);
assertThat(((QuestionInput) back).getNextValidation()).isEqualTo("다음 검증");
}
@Test
void renderBlockAndInlineUnionsRoundTripInsideCollections() {
InlineText text = new InlineText();
text.setType(InlineText.TypeEnum.TEXT);
text.setText("본문 조각");
HeadingBlock heading = new HeadingBlock();
heading.setType(HeadingBlock.TypeEnum.HEADING);
heading.setId("h-1");
heading.setLevel(2);
heading.setContent(List.of(text));
CasePublicRenderModel model = new CasePublicRenderModel();
model.setKind(CasePublicRenderModel.KindEnum.CASE);
model.setSlug("some-slug");
model.setTitle("제목");
model.setSummary("요약");
model.setPublicPath("/case/some-slug");
model.setBodyBlocks(List.of(heading));
PublicRenderModel declared = model;
String json = mapper.writeValueAsString(declared);
assertThat(json).contains("\"kind\":\"CASE\"");
assertThat(json).contains("\"type\":\"HEADING\"");
assertThat(json).contains("\"type\":\"TEXT\"");
PublicRenderModel back = mapper.readValue(json, PublicRenderModel.class);
assertThat(back).isInstanceOf(CasePublicRenderModel.class);
List<CaseRenderBlock> blocks = ((CasePublicRenderModel) back).getBodyBlocks();
assertThat(blocks).hasSize(1).first().isInstanceOf(HeadingBlock.class);
List<Inline> content = ((HeadingBlock) blocks.get(0)).getContent();
assertThat(content).hasSize(1).first().isInstanceOf(InlineText.class);
assertThat(((InlineText) content.get(0)).getText()).isEqualTo("본문 조각");
}
@Test
void unionDeserializationRejectsAnUnknownDiscriminatorInsteadOfSilentlyDroppingIt() {
String json = "{\"kind\":\"NOT_A_KIND\",\"title\":\"제목\"}";
assertThat(
org.assertj.core.api.Assertions.catchThrowable(
() -> mapper.readValue(json, WorkingCopy.class)))
.isNotNull();
}
}
@@ -0,0 +1,151 @@
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.config.PresentationWebConfig;
import dev.caskeleton.adapter.inbound.web.envelope.EnvelopeBodyAdvice;
import dev.caskeleton.adapter.inbound.web.error.GlobalExceptionHandler;
import dev.caskeleton.adapter.inbound.web.settings.PresentationSettings;
import dev.caskeleton.adapter.inbound.web.techlog.StudioExceptionHandler;
import dev.caskeleton.application.techlog.studio.port.out.CatalogQueryPort;
import dev.caskeleton.application.techlog.studio.service.ListCatalogUseCase;
import dev.caskeleton.application.transaction.TransactionPort;
import java.util.function.Supplier;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.SpringBootConfiguration;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.security.autoconfigure.SecurityAutoConfiguration;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
/**
* final whole-branch review B4: {@code GET /api/v1/studio/catalog} without its required {@code
* type} query parameter throws {@code MissingServletRequestParameterException}. Neither {@code
* GlobalExceptionHandler} (which we cannot modify — template file) nor the old {@code
* StudioExceptionHandler} handled it, so it fell through to the inherited {@code
* ResponseEntityExceptionHandler} behaviour: a bare {@code ProblemDetail} body with content-type
* {@code application/problem+json}. {@code EnvelopeBodyAdvice#beforeBodyWrite}'s {@code
* MediaType.APPLICATION_JSON.includes(...)} guard then skips wrapping, so a bare RFC 7807 body
* leaks past the envelope — exactly the wire shape ADR-006 says this backend does not use.
*
* <p>{@code ?type=BOGUS} and {@code ?limit=abc} throw {@code MethodArgumentTypeMismatchException}
* instead — {@code GlobalExceptionHandler} does handle that one directly (so the body stays
* enveloped), but with {@code OperationalError.BAD_PARAMETER}, a code outside the 23-code Studio
* contract.
*
* <p>This test pins the fix: a Studio-scoped {@code StudioExceptionHandler} handler moves both
* exceptions to the contract's {@code REQUEST_VALIDATION_FAILED} at 422 (the status the {@code
* StudioError} enum and {@code docs/registries/error-codes.yaml} agree on), enveloped like every
* other Studio failure.
*
* <p>Follows the {@code @WebMvcTest} + hand-built {@code TestBootstrap} slice pattern documented in
* {@link StudioSessionEnvelopeTest} — this module's test source set has no {@code
* CaSkeletonApplication} for {@code @WebMvcTest} to bootstrap from. Security is fully excluded
* (like {@code NoResourceFoundErrorHandlingTest}) since none of these scenarios are
* authentication/authorization-related. {@link ListCatalogUseCase} is real, not mocked (it is a
* {@code final} class and this module's Mockito is not configured with the inline mock maker) —
* built from hand-written fake ports, and its port never runs because request binding fails before
* the controller method body is entered.
*/
@WebMvcTest(
controllers = StudioCatalogController.class,
excludeAutoConfiguration = SecurityAutoConfiguration.class)
@AutoConfigureMockMvc(addFilters = false)
@Import({
PresentationWebConfig.class,
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 {
/**
* PresentationWebConfig 가 이 값으로 모든 컨트롤러 매핑에 "/api" 를 붙인다. 실제 앱의 PRESENTATION_API_BASE_PATH 와 같은
* 값이라, 아래 테스트들이 호출하는 /api/v1/... 은 컨트롤러가 선언한 /v1/... 에 prefix 가 적용된 결과다 — 컨트롤러가 "/api" 를 다시
* 선언하면 /api/api/... 로 밀려 이 테스트들이 404 로 깨진다.
*/
@Bean
PresentationSettings presentationSettings() {
return new PresentationSettings("/api");
}
@Bean
ListCatalogUseCase listCatalogUseCase() {
CatalogQueryPort neverInvoked =
(type, query, cursor, limit) -> {
throw new AssertionError(
"ListCatalogUseCase must not run when request binding already failed");
};
return new ListCatalogUseCase(neverInvoked, new PassthroughTransactionPort());
}
}
/** Runs the action synchronously with no real transactional semantics — a slice test fake. */
private static final class PassthroughTransactionPort implements TransactionPort {
@Override
public <T> T inWrite(Supplier<T> action) {
return action.get();
}
@Override
public <T> T inRootWrite(Supplier<T> action) {
return action.get();
}
@Override
public <T> T inRead(Supplier<T> action) {
return action.get();
}
@Override
public <T> T inNew(Supplier<T> action) {
return action.get();
}
}
@SpringBootConfiguration
@EnableAutoConfiguration(exclude = SecurityAutoConfiguration.class)
static class TestBootstrap {}
}
@@ -0,0 +1,106 @@
package dev.caskeleton.adapter.inbound.web.techlog.studio.controller;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import dev.caskeleton.adapter.inbound.web.auth.AuthenticatedPrincipal;
import dev.caskeleton.adapter.inbound.web.settings.SecuritySettings;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.StudioSession;
import dev.caskeleton.application.techlog.error.StudioError;
import dev.caskeleton.application.techlog.error.StudioException;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
import org.junit.jupiter.api.Test;
import org.springframework.security.web.csrf.DefaultCsrfToken;
class StudioSessionControllerTest {
private final StudioSessionController controller =
new StudioSessionController(securitySettingsWithCsrfHeaderName("X-CSRF-TOKEN"));
@Test
void reportsAuthenticatedPrincipalAndCsrfToken() {
StudioSession session =
controller.getStudioSession(
new AuthenticatedPrincipal("sub-1", "donghyeon@example.com", Set.of("STUDIO_EDITOR")),
new DefaultCsrfToken("X-CSRF-TOKEN", "_csrf", "token-value"));
assertThat(session.getAuthenticated()).isTrue();
assertThat(session.getDisplayName()).isEqualTo("donghyeon@example.com");
assertThat(session.getRoles()).containsExactly("STUDIO_EDITOR");
assertThat(session.getCsrfToken()).isEqualTo("token-value");
assertThat(session.getCsrfHeaderName()).isEqualTo("X-CSRF-TOKEN");
}
@Test
void fallsBackToIdpUserIdWhenEmailIsAbsent() {
StudioSession session =
controller.getStudioSession(
new AuthenticatedPrincipal("sub-1", null, Set.of()),
new DefaultCsrfToken("X-CSRF-TOKEN", "_csrf", "t"));
assertThat(session.getDisplayName()).isEqualTo("sub-1");
}
@Test
void rejectsAMisconfiguredCsrfHeaderNameAtConstructionRatherThanServingTheWrongHeader() {
assertThatThrownBy(() -> new StudioSessionController(securitySettingsWithCsrfHeaderName(null)))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("X-CSRF-TOKEN")
.hasMessageContaining("X-XSRF-TOKEN");
}
@Test
void reportsStudioUnavailableRatherThanNpeWhenCsrfTokenIsNull() {
assertThatThrownBy(
() ->
controller.getStudioSession(
new AuthenticatedPrincipal(
"sub-1", "donghyeon@example.com", Set.of("STUDIO_EDITOR")),
null))
.isInstanceOf(StudioException.class)
.extracting(ex -> ((StudioException) ex).studioError())
.isEqualTo(StudioError.STUDIO_UNAVAILABLE);
}
@Test
void reportsStudioUnavailableRatherThanTruncatingRolesBeyondTheContractMax() {
Set<String> tooManyRoles =
IntStream.range(0, 21).mapToObj(i -> "ROLE_" + i).collect(Collectors.toUnmodifiableSet());
assertThatThrownBy(
() ->
controller.getStudioSession(
new AuthenticatedPrincipal("sub-1", "donghyeon@example.com", tooManyRoles),
new DefaultCsrfToken("X-CSRF-TOKEN", "_csrf", "t")))
.isInstanceOf(StudioException.class)
.extracting(ex -> ((StudioException) ex).studioError())
.isEqualTo(StudioError.STUDIO_UNAVAILABLE);
}
@Test
void reportsStudioUnavailableRatherThanAnEmptyDisplayNameWhenEmailAndIdpUserIdAreBothBlank() {
assertThatThrownBy(
() ->
controller.getStudioSession(
new AuthenticatedPrincipal(" ", " ", Set.of()),
new DefaultCsrfToken("X-CSRF-TOKEN", "_csrf", "t")))
.isInstanceOf(StudioException.class)
.extracting(ex -> ((StudioException) ex).studioError())
.isEqualTo(StudioError.STUDIO_UNAVAILABLE);
}
private static SecuritySettings securitySettingsWithCsrfHeaderName(String csrfHeaderName) {
SecuritySettings.SessionCookieSettings session =
new SecuritySettings.SessionCookieSettings(
null, null, null, null, null, null, csrfHeaderName);
return new SecuritySettings(
SecuritySettings.AuthenticationMode.JWT,
"https://issuer.example",
null,
List.of(),
session);
}
}
@@ -0,0 +1,129 @@
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.config.PresentationWebConfig;
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.PresentationSettings;
import dev.caskeleton.adapter.inbound.web.settings.SecuritySettings;
import dev.caskeleton.adapter.inbound.web.techlog.StudioExceptionHandler;
import java.util.List;
import java.util.Set;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.slf4j.MDC;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.SpringBootConfiguration;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Import;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.test.web.servlet.MockMvc;
/**
* 프로덕션 JWT auth-mode를 재현한다: {@code SecurityConfig.filterChain}의 JWT 분기는 {@code csrf(csrf ->
* csrf.disable())}로 {@code CsrfConfigurer} 자체를 제거한다 — {@code CsrfFilter}가 돌지 않고 {@code CsrfToken}
* request attribute를 아무도 채우지 않는다.
*
* <p>그런데 {@code CsrfTokenArgumentResolver}(Spring Security 7.0.0, {@code
* WebMvcSecurityConfiguration.addArgumentResolvers}가 {@code @EnableWebSecurity}만 있으면 무조건 등록한다)의
* {@code resolveArgument}는 request attribute를 캐스팅만 할 뿐 null 체크가 없다 — attribute가 없으면 그냥 {@code
* null}을 돌려준다. 그래서 JWT 모드에서는 컨트롤러의 {@code CsrfToken csrfToken} 파라미터가 **항상 null**이다.
*
* <p>이 테스트는 그 사실을 슬라이스 필터체인으로 직접 재현한다 — {@link StudioSessionEnvelopeTest}의 {@code
* SecurityTestConfig}(CSRF 켜짐, Spring 기본값)와 정확히 반대다. 일부러 {@code
* SecurityMockMvcRequestPostProcessors.csrf()}를 쓰지 않는다 — 그 포스트 프로세서는 실제 필터체인 여부와 무관하게 request
* attribute를 직접 채워버려서, 쓰면 이 재현이 무력화된다(CSRF가 꺼져 있어도 토큰이 채워진 것처럼 보이게 된다).
*/
@WebMvcTest(controllers = StudioSessionController.class)
@Import({
PresentationWebConfig.class,
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 {
/**
* PresentationWebConfig 가 이 값으로 모든 컨트롤러 매핑에 "/api" 를 붙인다. 실제 앱의 PRESENTATION_API_BASE_PATH 와 같은
* 값이라, 아래 테스트들이 호출하는 /api/v1/... 은 컨트롤러가 선언한 /v1/... 에 prefix 가 적용된 결과다 — 컨트롤러가 "/api" 를 다시
* 선언하면 /api/api/... 로 밀려 이 테스트들이 404 로 깨진다.
*/
@Bean
PresentationSettings presentationSettings() {
return new PresentationSettings("/api");
}
@Bean
SecurityFilterChain csrfDisabledFilterChain(HttpSecurity http) throws Exception {
http.csrf(csrf -> csrf.disable())
.authorizeHttpRequests(auth -> auth.anyRequest().authenticated());
return http.build();
}
@Bean
SecuritySettings securitySettings() {
SecuritySettings.SessionCookieSettings session =
new SecuritySettings.SessionCookieSettings(
null, null, null, null, null, null, "X-CSRF-TOKEN");
return new SecuritySettings(
SecuritySettings.AuthenticationMode.JWT,
"https://issuer.example",
null,
List.of(),
session);
}
}
@SpringBootConfiguration
@EnableAutoConfiguration
static class TestBootstrap {}
}
@@ -0,0 +1,154 @@
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.config.PresentationWebConfig;
import dev.caskeleton.adapter.inbound.web.envelope.EnvelopeBodyAdvice;
import dev.caskeleton.adapter.inbound.web.observability.MdcKeys;
import dev.caskeleton.adapter.inbound.web.settings.PresentationSettings;
import dev.caskeleton.adapter.inbound.web.settings.SecuritySettings;
import java.util.List;
import java.util.Set;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.slf4j.MDC;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.SpringBootConfiguration;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Import;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.test.web.servlet.MockMvc;
/**
* 응답이 봉투로 정확히 한 번 감싸이는지 고정한다. 두 번 감싸이면 프론트가 조용히 깨진다. CSRF가 **켜진** 필터체인(Spring 기본값)에서의 해피 패스만 다룬다 —
* CSRF가 **꺼진**(프로덕션 JWT 모드와 같은 모양) 경로는 {@link StudioSessionCsrfDisabledTest}가 별도로 고정한다. 두 필터체인을 한
* 테스트 클래스에 같이 둘 수 없다({@code @WebMvcTest}는 클래스당 Spring 컨텍스트 하나뿐이라 {@code SecurityFilterChain} 빈도
* 하나뿐이다).
*
* <p>이 모듈(adapter:inbound:web)의 테스트 소스셋에는 {@code CaSkeletonApplication}이 없다 — 그건 app-bootstrap 모듈
* 소유다. 그래서 {@code @WebMvcTest}가 컨텍스트를 부트스트랩할 {@code @SpringBootConfiguration}을 못 찾는다. {@link
* dev.caskeleton.adapter.inbound.web.envelope.EnvelopeBodyAdviceTest}와 {@link
* dev.caskeleton.adapter.inbound.web.error.NoResourceFoundErrorHandlingTest}가 쓰는 것과 같은 방식으로 — 테스트
* 전용 nested {@code TestBootstrap}을 두고 필요한 빈을 명시적으로 {@code @Import}한다.
*
* <p>보안: 이 슬라이스는 실제 {@code SecurityConfig}를 끌어오지 않는다(그건 {@code CorsSettings}, {@code
* JwtToAuthenticatedPrincipalConverter} 등 앱 전체 배선이 필요해서 슬라이스 테스트에 과하다). 이 저장소에 이 조합 (WebMvcTest +
* 실제 보안 필터 체인 + AuthenticatedPrincipal)의 선례가 없다 — {@code SecurityModeWebContractTest}는 {@code
* WebApplicationContextRunner}+standalone MockMvc를 쓰지 slice가 아니고, {@code
* EnvelopeBodyAdviceTest}/{@code NoResourceFoundErrorHandlingTest}는 반대로 Security를 통째로 배제한다 ({@code
* excludeAutoConfiguration = SecurityAutoConfiguration.class} + {@code addFilters = false}). 그래서
* 브리프가 제시한 fallback을 따른다 — {@code @WithMockUser} 대신 {@code
* SecurityMockMvcRequestPostProcessors.csrf()}와 커스텀 {@code authentication(...)}을 쓴다.
* {@code @WithMockUser}는 principal을 {@code org.springframework.security.core.userdetails.User}로
* 채우는데 컨트롤러가 기대하는 타입은 {@code AuthenticatedPrincipal}이라 타입 불일치로 {@code @AuthenticationPrincipal}이
* null을 주입하고 컨트롤러가 NPE를 던진다 — 실측으로 확인했다(task-8 report 참조).
*
* <p>{@code @WebMvcTest}는 표준 {@code @EnableAutoConfiguration}을
* {@code @OverrideAutoConfiguration(enabled = false)}로 끄고 test-slice 전용의 제한된 auto-configuration 목록만
* 적용한다 — 그 목록은 Boot의 {@code ServletWebSecurityAutoConfiguration}(기본 {@code SecurityFilterChain} +
* {@code @EnableWebSecurity})을 포함하지 않는다. 그래서 {@code TestBootstrap}이
* {@code @EnableAutoConfiguration}을 달고 있어도 {@code CsrfToken}/{@code @AuthenticationPrincipal} 인자
* 리졸버가 등록되지 않는다 — 실제로 시도했더니 {@code CsrfToken}이 인자 리졸버 없이 {@code @ModelAttribute} 데이터바인딩 경로로 떨어져 "No
* primary or single unique constructor found for interface CsrfToken" {@code
* IllegalStateException}으로 500이 났다. 그래서 {@code @EnableWebSecurity}를 이 테스트가 직접 명시적으로 붙인다({@code
* SecurityTestConfig}) — 그래야 그 인자 리졸버들이 등록된다.
*
* <p>{@code meta.traceId}는 프로덕션에서 {@code RequestLoggingFilter}가 MDC에 채운다. 그 필터는 {@code
* UserPrincipalPseudonymizerPort} 빈이 필요하고 자체 테스트({@code RequestLoggingFilterTest})가 이미 있으므로 여기서는
* 재현하지 않는다 — MockMvc가 테스트 스레드에서 동기 실행되는 점을 이용해 MDC를 직접 채운다.
*/
@WebMvcTest(controllers = StudioSessionController.class)
@Import({
PresentationWebConfig.class,
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 {
/**
* PresentationWebConfig 가 이 값으로 모든 컨트롤러 매핑에 "/api" 를 붙인다. 실제 앱의 PRESENTATION_API_BASE_PATH 와 같은
* 값이라, 아래 테스트들이 호출하는 /api/v1/... 은 컨트롤러가 선언한 /v1/... 에 prefix 가 적용된 결과다 — 컨트롤러가 "/api" 를 다시
* 선언하면 /api/api/... 로 밀려 이 테스트들이 404 로 깨진다.
*/
@Bean
PresentationSettings presentationSettings() {
return new PresentationSettings("/api");
}
@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 {}
}
@@ -0,0 +1,223 @@
package dev.caskeleton.adapter.inbound.web.techlog.studio.render;
import static org.assertj.core.api.Assertions.assertThat;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.BlockquoteBlock;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.CalloutBlock;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.CaseRenderBlock;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.CodeBlock;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.DataTableBlock;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.EvidenceFigureBlock;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.HeadingBlock;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.InlineCode;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.InlineStrong;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.InlineText;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.ParagraphBlock;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.UnorderedListBlock;
import dev.caskeleton.application.techlog.studio.model.ResolvedAssetView;
import dev.caskeleton.application.techlog.studio.port.out.ContentAnalyzerPort;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import org.junit.jupiter.api.Test;
/**
* 렌더러가 설계 05장이 정한 문법을 계약 블록으로 옮기는지 고정한다.
*
* <p>여기서 지키는 것은 "그럴듯하게 렌더링된다"가 아니라 <b>계약 제약을 어기지 않는다</b>이다 — heading level 범위, 표의 최소 열 수, 미해결 Asset
* 을 지어내지 않는 것. 이것들이 깨지면 미리보기는 화면에 나오지만 게시된 문서가 계약을 위반한다.
*/
class StudioContentRendererTest {
private final StudioContentRenderer renderer = new StudioContentRenderer();
private static ResolvedAssetView asset(String key, boolean decorative) {
return new ResolvedAssetView(
UUID.fromString("00000000-0000-4000-8000-000000000009"),
key,
"image/png",
"/media/00000000-0000-4000-8000-000000000009",
800,
600,
decorative);
}
@Test
void headingsGetContractLevelsAndStableIds() {
var rendered =
renderer.render(
"""
# Authorization Code Flow
###### 아주 깊은 제목
## 결론
## 결론
""",
Map.of());
List<CaseRenderBlock> blocks = rendered.blocks();
assertThat(blocks).hasSize(4).allMatch(HeadingBlock.class::isInstance);
// 계약의 HeadingBlock.level 은 2..4 다. h1 과 h6 를 그대로 내보내면 계약 위반이다.
assertThat(((HeadingBlock) blocks.get(0)).getLevel()).isEqualTo(2);
assertThat(((HeadingBlock) blocks.get(1)).getLevel()).isEqualTo(4);
assertThat(((HeadingBlock) blocks.get(0)).getId()).isEqualTo("authorization-code-flow");
assertThat(((HeadingBlock) blocks.get(2)).getId()).isEqualTo("결론");
// 같은 제목이 두 번이면 두 번째부터 suffix 가 붙는다(설계 05장 §6 8단계).
assertThat(((HeadingBlock) blocks.get(3)).getId()).isEqualTo("결론-2");
}
@Test
void headingIdFollowsTheDesignedNormalisation() {
var rendered = renderer.render("## JPA N+1 문제\n", Map.of());
assertThat(((HeadingBlock) rendered.blocks().getFirst()).getId()).isEqualTo("jpa-n-1-문제");
}
@Test
void inlineMarkupBecomesTheContractInlineUnion() {
var rendered = renderer.render("본문 **강조** 와 `code` 조각\n", Map.of());
ParagraphBlock paragraph = (ParagraphBlock) rendered.blocks().getFirst();
assertThat(paragraph.getContent()).hasSize(5);
assertThat(paragraph.getContent().get(0)).isInstanceOf(InlineText.class);
assertThat(paragraph.getContent().get(1)).isInstanceOf(InlineStrong.class);
assertThat(paragraph.getContent().get(3)).isInstanceOf(InlineCode.class);
assertThat(((InlineCode) paragraph.getContent().get(3)).getCode()).isEqualTo("code");
}
@Test
void listsCodeAndTablesBecomeTheirContractBlocks() {
var rendered =
renderer.render(
"""
- 첫째
- 둘째
```java
int x = 1;
```
| 이름 | 값 |
| --- | ---: |
| a | 1 |
""",
Map.of());
List<CaseRenderBlock> blocks = rendered.blocks();
assertThat(blocks.get(0)).isInstanceOf(UnorderedListBlock.class);
assertThat(((UnorderedListBlock) blocks.get(0)).getItems()).hasSize(2);
CodeBlock code = (CodeBlock) blocks.get(1);
assertThat(code.getLanguage()).isEqualTo("java");
assertThat(code.getCode()).contains("int x = 1;");
DataTableBlock table = (DataTableBlock) blocks.get(2);
assertThat(table.getColumns()).hasSize(2);
assertThat(table.getColumns().get(1).getAlignment())
.isEqualTo(
dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.DataTableColumn
.AlignmentEnum.RIGHT);
assertThat(table.getRows()).hasSize(1);
assertThat(table.getRows().getFirst().getCells().getFirst().getColumnId()).isEqualTo("col-1");
}
@Test
void calloutDirectivesBecomeCalloutBlocksWithTheContractTone() {
var rendered =
renderer.render(
"""
:::warning 주의
Presigned URL 을 본문에 저장하지 않습니다.
:::
:::note
확인했습니다.
:::
""",
Map.of());
CalloutBlock warning = (CalloutBlock) rendered.blocks().get(0);
assertThat(warning.getTone()).isEqualTo(CalloutBlock.ToneEnum.WARNING);
assertThat(warning.getLabel()).isEqualTo("주의");
CalloutBlock note = (CalloutBlock) rendered.blocks().get(1);
// 설계는 note/tip/warning/danger 네 가지를 허용하지만 계약의 tone 은 두 가지다.
assertThat(note.getTone()).isEqualTo(CalloutBlock.ToneEnum.INFO);
}
@Test
void anUnknownDirectiveDegradesToAQuoteAndWarnsInsteadOfBeingSilentlyReinterpreted() {
var rendered =
renderer.render(
"""
:::mermaid
graph TD;
:::
""",
Map.of());
assertThat(rendered.blocks().getFirst()).isInstanceOf(BlockquoteBlock.class);
assertThat(rendered.warnings()).contains("UNSUPPORTED_DIRECTIVE:mermaid");
}
@Test
void evidenceDirectiveResolvesThroughTheInjectedAssetManifest() {
var rendered =
renderer.render(
":::evidence key=\"flow\" alt=\"요청 흐름\" caption=\"흐름도\" zoom=\"true\"\n",
Map.of("flow", asset("flow", false)));
EvidenceFigureBlock figure = (EvidenceFigureBlock) rendered.blocks().getFirst();
assertThat(figure.getKey()).isEqualTo("flow");
assertThat(figure.getAlt()).isEqualTo("요청 흐름");
assertThat(figure.getCaption()).isEqualTo("흐름도");
assertThat(figure.getZoom()).isTrue();
assertThat(figure.getAsset().getPublicPath())
.isEqualTo("/media/00000000-0000-4000-8000-000000000009");
}
@Test
void anUnresolvedAssetKeyIsDroppedWithAWarningRatherThanPointedAtNothing() {
var rendered = renderer.render(":::evidence key=\"missing\" alt=\"x\"\n", Map.of());
// 임의 경로를 지어내면 렌더 결과가 존재하지 않는 파일을 가리킨다.
assertThat(rendered.blocks()).isEmpty();
assertThat(rendered.warnings()).contains("UNRESOLVED_ASSET_KEY:missing");
}
@Test
void directiveSyntaxInsideACodeFenceIsNotADirective() {
var rendered =
renderer.render(
"""
```markdown
:::evidence key="example"
```
""",
Map.of());
assertThat(rendered.blocks().getFirst()).isInstanceOf(CodeBlock.class);
assertThat(rendered.warnings()).isEmpty();
}
@Test
void analysisReportsEveryUsageSiteSeparatelyBecauseAltIsPerUsage() {
ContentAnalyzerPort.ContentAnalysis analysis =
renderer.analyze(
"""
:::evidence key="flow" alt="첫 번째"
:::evidence key="flow" alt=""
:::mermaid
:::
""");
assertThat(analysis.assetUsages())
.extracting(ContentAnalyzerPort.AssetUsage::assetKey)
.containsExactly("flow", "flow");
assertThat(analysis.assetUsages().get(0).alt()).isEqualTo("첫 번째");
assertThat(analysis.assetUsages().get(1).alt()).isEmpty();
assertThat(analysis.unsupportedDirectives()).containsExactly("mermaid");
}
}
@@ -26,6 +26,12 @@ dependencies {
implementation project(':shared-contract') implementation project(':shared-contract')
implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
// Studio 편집본의 jsonb 컬럼(reference_detail.rules/examples, open_question.options,
// project_decision.consequences, applies_to/excluded_scope)을 읽고 쓰려면 이 모듈에 JSON 매퍼가
// 필요하다. Jackson 2 databind 가 data-jpa 경유로 이미 classpath 에 딸려오지만 그건 선언하지 않은
// 우연한 가용성이고, 이 저장소는 dependency locking 을 쓴다 — 앱의 다른 계층과 같은
// Jackson 3(tools.jackson)을 명시적으로 선언한다.
implementation 'org.springframework.boot:spring-boot-starter-jackson'
// feature-distributed-lock-contract: Spring Integration JDBC LockRegistry backs the // feature-distributed-lock-contract: Spring Integration JDBC LockRegistry backs the
// multi-instance distributedLockProvider. Version managed by Spring Boot BOM. // multi-instance distributedLockProvider. Version managed by Spring Boot BOM.
implementation 'org.springframework.integration:spring-integration-jdbc' implementation 'org.springframework.integration:spring-integration-jdbc'
@@ -107,6 +113,30 @@ def postgresqlFileserverMetadataIntegrationTest = registerPostgreSqlReadinessTes
def postgresqlFileserverReclamationIntegrationTest = registerPostgreSqlReadinessTest( def postgresqlFileserverReclamationIntegrationTest = registerPostgreSqlReadinessTest(
'postgresqlFileserverReclamationIntegrationTest', 'postgresqlFileserverReclamationIntegrationTest',
'dev.caskeleton.adapter.outbound.persistence.readiness.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')
// 슬라이스 2~5: Studio 영속 경로 전체(편집본 4종 왕복, 낙관적 잠금, union 목록, 의존 해석,
// validation/preview artifact, 게시 20단계, 게시 취소, Asset)를 실제 PostgreSQL 위에서 돌린다.
// 표준 check 는 Testcontainers 를 돌리지 않으므로, 이 태스크가 없으면 그 SQL 은 한 번도 실행되지
// 않은 채로 빌드가 통과한다.
def postgresqlTechLogStudioPersistenceIntegrationTest = registerPostgreSqlReadinessTest(
'postgresqlTechLogStudioPersistenceIntegrationTest',
'dev.caskeleton.adapter.outbound.persistence.techlog.studio.StudioPersistenceIntegrationTest')
// public-v1: 공개 조회 영속 경로(사이트/홈/프로필, 탐색 2종, 주제, 문서 3종, 프로젝트 4종, 릴리스 2종,
// 검색)와 V9 스키마를 실제 PostgreSQL 위에서 돌린다. 같은 이유다 — 표준 check 는 Testcontainers 를
// 돌리지 않으므로 이 태스크가 없으면 그 SQL 은 한 번도 실행되지 않은 채로 빌드가 통과한다.
def postgresqlTechLogPublicPersistenceIntegrationTest = registerPostgreSqlReadinessTest(
'postgresqlTechLogPublicPersistenceIntegrationTest',
'dev.caskeleton.adapter.outbound.persistence.techlog.publicsite.PublicSitePersistenceIntegrationTest')
def verifyJpaSqlConstructionSafety = tasks.register('verifyJpaSqlConstructionSafety') { def verifyJpaSqlConstructionSafety = tasks.register('verifyJpaSqlConstructionSafety') {
group = 'verification' group = 'verification'
@@ -152,7 +152,7 @@ org.springframework.boot:spring-boot-flyway:4.0.0=compileClasspath,postgresqlInt
org.springframework.boot:spring-boot-hibernate:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-hibernate:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-http-client:4.0.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-http-client:4.0.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-http-converter:4.0.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-http-converter:4.0.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-jackson:4.0.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-jackson:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-jdbc:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-jdbc:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-jpa:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-jpa:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-persistence:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-persistence:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
@@ -163,7 +163,7 @@ org.springframework.boot:spring-boot-sql:4.0.0=compileClasspath,postgresqlIntegr
org.springframework.boot:spring-boot-starter-data-jpa:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-data-jpa:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-flyway:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-flyway:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-jackson:4.0.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-jackson:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-jdbc:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-jdbc:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-logging:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-logging:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-test:4.0.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-test:4.0.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
@@ -205,7 +205,7 @@ org.testcontainers:testcontainers:2.0.2=postgresqlIntegrationTestCompileClasspat
org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs
org.xmlunit:xmlunit-core:2.10.4=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.xmlunit:xmlunit-core:2.10.4=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.yaml:snakeyaml:2.5=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.yaml:snakeyaml:2.5=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
tools.jackson.core:jackson-core:3.0.2=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath tools.jackson.core:jackson-core:3.0.2=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
tools.jackson.core:jackson-databind:3.0.2=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath tools.jackson.core:jackson-databind:3.0.2=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
tools.jackson:jackson-bom:3.0.2=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath tools.jackson:jackson-bom:3.0.2=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
empty= empty=
@@ -12,9 +12,8 @@ import org.springframework.boot.context.properties.ConfigurationProperties;
* *
* <p>Binding to an enum is what makes an unknown vendor a startup failure. With a raw string the * <p>Binding to an enum is what makes an unknown vendor a startup failure. With a raw string the
* two {@code @ConditionalOnProperty} vendor configurations would both stay off, and the first * two {@code @ConditionalOnProperty} vendor configurations would both stay off, and the first
* missing SPI bean would surface as a {@code NoSuchBeanDefinitionException} naming * missing SPI bean would surface as a {@code NoSuchBeanDefinitionException} naming {@code
* {@code OutboxClaimRepository} — a symptom several layers away from the misspelled value that * OutboxClaimRepository} — a symptom several layers away from the misspelled value that caused it.
* caused it.
*/ */
@ConfigurationProperties(prefix = PersistenceVendorSettings.PREFIX) @ConfigurationProperties(prefix = PersistenceVendorSettings.PREFIX)
public record PersistenceVendorSettings(Vendor vendor) { public record PersistenceVendorSettings(Vendor vendor) {
@@ -12,8 +12,8 @@ import org.jspecify.annotations.Nullable;
* H2 atomic scope claim. * H2 atomic scope claim.
* *
* <p>H2 has no {@code INSERT ... ON CONFLICT ... DO UPDATE ... RETURNING}, so the PostgreSQL * <p>H2 has no {@code INSERT ... ON CONFLICT ... DO UPDATE ... RETURNING}, so the PostgreSQL
* statement does not port. The standard {@code MERGE ... USING} does, and carries the same * statement does not port. The standard {@code MERGE ... USING} does, and carries the same meaning
* meaning in one statement: * in one statement:
* *
* <ul> * <ul>
* <li>no row for the scope → {@code WHEN NOT MATCHED} inserts the claim (1 row); * <li>no row for the scope → {@code WHEN NOT MATCHED} inserts the claim (1 row);
@@ -15,12 +15,12 @@ import org.springframework.jdbc.core.JdbcOperations;
* <li><b>Session scope, not transaction scope.</b> PostgreSQL takes {@code set_config(..., true)} * <li><b>Session scope, not transaction scope.</b> PostgreSQL takes {@code set_config(..., true)}
* — a value that reverts at transaction end. H2's {@code SET} is session-wide and outlives * — a value that reverts at transaction end. H2's {@code SET} is session-wide and outlives
* the transaction on a pooled connection. It is not left stale in practice because the * the transaction on a pooled connection. It is not left stale in practice because the
* transaction port applies these before every transaction, so each one overwrites the last; * transaction port applies these before every transaction, so each one overwrites the last; a
* a connection borrowed outside that path keeps the previous transaction's guard. * connection borrowed outside that path keeps the previous transaction's guard.
* <li><b>No idle-in-transaction guard.</b> H2 has no counterpart to * <li><b>No idle-in-transaction guard.</b> H2 has no counterpart to {@code
* {@code idle_in_transaction_session_timeout}, so that budget cannot be pushed into the * idle_in_transaction_session_timeout}, so that budget cannot be pushed into the database
* database here. It is left to the caller-side deadline the transaction port already * here. It is left to the caller-side deadline the transaction port already enforces, rather
* enforces, rather than silently reported as applied. * than silently reported as applied.
* </ul> * </ul>
* *
* <p>The millisecond values are inlined because H2's {@code SET} takes no bind parameter. They * <p>The millisecond values are inlined because H2's {@code SET} takes no bind parameter. They
@@ -15,13 +15,13 @@ import org.springframework.jdbc.core.JdbcOperations;
/** /**
* H2 vendor persistence configuration — the same four SPI beans the PostgreSQL vendor registers, * H2 vendor persistence configuration — the same four SPI beans the PostgreSQL vendor registers,
* implemented against H2. Selected by {@code ca-skeleton.persistence.vendor=h2}, which the * implemented against H2. Selected by {@code ca-skeleton.persistence.vendor=h2}, which the {@code
* {@code local} profile sets. * local} profile sets.
* *
* <p><b>No Flyway location customizer, deliberately.</b> The PostgreSQL vendor points Flyway at * <p><b>No Flyway location customizer, deliberately.</b> The PostgreSQL vendor points Flyway at
* {@code classpath:db/migration/postgresql}; there is no H2 equivalent tree, because the local * {@code classpath:db/migration/postgresql}; there is no H2 equivalent tree, because the local
* profile turns Flyway off and lets Hibernate derive the schema from the entities. Two * profile turns Flyway off and lets Hibernate derive the schema from the entities. Two consequences
* consequences worth stating out loud: * worth stating out loud:
* *
* <ul> * <ul>
* <li>Tables that exist only in migrations — the capability schema registry, the polling-delivery * <li>Tables that exist only in migrations — the capability schema registry, the polling-delivery
@@ -30,12 +30,12 @@ import org.springframework.jdbc.core.JdbcOperations;
* there will fail on a missing table rather than silently misbehave. * there will fail on a missing table rather than silently misbehave.
* <li>A fork that enables Flyway while this vendor is selected gets no location override, so * <li>A fork that enables Flyway while this vendor is selected gets no location override, so
* Flyway falls back to {@code classpath:db/migration} and walks the whole tree — including * Flyway falls back to {@code classpath:db/migration} and walks the whole tree — including
* PostgreSQL DDL H2 cannot parse. Such a fork should register its own * PostgreSQL DDL H2 cannot parse. Such a fork should register its own {@code
* {@code FlywayConfigurationCustomizer} naming an H2 location. * FlywayConfigurationCustomizer} naming an H2 location.
* </ul> * </ul>
* *
* <p>Local therefore verifies wiring and behaviour, not migrations. Migration and vendor-concurrency * <p>Local therefore verifies wiring and behaviour, not migrations. Migration and
* fidelity stay with the real-PostgreSQL integration suites. * vendor-concurrency fidelity stay with the real-PostgreSQL integration suites.
*/ */
@Configuration(proxyBeanMethods = false) @Configuration(proxyBeanMethods = false)
@ConditionalOnProperty( @ConditionalOnProperty(
@@ -19,6 +19,8 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.dao.DataIntegrityViolationException; import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
/** /**
* DB-backed {@link IdempotencyStorePort}. {@link #tryBegin} uses the {@code uq_idempotency_scope} * DB-backed {@link IdempotencyStorePort}. {@link #tryBegin} uses the {@code uq_idempotency_scope}
@@ -162,7 +164,14 @@ public class IdempotencyStoreAdapter implements IdempotencyStorePort {
row.getExpiresAt())); row.getExpiresAt()));
} }
/**
* {@code deleteByScope} 는 {@code @Modifying} 벌크 delete 이므로 활성 트랜잭션을 요구한다. 이 메서드는 {@code
* IdempotencyExecutor} 의 실패 경로에서 호출되는데 그 지점에는 트랜잭션이 없다 — 예약 레코드를 지우려다 {@code
* TransactionRequiredException} 을 던져 원래 실패를 덮고 있었다(403 이 500 으로 바뀌고 로그에 원인이 남지 않았다). REQUIRES_NEW
* 인 이유: 정리는 실패한 작업의 롤백에 휩쓸리면 안 된다.
*/
@Override @Override
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void discard(IdempotencyScope scope) { public void discard(IdempotencyScope scope) {
repository.deleteByScope( repository.deleteByScope(
IdempotencyRecordEntityMapper.tenantColumn(scope), IdempotencyRecordEntityMapper.tenantColumn(scope),
@@ -0,0 +1,91 @@
package dev.caskeleton.adapter.outbound.persistence.techlog.artifact;
import dev.caskeleton.application.techlog.studio.model.PublicPreviewView;
import dev.caskeleton.application.techlog.studio.model.RecordKind;
import dev.caskeleton.application.techlog.studio.port.out.PreviewArtifactPort;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Timestamp;
import java.util.Optional;
import java.util.UUID;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Repository;
/**
* {@code studio_preview} 접근.
*
* <p>{@code render_model}은 렌더러가 만든 계약 모양 그대로를 문자열로 저장하고 그대로 돌려준다 — 중간에서 파싱했다 다시 직렬화하면 사용자가 확인한 화면과
* 저장된 화면이 미묘하게 달라질 수 있고, 게시 시점에 그대로 snapshot 으로 옮겨야 하는 값이라 그 차이가 공개 결과까지 간다.
*/
@Repository
public class JdbcPreviewArtifactAdapter implements PreviewArtifactPort {
private final JdbcClient jdbcClient;
public JdbcPreviewArtifactAdapter(JdbcClient jdbcClient) {
this.jdbcClient = jdbcClient;
}
@Override
public Optional<PublicPreviewView> latestFor(RecordKind kind, UUID documentId) {
return jdbcClient
.sql(
selectColumns()
+ " WHERE source_kind = :kind AND source_id = :id"
+ " ORDER BY created_at DESC LIMIT 1")
.param("kind", kind.name())
.param("id", documentId)
.query(JdbcPreviewArtifactAdapter::mapRow)
.optional();
}
@Override
public Optional<PublicPreviewView> findById(UUID previewId) {
return jdbcClient
.sql(selectColumns() + " WHERE preview_id = :id")
.param("id", previewId)
.query(JdbcPreviewArtifactAdapter::mapRow)
.optional();
}
@Override
public PublicPreviewView save(RecordKind kind, PublicPreviewView preview, String principal) {
jdbcClient
.sql(
"INSERT INTO studio_preview (preview_id, source_kind, source_id, source_version,"
+ " validation_id, dependency_revision, render_model, created_at, expires_at,"
+ " created_by)"
+ " VALUES (:previewId, :kind, :sourceId, :sourceVersion, :validationId,"
+ " :dependencyRevision, CAST(:renderModel AS jsonb), :createdAt, :expiresAt,"
+ " :principal)")
.param("previewId", preview.previewId())
.param("kind", kind.name())
.param("sourceId", preview.documentId())
.param("sourceVersion", preview.previewVersion())
.param("validationId", preview.validationId())
.param("dependencyRevision", preview.dependencyRevision())
.param("renderModel", preview.renderModelJson())
.param("createdAt", Timestamp.from(preview.createdAt()))
.param("expiresAt", Timestamp.from(preview.expiresAt()))
.param("principal", principal)
.update();
return preview;
}
private static String selectColumns() {
return "SELECT preview_id, source_id, source_version, validation_id, dependency_revision,"
+ " render_model, created_at, expires_at FROM studio_preview";
}
private static PublicPreviewView mapRow(ResultSet rs, int rowNum) throws SQLException {
return new PublicPreviewView(
rs.getObject("preview_id", UUID.class),
rs.getObject("source_id", UUID.class),
rs.getLong("source_version"),
rs.getObject("validation_id", UUID.class),
rs.getString("dependency_revision"),
rs.getTimestamp("created_at").toInstant(),
rs.getTimestamp("expires_at").toInstant(),
rs.getString("render_model"));
}
}
@@ -0,0 +1,143 @@
package dev.caskeleton.adapter.outbound.persistence.techlog.artifact;
import dev.caskeleton.application.techlog.studio.model.RecordKind;
import dev.caskeleton.application.techlog.studio.model.ValidationIssueView;
import dev.caskeleton.application.techlog.studio.model.ValidationReportView;
import dev.caskeleton.application.techlog.studio.model.ValidationSeverity;
import dev.caskeleton.application.techlog.studio.model.ValidationStatus;
import dev.caskeleton.application.techlog.studio.port.out.ValidationArtifactPort;
import dev.caskeleton.shared.error.MappingException;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Timestamp;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Repository;
import tools.jackson.core.JacksonException;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.node.ArrayNode;
import tools.jackson.databind.node.ObjectNode;
/**
* {@code studio_validation} 접근. 검증 결과는 일급 artifact이며 실행 후 버리지 않는다(spec §7.3).
*
* <p>{@code studio_validation}에는 UPDATE가 없다. 재검증은 새 행이며, 이전 결과는 "그때 이 버전은 이런 상태였다"는 사실로 남는다 — 덮어쓰면
* 게시 시점에 어떤 근거로 통과했는지 되짚을 수 없다.
*/
@Repository
public class JdbcValidationArtifactAdapter implements ValidationArtifactPort {
private final JdbcClient jdbcClient;
private final ObjectMapper objectMapper;
public JdbcValidationArtifactAdapter(JdbcClient jdbcClient, ObjectMapper objectMapper) {
this.jdbcClient = jdbcClient;
this.objectMapper = objectMapper;
}
@Override
public Optional<ValidationReportView> latestFor(RecordKind kind, UUID documentId) {
return jdbcClient
.sql(
selectColumns()
+ " WHERE source_kind = :kind AND source_id = :id"
+ " ORDER BY validated_at DESC LIMIT 1")
.param("kind", kind.name())
.param("id", documentId)
.query(this::mapRow)
.optional();
}
@Override
public Optional<ValidationReportView> findById(UUID validationId) {
return jdbcClient
.sql(selectColumns() + " WHERE validation_id = :id")
.param("id", validationId)
.query(this::mapRow)
.optional();
}
@Override
public ValidationReportView save(RecordKind kind, ValidationReportView report, String principal) {
jdbcClient
.sql(
"INSERT INTO studio_validation (validation_id, source_kind, source_id,"
+ " validated_version, status, issues, dependency_revision, validated_at,"
+ " valid_until, created_by)"
+ " VALUES (:validationId, :kind, :sourceId, :validatedVersion, :status,"
+ " CAST(:issues AS jsonb), :dependencyRevision, :validatedAt, :validUntil,"
+ " :principal)")
.param("validationId", report.validationId())
.param("kind", kind.name())
.param("sourceId", report.documentId())
.param("validatedVersion", report.validatedVersion())
.param("status", report.status().name())
.param("issues", issuesToJson(report.issues()))
.param("dependencyRevision", report.dependencyRevision())
.param("validatedAt", Timestamp.from(report.validatedAt()))
.param("validUntil", Timestamp.from(report.validUntil()))
.param("principal", principal)
.update();
return report;
}
private static String selectColumns() {
return "SELECT validation_id, source_id, validated_version, status, issues,"
+ " dependency_revision, validated_at, valid_until FROM studio_validation";
}
private ValidationReportView mapRow(ResultSet rs, int rowNum) throws SQLException {
return new ValidationReportView(
rs.getObject("validation_id", UUID.class),
rs.getObject("source_id", UUID.class),
rs.getLong("validated_version"),
ValidationStatus.valueOf(rs.getString("status")),
issuesFromJson(rs.getString("issues")),
rs.getTimestamp("validated_at").toInstant(),
rs.getTimestamp("valid_until").toInstant(),
rs.getString("dependency_revision"));
}
private String issuesToJson(List<ValidationIssueView> issues) {
ArrayNode array = objectMapper.createArrayNode();
for (ValidationIssueView issue : issues) {
ObjectNode node = array.addObject();
node.put("code", issue.code());
node.put("severity", issue.severity().name());
node.put("path", issue.path());
node.put("message", issue.message());
}
try {
return objectMapper.writeValueAsString(array);
} catch (JacksonException e) {
throw new MappingException("failed to serialise validation issues", e);
}
}
private List<ValidationIssueView> issuesFromJson(String json) {
if (json == null || json.isBlank()) {
return List.of();
}
try {
JsonNode array = objectMapper.readTree(json);
if (!array.isArray()) {
return List.of();
}
return array
.valueStream()
.map(
node ->
new ValidationIssueView(
node.path("code").asString(""),
ValidationSeverity.valueOf(node.path("severity").asString("ERROR")),
node.path("path").asString(""),
node.path("message").asString("")))
.toList();
} catch (JacksonException e) {
throw new MappingException("failed to read validation issues", e);
}
}
}
@@ -0,0 +1,260 @@
package dev.caskeleton.adapter.outbound.persistence.techlog.publicsite;
import dev.caskeleton.application.techlog.publicsite.model.AssetReferenceView;
import dev.caskeleton.application.techlog.publicsite.model.CaseDetailView;
import dev.caskeleton.application.techlog.publicsite.model.CaseRelationsView;
import dev.caskeleton.application.techlog.publicsite.model.ProjectSummaryView;
import dev.caskeleton.application.techlog.publicsite.model.PublishedDocumentView;
import dev.caskeleton.application.techlog.publicsite.model.PublishedQuestionView;
import dev.caskeleton.application.techlog.publicsite.model.QuestionDetailView;
import dev.caskeleton.application.techlog.publicsite.model.QuestionPointGroupView;
import dev.caskeleton.application.techlog.publicsite.model.QuestionRelationsView;
import dev.caskeleton.application.techlog.publicsite.model.QuestionUpdateView;
import dev.caskeleton.application.techlog.publicsite.model.ReferenceDetailView;
import dev.caskeleton.application.techlog.publicsite.model.ReferenceRelationsView;
import dev.caskeleton.application.techlog.publicsite.model.TagSummaryView;
import dev.caskeleton.application.techlog.publicsite.model.TopicSummaryView;
import dev.caskeleton.application.techlog.publicsite.port.out.PublicDocumentQueryPort;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.time.Instant;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Repository;
import tools.jackson.databind.ObjectMapper;
/**
* 공개된 Case / Reference / Question 상세.
*
* <p>본문은 {@code public_resource_projection.payload}(Studio 렌더 모델)가 아니라 원본 테이블에서 읽는다 — 공개 계약은 블록 배열이
* 아니라 Markdown 원문과 {@code contentFormat} 을 준다. projection 은 "공개됐는가"와 게시 시각을 정하는 데만 쓴다.
*/
@Repository
public class JdbcPublicDocumentQueryAdapter implements PublicDocumentQueryPort {
private final JdbcClient jdbcClient;
private final PublicJson json;
private final PublicRelationLookup relations;
public JdbcPublicDocumentQueryAdapter(JdbcClient jdbcClient, ObjectMapper objectMapper) {
this.jdbcClient = jdbcClient;
this.json = new PublicJson(objectMapper);
this.relations = new PublicRelationLookup(jdbcClient);
}
@Override
public Optional<CaseDetailView> findCase(String slug) {
return document("CASE", slug)
.map(
row ->
new CaseDetailView(
row.view().canonicalPath(),
true,
row.view(),
new CaseRelationsView(
relations.firstTargetOfType("CASE", row.id(), "QUESTION"),
relations.targetsOfType("CASE", row.id(), "PROJECT_DECISION"),
// 이 Case 에서 파생된 Reference 는 역방향이다 — Reference 쪽이 Case 를 가리킨다.
relations.sourcesOfType(row.id(), "REFERENCE"),
relations.targetsOfType("CASE", row.id(), "CASE"))));
}
@Override
public Optional<ReferenceDetailView> findReference(String slug) {
return document("REFERENCE", slug)
.map(
row ->
new ReferenceDetailView(
row.view().canonicalPath(),
true,
row.view(),
new ReferenceRelationsView(
relations.targetsOfType("REFERENCE", row.id(), "CASE"),
relations.targetsOfType("REFERENCE", row.id(), "PROJECT_DECISION"),
relations.targetsOfType("REFERENCE", row.id(), "REFERENCE"))));
}
/** 관계 조회에 문서 id 가 필요한데 계약의 응답에는 id 가 없다. 뷰 밖으로 id 를 새로 노출하지 않고 이 안에서만 함께 나른다. */
private record DocumentRow(UUID id, PublishedDocumentView view) {}
private Optional<DocumentRow> document(String type, String slug) {
return jdbcClient
.sql(
"SELECT d.id, d.title, d.body_markdown, d.content_format,"
+ " d.content_format_version, d.cover_asset_id,"
+ " c.problem_summary, c.conclusion_summary, c.environment_items,"
+ " r.scope_summary, r.applies_to, r.excluded_scope, r.freshness_status,"
+ " p.navigation_path, p.published_at, p.updated_at, p.last_verified_at,"
+ " t.name AS topic_name, t.slug AS topic_slug,"
+ " pr.name AS project_name, pr.slug AS project_slug,"
+ " a.content_type AS cover_content_type, a.alt_text AS cover_alt,"
+ " a.width AS cover_width, a.height AS cover_height"
+ " FROM document d"
+ " JOIN public_resource_projection p"
+ " ON p.resource_type = d.document_type AND p.resource_id = d.id"
+ " LEFT JOIN case_detail c ON c.document_id = d.id"
+ " LEFT JOIN reference_detail r ON r.document_id = d.id"
+ " LEFT JOIN topic t ON t.id = d.primary_topic_id"
+ " LEFT JOIN public_resource_project_link l"
+ " ON l.resource_type = p.resource_type AND l.resource_id = p.resource_id"
+ " AND l.relation_type = 'PRIMARY'"
+ " LEFT JOIN project pr ON pr.id = l.project_id"
+ " LEFT JOIN asset a ON a.id = d.cover_asset_id"
+ " WHERE d.document_type = :type AND d.slug = :slug AND "
+ PublicSql.ACTIVE)
.param("type", type)
.param("slug", slug)
.query(
(rs, rowNum) -> {
UUID id = rs.getObject("id", UUID.class);
boolean isCase = "CASE".equals(type);
return new DocumentRow(
id,
new PublishedDocumentView(
type,
rs.getString("navigation_path"),
rs.getString("title"),
// Case 는 문제/결론, Reference 는 범위/적용이 각각 앞뒤 요약 자리에 온다.
isCase ? rs.getString("problem_summary") : rs.getString("scope_summary"),
isCase ? rs.getString("conclusion_summary") : null,
isCase ? json.strings(rs.getString("environment_items")) : List.of(),
isCase ? List.of() : json.strings(rs.getString("applies_to")),
isCase ? List.of() : json.strings(rs.getString("excluded_scope")),
isCase ? null : rs.getString("freshness_status"),
rs.getString("body_markdown"),
rs.getString("content_format"),
rs.getInt("content_format_version"),
topic(rs),
tags(id),
project(rs),
cover(rs),
instant(rs, "published_at"),
instant(rs, "updated_at"),
instant(rs, "last_verified_at")));
})
.optional();
}
@Override
public Optional<QuestionDetailView> findQuestion(String slug) {
return jdbcClient
.sql(
"SELECT q.id, q.question, q.slug, q.summary, q.context_markdown,"
+ " q.importance_markdown, q.question_status, q.next_verification,"
+ " q.resolution_type, q.resolution_summary, q.resolved_at, q.opened_at,"
+ " p.navigation_path, p.updated_at"
+ " FROM open_question q"
+ " JOIN public_resource_projection p"
+ " ON p.resource_type = 'QUESTION' AND p.resource_id = q.id"
+ " WHERE q.slug = :slug AND "
+ PublicSql.ACTIVE)
.param("slug", slug)
.query(
(rs, rowNum) -> {
UUID id = rs.getObject("id", UUID.class);
PublishedQuestionView question =
new PublishedQuestionView(
rs.getString("question"),
rs.getString("summary"),
rs.getString("context_markdown"),
rs.getString("importance_markdown"),
rs.getString("question_status"),
rs.getString("next_verification"),
new QuestionPointGroupView(
points(id, "FACT"),
points(id, "ASSUMPTION"),
points(id, "UNKNOWN"),
points(id, "CONSTRAINT")),
updates(id),
rs.getString("resolution_type"),
rs.getString("resolution_summary"),
instant(rs, "resolved_at"),
instant(rs, "opened_at"),
instant(rs, "updated_at"));
return new QuestionDetailView(
rs.getString("navigation_path"),
true,
question,
new QuestionRelationsView(
relations.primaryProject("project_question_link", "question_id", id),
relations.firstTargetOfType("QUESTION", id, "CASE"),
relations.firstTargetOfType("QUESTION", id, "PROJECT_DECISION"),
relations.targetsOfType("QUESTION", id, "REFERENCE")));
})
.optional();
}
private List<String> points(UUID questionId, String pointKind) {
return jdbcClient
.sql(
"SELECT content FROM question_point WHERE question_id = :id AND point_kind = :kind"
+ " ORDER BY display_order")
.param("id", questionId)
.param("kind", pointKind)
.query(String.class)
.list();
}
/** 공개된 조사 기록만 보여준다 — {@code PRIVATE} 기록은 Studio 안에만 있다. */
private List<QuestionUpdateView> updates(UUID questionId) {
return jdbcClient
.sql(
"SELECT update_type, title, body_markdown, occurred_at FROM question_update"
+ " WHERE question_id = :id AND update_visibility = 'PUBLIC'"
+ " ORDER BY sequence_no")
.param("id", questionId)
.query(
(rs, rowNum) ->
new QuestionUpdateView(
rs.getString("update_type"),
rs.getString("title"),
rs.getString("body_markdown"),
instant(rs, "occurred_at")))
.list();
}
static Instant instant(ResultSet rs, String column) throws SQLException {
var value = rs.getTimestamp(column);
return value == null ? null : value.toInstant();
}
static TopicSummaryView topic(ResultSet rs) throws SQLException {
return rs.getString("topic_slug") == null
? null
: new TopicSummaryView(rs.getString("topic_name"), rs.getString("topic_slug"));
}
static ProjectSummaryView project(ResultSet rs) throws SQLException {
return rs.getString("project_slug") == null
? null
: new ProjectSummaryView(
rs.getString("project_name"),
rs.getString("project_slug"),
"/projects/" + rs.getString("project_slug"));
}
static AssetReferenceView cover(ResultSet rs) throws SQLException {
UUID assetId = rs.getObject("cover_asset_id", UUID.class);
return assetId == null
? null
: new AssetReferenceView(
assetId,
"/media/" + assetId,
rs.getString("cover_alt"),
(Integer) rs.getObject("cover_width"),
(Integer) rs.getObject("cover_height"),
rs.getString("cover_content_type"));
}
List<TagSummaryView> tags(UUID documentId) {
return jdbcClient
.sql(
"SELECT g.name, g.slug FROM document_tag dt JOIN tag g ON g.id = dt.tag_id"
+ " WHERE dt.document_id = :id ORDER BY dt.display_order")
.param("id", documentId)
.query((rs, rowNum) -> new TagSummaryView(rs.getString("name"), rs.getString("slug")))
.list();
}
}
@@ -0,0 +1,226 @@
package dev.caskeleton.adapter.outbound.persistence.techlog.publicsite;
import dev.caskeleton.application.techlog.publicsite.model.KnowledgeListItemView;
import dev.caskeleton.application.techlog.publicsite.model.KnowledgePageView;
import dev.caskeleton.application.techlog.publicsite.model.PageMetadataView;
import dev.caskeleton.application.techlog.publicsite.model.ProjectSummaryView;
import dev.caskeleton.application.techlog.publicsite.model.QuestionListItemView;
import dev.caskeleton.application.techlog.publicsite.model.QuestionPageView;
import dev.caskeleton.application.techlog.publicsite.model.TopicSummaryView;
import dev.caskeleton.application.techlog.publicsite.port.out.PublicExploreQueryPort;
import dev.caskeleton.application.techlog.publicsite.query.ExploreKnowledgeQuery;
import dev.caskeleton.application.techlog.publicsite.query.ExploreQuestionsQuery;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Repository;
/**
* 탐색 목록.
*
* <p>필터와 정렬을 SQL 로 처리하고 페이지 총계를 같은 조건으로 센다 — 목록과 총계가 다른 조건을 쓰면 마지막 페이지가 비어 보이거나 있지도 않은 페이지 번호가 생긴다.
*/
@Repository
public class JdbcPublicExploreQueryAdapter implements PublicExploreQueryPort {
private final JdbcClient jdbcClient;
public JdbcPublicExploreQueryAdapter(JdbcClient jdbcClient) {
this.jdbcClient = jdbcClient;
}
@Override
public KnowledgePageView knowledge(ExploreKnowledgeQuery query) {
StringBuilder where =
new StringBuilder(
" WHERE " + PublicSql.ACTIVE + " AND p.resource_type IN ('CASE', 'REFERENCE')");
Map<String, Object> params = new HashMap<>();
if (query.type() != null) {
where.append(" AND p.resource_type = :type");
params.put("type", query.type());
}
if (query.topicSlug() != null) {
where.append(" AND t.slug = :topicSlug");
params.put("topicSlug", query.topicSlug());
}
if (query.projectSlug() != null) {
where.append(" AND pr.slug = :projectSlug");
params.put("projectSlug", query.projectSlug());
}
if (query.tagSlug() != null) {
where.append(
" AND EXISTS (SELECT 1 FROM public_resource_tag rt JOIN tag g ON g.id = rt.tag_id"
+ " WHERE rt.resource_type = p.resource_type AND rt.resource_id = p.resource_id"
+ " AND g.slug = :tagSlug)");
params.put("tagSlug", query.tagSlug());
}
if (query.year() != null) {
where.append(" AND date_part('year', p.published_at) = :year");
params.put("year", query.year());
}
String joins =
" FROM public_resource_projection p"
+ " LEFT JOIN topic t ON t.id = p.primary_topic_id"
+ " LEFT JOIN public_resource_project_link l"
+ " ON l.resource_type = p.resource_type AND l.resource_id = p.resource_id"
+ " AND l.relation_type = 'PRIMARY'"
+ " LEFT JOIN project pr ON pr.id = l.project_id";
long total = count(joins + where, params);
List<KnowledgeListItemView> items =
page(
"SELECT p.resource_type, p.title, p.navigation_path, p.summary, p.state_code,"
+ " p.published_at, p.last_verified_at,"
+ " t.name AS topic_name, t.slug AS topic_slug,"
+ " pr.name AS project_name, pr.slug AS project_slug"
+ joins
+ where
+ knowledgeOrder(query.sort()),
params,
query.page().size(),
query.page().offset(),
JdbcPublicExploreQueryAdapter::readKnowledge);
return new KnowledgePageView(
items, PageMetadataView.of(query.page().page(), query.page().size(), total));
}
/** 계약의 정렬 세 값. 같은 시각이 여럿일 때 페이지 경계가 흔들리지 않도록 id 를 tie-breaker 로 둔다. */
private static String knowledgeOrder(String sort) {
String key =
switch (sort == null ? "PUBLISHED_DESC" : sort) {
case "UPDATED_DESC" -> "p.updated_at DESC";
case "VERIFIED_DESC" -> "p.last_verified_at DESC NULLS LAST";
default -> "p.published_at DESC";
};
return " ORDER BY " + key + ", p.resource_id DESC";
}
/**
* 계약 {@code exploreQuestions.sort} 의 세 값. {@code RESOLVED_DESC} 는 미해결 질문에 값이 없으므로 NULLS LAST 로 밀어
* 낸다 — 그러지 않으면 PostgreSQL 의 DESC 기본값 NULLS FIRST 때문에 미해결 질문이 "가장 최근에 해결된 것" 자리에 올라온다.
*/
private static String questionOrder(String sort) {
String key =
switch (sort == null ? "UPDATED_DESC" : sort) {
case "OPENED_DESC" -> "q.opened_at DESC NULLS LAST";
case "RESOLVED_DESC" -> "q.resolved_at DESC NULLS LAST";
default -> "p.updated_at DESC";
};
return " ORDER BY " + key + ", p.resource_id DESC";
}
private static KnowledgeListItemView readKnowledge(ResultSet rs, int rowNum) throws SQLException {
return new KnowledgeListItemView(
rs.getString("resource_type"),
rs.getString("title"),
rs.getString("navigation_path"),
rs.getString("summary"),
null,
rs.getString("topic_slug") == null
? null
: new TopicSummaryView(rs.getString("topic_name"), rs.getString("topic_slug")),
rs.getString("project_slug") == null
? null
: new ProjectSummaryView(
rs.getString("project_name"),
rs.getString("project_slug"),
"/projects/" + rs.getString("project_slug")),
rs.getTimestamp("published_at").toInstant(),
rs.getTimestamp("last_verified_at") == null
? null
: rs.getTimestamp("last_verified_at").toInstant(),
rs.getString("state_code"));
}
@Override
public QuestionPageView questions(ExploreQuestionsQuery query) {
StringBuilder where =
new StringBuilder(" WHERE " + PublicSql.ACTIVE + " AND p.resource_type = 'QUESTION'");
Map<String, Object> params = new HashMap<>();
if (query.status() != null) {
where.append(" AND p.state_code = :status");
params.put("status", query.status());
}
if (query.topicSlug() != null) {
where.append(" AND t.slug = :topicSlug");
params.put("topicSlug", query.topicSlug());
}
if (query.projectSlug() != null) {
where.append(" AND pr.slug = :projectSlug");
params.put("projectSlug", query.projectSlug());
}
if (query.tagSlug() != null) {
where.append(
" AND EXISTS (SELECT 1 FROM public_resource_tag rt JOIN tag g ON g.id = rt.tag_id"
+ " WHERE rt.resource_type = p.resource_type AND rt.resource_id = p.resource_id"
+ " AND g.slug = :tagSlug)");
params.put("tagSlug", query.tagSlug());
}
String joins =
" FROM public_resource_projection p"
+ " JOIN open_question q ON q.id = p.resource_id"
+ " LEFT JOIN topic t ON t.id = p.primary_topic_id"
+ " LEFT JOIN public_resource_project_link l"
+ " ON l.resource_type = p.resource_type AND l.resource_id = p.resource_id"
+ " AND l.relation_type = 'PRIMARY'"
+ " LEFT JOIN project pr ON pr.id = l.project_id";
long total = count(joins + where, params);
List<QuestionListItemView> items =
page(
"SELECT q.question, p.navigation_path, q.question_status, p.summary,"
+ " q.next_verification, p.updated_at,"
+ " pr.name AS project_name, pr.slug AS project_slug"
+ joins
+ where
+ questionOrder(query.sort()),
params,
query.page().size(),
query.page().offset(),
(rs, rowNum) ->
new QuestionListItemView(
rs.getString("question"),
rs.getString("navigation_path"),
rs.getString("question_status"),
rs.getString("summary"),
null,
rs.getString("next_verification"),
rs.getString("project_slug") == null
? null
: new ProjectSummaryView(
rs.getString("project_name"),
rs.getString("project_slug"),
"/projects/" + rs.getString("project_slug")),
rs.getTimestamp("updated_at").toInstant()));
return new QuestionPageView(
items, PageMetadataView.of(query.page().page(), query.page().size(), total));
}
private long count(String fromAndWhere, Map<String, Object> params) {
var spec = jdbcClient.sql("SELECT count(*)" + fromAndWhere);
for (Map.Entry<String, Object> e : params.entrySet()) {
spec = spec.param(e.getKey(), e.getValue());
}
return spec.query(Long.class).single();
}
private <T> List<T> page(
String sql,
Map<String, Object> params,
int size,
int offset,
org.springframework.jdbc.core.RowMapper<T> mapper) {
var spec = jdbcClient.sql(sql + " LIMIT :size OFFSET :offset");
for (Map.Entry<String, Object> e : params.entrySet()) {
spec = spec.param(e.getKey(), e.getValue());
}
return spec.param("size", size).param("offset", offset).query(mapper).list();
}
}
@@ -0,0 +1,330 @@
package dev.caskeleton.adapter.outbound.persistence.techlog.publicsite;
import dev.caskeleton.application.techlog.publicsite.model.PageMetadataView;
import dev.caskeleton.application.techlog.publicsite.model.ProjectActivityItemView;
import dev.caskeleton.application.techlog.publicsite.model.ProjectActivityPageView;
import dev.caskeleton.application.techlog.publicsite.model.ProjectDecisionItemView;
import dev.caskeleton.application.techlog.publicsite.model.ProjectDecisionPageView;
import dev.caskeleton.application.techlog.publicsite.model.ProjectDetailView;
import dev.caskeleton.application.techlog.publicsite.model.ProjectListItemView;
import dev.caskeleton.application.techlog.publicsite.model.ProjectRecordPageView;
import dev.caskeleton.application.techlog.publicsite.model.PublishedProjectView;
import dev.caskeleton.application.techlog.publicsite.model.RelatedEntryView;
import dev.caskeleton.application.techlog.publicsite.port.out.PublicProjectQueryPort;
import dev.caskeleton.application.techlog.publicsite.query.ProjectDecisionPageQuery;
import dev.caskeleton.application.techlog.publicsite.query.ProjectPageQuery;
import dev.caskeleton.application.techlog.publicsite.query.ProjectRecordPageQuery;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Repository;
import tools.jackson.databind.ObjectMapper;
/** 프로젝트 목록·상세와 그 하위 목록. */
@Repository
public class JdbcPublicProjectQueryAdapter implements PublicProjectQueryPort {
private static final int SECTION_LIMIT = 10;
private final JdbcClient jdbcClient;
private final PublicJson json;
public JdbcPublicProjectQueryAdapter(JdbcClient jdbcClient, ObjectMapper objectMapper) {
this.jdbcClient = jdbcClient;
this.json = new PublicJson(objectMapper);
}
@Override
public List<ProjectListItemView> list() {
return jdbcClient
.sql(
"SELECT pr.name, pr.slug, pr.one_line_purpose, pr.phase, pr.current_objective,"
+ " pr.next_step, p.updated_at FROM project pr"
+ " JOIN public_resource_projection p"
+ " ON p.resource_type = 'PROJECT' AND p.resource_id = pr.id"
+ " WHERE "
+ PublicSql.ACTIVE
+ " ORDER BY pr.featured_order NULLS LAST, p.updated_at DESC")
.query(
(rs, rowNum) ->
new ProjectListItemView(
rs.getString("name"),
rs.getString("slug"),
"/projects/" + rs.getString("slug"),
rs.getString("one_line_purpose"),
rs.getString("phase"),
rs.getString("current_objective"),
rs.getString("next_step"),
JdbcPublicDocumentQueryAdapter.instant(rs, "updated_at")))
.list();
}
@Override
public Optional<ProjectDetailView> findBySlug(String slug) {
return jdbcClient
.sql(
"SELECT pr.id, pr.name, pr.slug, pr.one_line_purpose, pr.purpose_markdown,"
+ " pr.boundary_markdown, pr.phase, pr.current_objective, pr.next_step,"
+ " pr.system_overview_markdown, pr.technology_labels,"
+ " p.navigation_path, p.updated_at FROM project pr"
+ " JOIN public_resource_projection p"
+ " ON p.resource_type = 'PROJECT' AND p.resource_id = pr.id"
+ " WHERE pr.slug = :slug AND "
+ PublicSql.ACTIVE)
.param("slug", slug)
.query(
(rs, rowNum) -> {
UUID projectId = rs.getObject("id", UUID.class);
PublishedProjectView project =
new PublishedProjectView(
rs.getString("name"),
rs.getString("slug"),
rs.getString("one_line_purpose"),
rs.getString("purpose_markdown"),
rs.getString("boundary_markdown"),
rs.getString("phase"),
rs.getString("current_objective"),
rs.getString("next_step"),
rs.getString("system_overview_markdown"),
json.strings(rs.getString("technology_labels")),
JdbcPublicDocumentQueryAdapter.instant(rs, "updated_at"));
return new ProjectDetailView(
rs.getString("navigation_path"),
true,
project,
featuredDecision(projectId),
activeQuestion(projectId),
selectedRecords(projectId));
})
.optional();
}
private RelatedEntryView featuredDecision(UUID projectId) {
return jdbcClient
.sql(
"SELECT p.resource_type, p.title, p.summary, p.navigation_path"
+ " FROM project_decision d"
+ " JOIN public_resource_projection p"
+ " ON p.resource_type = 'PROJECT_DECISION' AND p.resource_id = d.id"
+ " WHERE d.project_id = :projectId AND "
+ PublicSql.ACTIVE
+ " ORDER BY d.is_featured DESC, d.decided_at DESC NULLS LAST LIMIT 1")
.param("projectId", projectId)
.query(JdbcPublicTopicQueryAdapter::relatedEntry)
.optional()
.orElse(null);
}
private RelatedEntryView activeQuestion(UUID projectId) {
return jdbcClient
.sql(
"SELECT p.resource_type, p.title, p.summary, p.navigation_path"
+ " FROM project_question_link l"
+ " JOIN public_resource_projection p"
+ " ON p.resource_type = 'QUESTION' AND p.resource_id = l.question_id"
+ " WHERE l.project_id = :projectId AND p.state_code <> 'RESOLVED'"
+ " AND "
+ PublicSql.ACTIVE
+ " ORDER BY p.updated_at DESC LIMIT 1")
.param("projectId", projectId)
.query(JdbcPublicTopicQueryAdapter::relatedEntry)
.optional()
.orElse(null);
}
private List<RelatedEntryView> selectedRecords(UUID projectId) {
return jdbcClient
.sql(
"SELECT p.resource_type, p.title, p.summary, p.navigation_path"
+ " FROM public_resource_project_link l"
+ " JOIN public_resource_projection p"
+ " ON p.resource_type = l.resource_type AND p.resource_id = l.resource_id"
+ " WHERE l.project_id = :projectId AND "
+ PublicSql.ACTIVE
+ " ORDER BY l.featured_order NULLS LAST, p.published_at DESC LIMIT :limit")
.param("projectId", projectId)
.param("limit", SECTION_LIMIT)
.query(JdbcPublicTopicQueryAdapter::relatedEntry)
.list();
}
@Override
public Optional<ProjectDecisionPageView> decisions(ProjectDecisionPageQuery query) {
return projectId(query.projectSlug())
.map(
projectId -> {
// 계약의 status 필터. 총계와 목록이 반드시 같은 조건을 써야 마지막 페이지가 비어 보이지 않는다.
String from =
" FROM project_decision d"
+ " JOIN public_resource_projection p"
+ " ON p.resource_type = 'PROJECT_DECISION' AND p.resource_id = d.id"
+ " WHERE d.project_id = :projectId AND "
+ PublicSql.ACTIVE
+ (query.status() == null ? "" : " AND d.decision_status = :status");
long total =
bind(jdbcClient.sql("SELECT count(*)" + from), projectId, query.status())
.query(Long.class)
.single();
List<ProjectDecisionItemView> items =
bind(
jdbcClient.sql(
"SELECT d.id, d.statement, d.decision_status, d.rationale_markdown,"
+ " d.decided_at, d.source_question_id, d.source_case_id"
+ from
+ " ORDER BY d.decided_at DESC NULLS LAST, d.id DESC"
+ " LIMIT :size OFFSET :offset"),
projectId,
query.status())
.param("size", query.page().size())
.param("offset", query.page().offset())
.query(
(rs, rowNum) ->
new ProjectDecisionItemView(
rs.getObject("id", UUID.class),
rs.getString("statement"),
rs.getString("decision_status"),
rs.getString("rationale_markdown"),
JdbcPublicDocumentQueryAdapter.instant(rs, "decided_at"),
publishedEntry(rs.getObject("source_question_id", UUID.class)),
publishedEntry(rs.getObject("source_case_id", UUID.class))))
.list();
return new ProjectDecisionPageView(
items, PageMetadataView.of(query.page().page(), query.page().size(), total));
});
}
/** 지목된 원천이 비공개면 링크를 만들지 않는다 — 404 로 이어지는 링크를 내보내지 않는다. */
private RelatedEntryView publishedEntry(UUID resourceId) {
if (resourceId == null) {
return null;
}
return jdbcClient
.sql(
"SELECT p.resource_type, p.title, p.summary, p.navigation_path"
+ " FROM public_resource_projection p"
+ " WHERE p.resource_id = :id AND "
+ PublicSql.ACTIVE)
.param("id", resourceId)
.query(JdbcPublicTopicQueryAdapter::relatedEntry)
.optional()
.orElse(null);
}
@Override
public Optional<ProjectRecordPageView> records(ProjectRecordPageQuery query) {
return projectId(query.projectSlug())
.map(
projectId -> {
// 계약이 세는 record 는 CASE/REFERENCE/QUESTION 세 종류다. type 이 없으면 셋 다 센다.
String from =
" FROM public_resource_project_link l"
+ " JOIN public_resource_projection p"
+ " ON p.resource_type = l.resource_type AND p.resource_id = l.resource_id"
+ " WHERE l.project_id = :projectId"
+ " AND p.resource_type IN ('CASE', 'REFERENCE', 'QUESTION')"
+ " AND "
+ PublicSql.ACTIVE
+ (query.type() == null ? "" : " AND p.resource_type = :type")
+ (query.relation() == null ? "" : " AND l.relation_type = :relation");
long total =
bindRecord(jdbcClient.sql("SELECT count(*)" + from), projectId, query)
.query(Long.class)
.single();
List<RelatedEntryView> items =
bindRecord(
jdbcClient.sql(
"SELECT p.resource_type, p.title, p.summary, p.navigation_path"
+ from
+ " ORDER BY p.published_at DESC, p.resource_id DESC"
+ " LIMIT :size OFFSET :offset"),
projectId,
query)
.param("size", query.page().size())
.param("offset", query.page().offset())
.query(JdbcPublicTopicQueryAdapter::relatedEntry)
.list();
return new ProjectRecordPageView(
items, PageMetadataView.of(query.page().page(), query.page().size(), total));
});
}
@Override
public Optional<ProjectActivityPageView> activities(ProjectPageQuery query) {
return projectId(query.projectSlug())
.map(
projectId -> {
String from =
" FROM project_activity a"
+ " WHERE a.project_id = :projectId AND a.visibility = 'PUBLIC'";
long total =
jdbcClient
.sql("SELECT count(*)" + from)
.param("projectId", projectId)
.query(Long.class)
.single();
List<ProjectActivityItemView> items =
jdbcClient
.sql(
"SELECT a.activity_type, a.title, a.summary, a.occurred_at,"
+ " a.related_resource_id"
+ from
+ " ORDER BY a.occurred_at DESC, a.id DESC"
+ " LIMIT :size OFFSET :offset")
.param("projectId", projectId)
.param("size", query.page().size())
.param("offset", query.page().offset())
.query(
(rs, rowNum) -> {
RelatedEntryView related =
publishedEntry(rs.getObject("related_resource_id", UUID.class));
return new ProjectActivityItemView(
rs.getString("activity_type"),
rs.getString("title"),
rs.getString("summary"),
JdbcPublicDocumentQueryAdapter.instant(rs, "occurred_at"),
related == null ? null : related.path());
})
.list();
return new ProjectActivityPageView(
items, PageMetadataView.of(query.page().page(), query.page().size(), total));
});
}
/**
* optional 필터는 SQL 조각과 파라미터 바인딩을 함께 켜고 꺼야 한다. 조각만 빼고 바인딩을 남기면 JdbcClient 가 "쓰이지 않은 파라미터"로 실패하고,
* 반대면 파라미터 미해결로 실패한다 — 총계와 목록 두 쿼리에서 같은 실수를 두 번 하지 않도록 한 곳에 모은다.
*/
private static org.springframework.jdbc.core.simple.JdbcClient.StatementSpec bind(
org.springframework.jdbc.core.simple.JdbcClient.StatementSpec spec,
UUID projectId,
String status) {
spec = spec.param("projectId", projectId);
return status == null ? spec : spec.param("status", status);
}
private static org.springframework.jdbc.core.simple.JdbcClient.StatementSpec bindRecord(
org.springframework.jdbc.core.simple.JdbcClient.StatementSpec spec,
UUID projectId,
ProjectRecordPageQuery query) {
spec = spec.param("projectId", projectId);
if (query.type() != null) {
spec = spec.param("type", query.type());
}
return query.relation() == null ? spec : spec.param("relation", query.relation());
}
/** 공개된 프로젝트만 하위 목록을 연다 — 비공개 프로젝트의 결정 목록이 새어 나가면 안 된다. */
private Optional<UUID> projectId(String slug) {
return jdbcClient
.sql(
"SELECT pr.id FROM project pr"
+ " JOIN public_resource_projection p"
+ " ON p.resource_type = 'PROJECT' AND p.resource_id = pr.id"
+ " WHERE pr.slug = :slug AND "
+ PublicSql.ACTIVE)
.param("slug", slug)
.query(UUID.class)
.optional();
}
}
@@ -0,0 +1,100 @@
package dev.caskeleton.adapter.outbound.persistence.techlog.publicsite;
import dev.caskeleton.application.techlog.publicsite.model.ReleaseDetailView;
import dev.caskeleton.application.techlog.publicsite.model.ReleaseListItemView;
import dev.caskeleton.application.techlog.publicsite.port.out.PublicReleaseQueryPort;
import java.util.List;
import java.util.Optional;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Repository;
import tools.jackson.databind.ObjectMapper;
/**
* 릴리스 목록·상세.
*
* <p>릴리스는 {@code public_resource_projection} 을 거치지 않는다 — 설계상 Publication 파이프라인의 대상이 아니라 자체 {@code
* workflow_status} 로 공개 여부를 정하는 기록이다.
*/
@Repository
public class JdbcPublicReleaseQueryAdapter implements PublicReleaseQueryPort {
private final JdbcClient jdbcClient;
private final PublicJson json;
public JdbcPublicReleaseQueryAdapter(JdbcClient jdbcClient, ObjectMapper objectMapper) {
this.jdbcClient = jdbcClient;
this.json = new PublicJson(objectMapper);
}
@Override
public List<ReleaseListItemView> list() {
return jdbcClient
.sql(
"SELECT version_label, title, summary, released_on, change_types FROM release"
+ " WHERE workflow_status = 'PUBLISHED'"
+ " ORDER BY released_on DESC NULLS LAST, version_label DESC")
.query(
(rs, rowNum) ->
new ReleaseListItemView(
rs.getString("version_label"),
rs.getString("title"),
rs.getString("summary"),
rs.getDate("released_on") == null
? null
: rs.getDate("released_on").toLocalDate(),
json.strings(rs.getString("change_types")),
"/releases/" + rs.getString("version_label")))
.list();
}
@Override
public Optional<ReleaseDetailView> findByVersion(String version) {
return jdbcClient
.sql(
"SELECT version_label, title, summary, released_on, change_types, reason_markdown,"
+ " changes_markdown, user_impact_markdown, implementation_impact_markdown,"
+ " verification_markdown, known_limitations_markdown, related_resources"
+ " FROM release WHERE version_label = :version AND workflow_status = 'PUBLISHED'")
.param("version", version)
.query(
(rs, rowNum) ->
new ReleaseDetailView(
rs.getString("version_label"),
rs.getString("title"),
rs.getString("summary"),
rs.getDate("released_on") == null
? null
: rs.getDate("released_on").toLocalDate(),
json.strings(rs.getString("change_types")),
rs.getString("reason_markdown"),
rs.getString("changes_markdown"),
rs.getString("user_impact_markdown"),
rs.getString("implementation_impact_markdown"),
rs.getString("verification_markdown"),
rs.getString("known_limitations_markdown"),
relatedRecords(rs.getString("related_resources"))))
.optional();
}
/**
* {@code related_resources} 는 resource id 배열이다. 그중 <b>공개된 것만</b> 되살린다 — 릴리스가 지목한 기록이 비공개로 바뀌었을 수
* 있고, 그 링크를 그대로 내보내면 404 로 이어진다.
*/
private List<dev.caskeleton.application.techlog.publicsite.model.RelatedEntryView> relatedRecords(
String relatedResourcesJson) {
List<String> ids = json.strings(relatedResourcesJson);
if (ids.isEmpty()) {
return List.of();
}
return jdbcClient
.sql(
"SELECT p.resource_type, p.title, p.summary, p.navigation_path"
+ " FROM public_resource_projection p"
+ " WHERE p.resource_id::text IN (:ids) AND "
+ PublicSql.ACTIVE
+ " ORDER BY p.published_at DESC")
.param("ids", ids)
.query(JdbcPublicTopicQueryAdapter::relatedEntry)
.list();
}
}
@@ -0,0 +1,146 @@
package dev.caskeleton.adapter.outbound.persistence.techlog.publicsite;
import dev.caskeleton.application.techlog.publicsite.model.PageMetadataView;
import dev.caskeleton.application.techlog.publicsite.model.ProjectSummaryView;
import dev.caskeleton.application.techlog.publicsite.model.SearchResultItemView;
import dev.caskeleton.application.techlog.publicsite.model.SearchResultPageView;
import dev.caskeleton.application.techlog.publicsite.model.TopicSummaryView;
import dev.caskeleton.application.techlog.publicsite.port.out.PublicSearchQueryPort;
import dev.caskeleton.application.techlog.publicsite.query.SearchQuery;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Repository;
/**
* 공개 검색.
*
* <p>게시 시 만들어 둔 {@code search_text}(제목 + 요약 + 본문 평문)를 본다. 검색 때 본문을 다시 훑지 않는 이유는 그 평문이 게시 시점에 확정된
* 값이기 때문이다 — 나중에 초안이 바뀌어도 공개 검색 결과는 공개된 내용을 따라야 한다.
*/
@Repository
public class JdbcPublicSearchQueryAdapter implements PublicSearchQueryPort {
/** 스니펫 길이. 너무 길면 목록이 읽히지 않고, 너무 짧으면 왜 걸렸는지 알 수 없다. */
private static final int SNIPPET_LENGTH = 200;
private final JdbcClient jdbcClient;
public JdbcPublicSearchQueryAdapter(JdbcClient jdbcClient) {
this.jdbcClient = jdbcClient;
}
@Override
public SearchResultPageView search(SearchQuery query) {
String pattern = "%" + query.query().toLowerCase(Locale.ROOT) + "%";
StringBuilder where =
new StringBuilder(" WHERE " + PublicSql.ACTIVE + " AND lower(p.search_text) LIKE :pattern");
Map<String, Object> params = new HashMap<>();
params.put("pattern", pattern);
if (query.type() != null) {
where.append(" AND p.resource_type = :type");
params.put("type", query.type());
}
if (query.topicSlug() != null) {
where.append(" AND t.slug = :topicSlug");
params.put("topicSlug", query.topicSlug());
}
String joins =
" FROM public_resource_projection p"
+ " LEFT JOIN topic t ON t.id = p.primary_topic_id"
+ " LEFT JOIN public_resource_project_link l"
+ " ON l.resource_type = p.resource_type AND l.resource_id = p.resource_id"
+ " AND l.relation_type = 'PRIMARY'"
+ " LEFT JOIN project pr ON pr.id = l.project_id";
var countSpec = jdbcClient.sql("SELECT count(*)" + joins + where);
for (Map.Entry<String, Object> e : params.entrySet()) {
countSpec = countSpec.param(e.getKey(), e.getValue());
}
long total = countSpec.query(Long.class).single();
var spec =
jdbcClient.sql(
"SELECT p.resource_type, p.title, p.navigation_path, p.summary, p.body_plain_text,"
+ " p.published_at, p.updated_at,"
+ " t.name AS topic_name, t.slug AS topic_slug,"
+ " pr.name AS project_name, pr.slug AS project_slug"
+ joins
+ where
+ " ORDER BY p.published_at DESC, p.resource_id DESC"
+ " LIMIT :size OFFSET :offset");
for (Map.Entry<String, Object> e : params.entrySet()) {
spec = spec.param(e.getKey(), e.getValue());
}
List<SearchResultItemView> items =
spec.param("size", query.page().size())
.param("offset", query.page().offset())
.query(
(rs, rowNum) ->
new SearchResultItemView(
rs.getString("resource_type"),
rs.getString("title"),
rs.getString("navigation_path"),
snippet(
rs.getString("body_plain_text"),
rs.getString("summary"),
query.query()),
matchedFields(
query.query(),
rs.getString("title"),
rs.getString("summary"),
rs.getString("body_plain_text")),
rs.getString("topic_slug") == null
? null
: new TopicSummaryView(
rs.getString("topic_name"), rs.getString("topic_slug")),
rs.getString("project_slug") == null
? null
: new ProjectSummaryView(
rs.getString("project_name"),
rs.getString("project_slug"),
"/projects/" + rs.getString("project_slug")),
rs.getTimestamp("published_at").toInstant(),
rs.getTimestamp("updated_at").toInstant()))
.list();
return new SearchResultPageView(
query.query(), items, PageMetadataView.of(query.page().page(), query.page().size(), total));
}
/** 검색어가 나온 자리를 중심으로 잘라 준다. 없으면 요약을 쓴다. */
private static String snippet(String body, String summary, String term) {
String source = (body == null || body.isBlank()) ? summary : body;
if (source == null || source.isBlank()) {
return "";
}
int at = source.toLowerCase(Locale.ROOT).indexOf(term.toLowerCase(Locale.ROOT));
if (at < 0) {
return source.length() <= SNIPPET_LENGTH ? source : source.substring(0, SNIPPET_LENGTH);
}
int from = Math.max(0, at - SNIPPET_LENGTH / 2);
int to = Math.min(source.length(), from + SNIPPET_LENGTH);
return source.substring(from, to);
}
/** 어느 필드에서 걸렸는지. 사용자가 왜 이 결과가 나왔는지 알 수 있어야 한다. */
private static List<String> matchedFields(
String term, String title, String summary, String body) {
String needle = term.toLowerCase(Locale.ROOT);
List<String> fields = new ArrayList<>();
if (title != null && title.toLowerCase(Locale.ROOT).contains(needle)) {
fields.add("title");
}
if (summary != null && summary.toLowerCase(Locale.ROOT).contains(needle)) {
fields.add("summary");
}
if (body != null && body.toLowerCase(Locale.ROOT).contains(needle)) {
fields.add("content");
}
return fields;
}
}
@@ -0,0 +1,271 @@
package dev.caskeleton.adapter.outbound.persistence.techlog.publicsite;
import dev.caskeleton.application.techlog.publicsite.model.AssetReferenceView;
import dev.caskeleton.application.techlog.publicsite.model.HomeFocusView;
import dev.caskeleton.application.techlog.publicsite.model.HomeView;
import dev.caskeleton.application.techlog.publicsite.model.LatestEntryView;
import dev.caskeleton.application.techlog.publicsite.model.ProfileView;
import dev.caskeleton.application.techlog.publicsite.model.ProjectSummaryView;
import dev.caskeleton.application.techlog.publicsite.model.RelatedEntryView;
import dev.caskeleton.application.techlog.publicsite.model.SiteView;
import dev.caskeleton.application.techlog.publicsite.model.TopicSummaryView;
import dev.caskeleton.application.techlog.publicsite.port.out.PublicSiteQueryPort;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Repository;
import tools.jackson.databind.ObjectMapper;
/** 사이트 · 홈 · 프로필. 셋 다 단일 행 테이블이 원천이다. */
@Repository
public class JdbcPublicSiteQueryAdapter implements PublicSiteQueryPort {
private final JdbcClient jdbcClient;
private final PublicJson json;
public JdbcPublicSiteQueryAdapter(JdbcClient jdbcClient, ObjectMapper objectMapper) {
this.jdbcClient = jdbcClient;
this.json = new PublicJson(objectMapper);
}
@Override
public Optional<SiteView> site() {
return jdbcClient
.sql(
"SELECT s.brand_title, s.identity_statement, s.operator_display_name,"
+ " s.short_identity, s.contacts, s.avatar_asset_id,"
+ " a.content_type, a.alt_text, a.width, a.height"
+ " FROM site_config s LEFT JOIN asset a ON a.id = s.avatar_asset_id")
.query(
(rs, rowNum) ->
new SiteView(
rs.getString("brand_title"),
rs.getString("identity_statement"),
rs.getString("operator_display_name"),
rs.getString("short_identity"),
avatar(rs),
"/profile",
json.contacts(rs.getString("contacts"))))
.optional();
}
private static AssetReferenceView avatar(java.sql.ResultSet rs) throws java.sql.SQLException {
UUID assetId = rs.getObject("avatar_asset_id", UUID.class);
if (assetId == null) {
return null;
}
return new AssetReferenceView(
assetId,
// 본문과 마찬가지로 저장소 경로가 아니라 안정적인 전송 경로를 노출한다(설계 05장 §3.1).
"/media/" + assetId,
rs.getString("alt_text"),
(Integer) rs.getObject("width"),
(Integer) rs.getObject("height"),
rs.getString("content_type"));
}
@Override
public HomeView home(int latestEntryLimit) {
HomeFocusView focus =
jdbcClient
.sql(
"SELECT default_focus_type, current_project_id, open_question_id,"
+ " recent_decision_id FROM home_focus_config")
.query(
(rs, rowNum) ->
HomeFocusView.resolve(
rs.getString("default_focus_type"),
currentWork(rs.getObject("current_project_id", UUID.class)),
openQuestion(rs.getObject("open_question_id", UUID.class)),
recentDecision(rs.getObject("recent_decision_id", UUID.class))))
.optional()
.orElseGet(() -> HomeFocusView.resolve(null, null, null, null));
return new HomeView(focus, latestEntries(latestEntryLimit));
}
/** 지목한 프로젝트가 지워졌거나 비공개면 focus 는 비운다 — 없는 것을 억지로 채우지 않는다. */
private HomeFocusView.CurrentWork currentWork(UUID projectId) {
if (projectId == null) {
return null;
}
return jdbcClient
.sql(
"SELECT pr.name, pr.slug, pr.one_line_purpose, pr.phase, pr.current_objective,"
+ " pr.next_step, pr.updated_at FROM project pr"
+ " JOIN public_resource_projection p"
+ " ON p.resource_type = 'PROJECT' AND p.resource_id = pr.id"
+ " WHERE pr.id = :id AND "
+ PublicSql.ACTIVE)
.param("id", projectId)
.query(
(rs, rowNum) ->
new HomeFocusView.CurrentWork(
rs.getString("name"),
"/projects/" + rs.getString("slug"),
rs.getString("one_line_purpose"),
rs.getString("phase"),
rs.getString("current_objective"),
rs.getString("next_step"),
rs.getTimestamp("updated_at").toInstant()))
.optional()
.orElse(null);
}
private HomeFocusView.OpenQuestion openQuestion(UUID questionId) {
if (questionId == null) {
return null;
}
return jdbcClient
.sql(
"SELECT q.id, q.question, q.slug, q.summary, q.next_verification, q.updated_at"
+ " FROM open_question q"
+ " JOIN public_resource_projection p"
+ " ON p.resource_type = 'QUESTION' AND p.resource_id = q.id"
+ " WHERE q.id = :id AND "
+ PublicSql.ACTIVE)
.param("id", questionId)
.query(
(rs, rowNum) ->
new HomeFocusView.OpenQuestion(
rs.getString("question"),
"/questions/" + rs.getString("slug"),
rs.getString("summary"),
points(questionId, "FACT"),
points(questionId, "UNKNOWN"),
rs.getString("next_verification"),
rs.getTimestamp("updated_at").toInstant()))
.optional()
.orElse(null);
}
private List<String> points(UUID questionId, String pointKind) {
return jdbcClient
.sql(
"SELECT content FROM question_point WHERE question_id = :id AND point_kind = :kind"
+ " ORDER BY display_order")
.param("id", questionId)
.param("kind", pointKind)
.query(String.class)
.list();
}
private HomeFocusView.RecentDecision recentDecision(UUID decisionId) {
if (decisionId == null) {
return null;
}
return jdbcClient
.sql(
"SELECT d.statement, d.slug, d.rationale_markdown, d.consequences, d.decided_at,"
+ " pr.slug AS project_slug FROM project_decision d"
+ " LEFT JOIN project pr ON pr.id = d.project_id"
+ " JOIN public_resource_projection p"
+ " ON p.resource_type = 'PROJECT_DECISION' AND p.resource_id = d.id"
+ " WHERE d.id = :id AND "
+ PublicSql.ACTIVE)
.param("id", decisionId)
.query(
(rs, rowNum) ->
new HomeFocusView.RecentDecision(
rs.getString("statement"),
PublicSql.pathOf(
"PROJECT_DECISION", rs.getString("slug"), rs.getString("project_slug")),
rs.getString("rationale_markdown"),
json.strings(rs.getString("consequences")),
rs.getTimestamp("decided_at") == null
? null
: rs.getTimestamp("decided_at").toInstant()))
.optional()
.orElse(null);
}
/**
* 계약 {@code LatestEntry.entryType} 은 {@code CASE / REFERENCE / PROJECT_ACTIVITY / RELEASE} 네 값만
* 허용한다. projection 에는 {@code QUESTION}·{@code PROJECT}·{@code PROJECT_DECISION}·{@code PROFILE} 도
* 들어 있으므로 여기서 걸러야 한다 — 거르지 않으면 응답 매퍼가 계약 밖 값을 만나 500 이 되고, 그 500 은 홈 화면 전체를 못 쓰게 만든다.
*
* <p>{@code RELEASE} 가 결과에 없는 것은 누락이 아니다. 릴리스는 Publication 파이프라인을 거치지 않고 자체 {@code
* workflow_status} 로 공개되므로 이 projection 에 아예 행이 없다({@code JdbcPublicReleaseQueryAdapter} 클래스 주석).
* 계약은 그 값을 <b>허용</b>할 뿐 매번 포함하라고 요구하지 않는다.
*/
private List<LatestEntryView> latestEntries(int limit) {
return jdbcClient
.sql(
"SELECT p.resource_type, p.title, p.summary, p.navigation_path, p.published_at,"
+ " t.name AS topic_name, t.slug AS topic_slug,"
+ " pr.name AS project_name, pr.slug AS project_slug"
+ " FROM public_resource_projection p"
+ " LEFT JOIN topic t ON t.id = p.primary_topic_id"
+ " LEFT JOIN public_resource_project_link l"
+ " ON l.resource_type = p.resource_type AND l.resource_id = p.resource_id"
+ " AND l.relation_type = 'PRIMARY'"
+ " LEFT JOIN project pr ON pr.id = l.project_id"
+ " WHERE "
+ PublicSql.ACTIVE
+ " AND "
+ PublicSql.LATEST_ENTRY_TYPES
+ " ORDER BY p.published_at DESC LIMIT :limit")
.param("limit", limit)
.query(
(rs, rowNum) ->
new LatestEntryView(
rs.getString("resource_type"),
rs.getString("title"),
rs.getString("summary"),
rs.getString("navigation_path"),
rs.getString("topic_slug") == null
? null
: new TopicSummaryView(
rs.getString("topic_name"), rs.getString("topic_slug")),
rs.getString("project_slug") == null
? null
: new ProjectSummaryView(
rs.getString("project_name"),
rs.getString("project_slug"),
"/projects/" + rs.getString("project_slug")),
rs.getTimestamp("published_at").toInstant()))
.list();
}
@Override
public Optional<ProfileView> profile() {
return jdbcClient
.sql(
"SELECT headline, introduction_markdown, working_model, territories,"
+ " selected_evidence, trajectory, contacts FROM profile_page"
+ " WHERE target_visibility = 'PUBLIC'")
.query(
(rs, rowNum) ->
new ProfileView(
rs.getString("headline"),
rs.getString("introduction_markdown"),
json.namedDescriptions(rs.getString("working_model")),
json.territories(rs.getString("territories")),
selectedEvidence(rs.getString("selected_evidence")),
json.namedDescriptions(rs.getString("trajectory")),
json.contacts(rs.getString("contacts"))))
.optional();
}
/**
* {@code selected_evidence} 는 resource id 배열이다. 그중 <b>공개된 것만</b> 되살린다 — 프로필이 지목한 기록이 비공개로 바뀌었을 수
* 있고, 그 링크를 그대로 내보내면 404 로 이어진다({@code JdbcPublicReleaseQueryAdapter} 의 {@code related_resources}
* 와 같은 규칙).
*/
private List<RelatedEntryView> selectedEvidence(String selectedEvidenceJson) {
List<String> ids = json.strings(selectedEvidenceJson);
if (ids.isEmpty()) {
return List.of();
}
return jdbcClient
.sql(
"SELECT p.resource_type, p.title, p.summary, p.navigation_path"
+ " FROM public_resource_projection p"
+ " WHERE p.resource_id::text IN (:ids) AND "
+ PublicSql.ACTIVE
+ " ORDER BY p.published_at DESC")
.param("ids", ids)
.query(JdbcPublicTopicQueryAdapter::relatedEntry)
.list();
}
}
@@ -0,0 +1,178 @@
package dev.caskeleton.adapter.outbound.persistence.techlog.publicsite;
import dev.caskeleton.application.techlog.publicsite.model.LatestEntryView;
import dev.caskeleton.application.techlog.publicsite.model.ProjectSummaryView;
import dev.caskeleton.application.techlog.publicsite.model.RelatedEntryView;
import dev.caskeleton.application.techlog.publicsite.model.TopicDetailView;
import dev.caskeleton.application.techlog.publicsite.model.TopicListItemView;
import dev.caskeleton.application.techlog.publicsite.model.TopicSummaryView;
import dev.caskeleton.application.techlog.publicsite.port.out.PublicTopicQueryPort;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Repository;
/** 주제 목록·상세. 개수와 목록 모두 공개된 것만 센다. */
@Repository
public class JdbcPublicTopicQueryAdapter implements PublicTopicQueryPort {
/** 상세 화면이 한 화면에 담는 개수. */
private static final int SECTION_LIMIT = 10;
private final JdbcClient jdbcClient;
public JdbcPublicTopicQueryAdapter(JdbcClient jdbcClient) {
this.jdbcClient = jdbcClient;
}
@Override
public List<TopicListItemView> list() {
return jdbcClient
.sql(
"SELECT t.name, t.slug, t.description,"
+ " (SELECT count(*) FROM public_resource_projection p"
+ " WHERE p.primary_topic_id = t.id AND "
+ PublicSql.ACTIVE
+ ") AS record_count"
+ " FROM topic t WHERE t.status = 'ACTIVE' ORDER BY t.name")
.query(
(rs, rowNum) ->
new TopicListItemView(
rs.getString("name"),
rs.getString("slug"),
rs.getString("description"),
rs.getInt("record_count")))
.list();
}
@Override
public Optional<TopicDetailView> findBySlug(String slug) {
return jdbcClient
.sql(
"SELECT id, name, slug, description, scope FROM topic WHERE slug = :slug AND status = 'ACTIVE'")
.param("slug", slug)
.query(
(rs, rowNum) -> {
UUID topicId = rs.getObject("id", UUID.class);
return new TopicDetailView(
rs.getString("name"),
rs.getString("slug"),
rs.getString("description"),
rs.getString("scope"),
featured(topicId, "START_HERE").stream().findFirst().orElse(null),
featured(topicId, "FEATURED_CASE"),
activeQuestions(topicId),
relatedProjects(topicId),
latestRecords(topicId));
})
.optional();
}
/**
* {@code topic_featured_document} 가 지목한 문서 중 <b>공개된 것만</b> 보여준다 — 지목은 Studio 의 편집 행위이고 공개 여부와
* 별개다.
*/
private List<RelatedEntryView> featured(UUID topicId, String role) {
return jdbcClient
.sql(
"SELECT p.resource_type, p.title, p.summary, p.navigation_path"
+ " FROM topic_featured_document f"
+ " JOIN public_resource_projection p ON p.resource_id = f.document_id"
+ " WHERE f.topic_id = :topicId AND f.feature_role = :role AND "
+ PublicSql.ACTIVE
+ " ORDER BY f.display_order")
.param("topicId", topicId)
.param("role", role)
.query(JdbcPublicTopicQueryAdapter::relatedEntry)
.list();
}
private List<RelatedEntryView> activeQuestions(UUID topicId) {
return jdbcClient
.sql(
"SELECT p.resource_type, p.title, p.summary, p.navigation_path"
+ " FROM public_resource_projection p"
+ " WHERE p.resource_type = 'QUESTION' AND p.primary_topic_id = :topicId"
+ " AND p.state_code <> 'RESOLVED' AND "
+ PublicSql.ACTIVE
+ " ORDER BY p.updated_at DESC LIMIT :limit")
.param("topicId", topicId)
.param("limit", SECTION_LIMIT)
.query(JdbcPublicTopicQueryAdapter::relatedEntry)
.list();
}
private List<RelatedEntryView> relatedProjects(UUID topicId) {
return jdbcClient
.sql(
"SELECT p.resource_type, p.title, p.summary, p.navigation_path"
+ " FROM project_topic pt"
+ " JOIN public_resource_projection p"
+ " ON p.resource_type = 'PROJECT' AND p.resource_id = pt.project_id"
+ " WHERE pt.topic_id = :topicId AND "
+ PublicSql.ACTIVE
+ " ORDER BY pt.display_order")
.param("topicId", topicId)
.query(JdbcPublicTopicQueryAdapter::relatedEntry)
.list();
}
/**
* 계약 {@code LatestEntry.entryType} 은 {@code CASE / REFERENCE / PROJECT_ACTIVITY / RELEASE} 네 값만
* 허용한다. projection 에는 {@code QUESTION}·{@code PROJECT}·{@code PROJECT_DECISION}·{@code PROFILE} 도
* 들어 있으므로 여기서 걸러야 한다 — 거르지 않으면 응답 매퍼가 계약 밖 값을 만나 500 이 되고, 그 500 은 홈 화면 전체를 못 쓰게 만든다.
*
* <p>{@code RELEASE} 가 결과에 없는 것은 누락이 아니다. 릴리스는 Publication 파이프라인을 거치지 않고 자체 {@code
* workflow_status} 로 공개되므로 이 projection 에 아예 행이 없다({@code JdbcPublicReleaseQueryAdapter} 클래스 주석).
* 계약은 그 값을 <b>허용</b>할 뿐 매번 포함하라고 요구하지 않는다.
*/
private List<LatestEntryView> latestRecords(UUID topicId) {
return jdbcClient
.sql(
"SELECT p.resource_type, p.title, p.summary, p.navigation_path, p.published_at,"
+ " t.name AS topic_name, t.slug AS topic_slug,"
+ " pr.name AS project_name, pr.slug AS project_slug"
+ " FROM public_resource_projection p"
+ " LEFT JOIN topic t ON t.id = p.primary_topic_id"
+ " LEFT JOIN public_resource_project_link l"
+ " ON l.resource_type = p.resource_type AND l.resource_id = p.resource_id"
+ " AND l.relation_type = 'PRIMARY'"
+ " LEFT JOIN project pr ON pr.id = l.project_id"
+ " WHERE p.primary_topic_id = :topicId AND "
+ PublicSql.ACTIVE
+ " AND "
+ PublicSql.LATEST_ENTRY_TYPES
+ " ORDER BY p.published_at DESC LIMIT :limit")
.param("topicId", topicId)
.param("limit", SECTION_LIMIT)
.query(
(rs, rowNum) ->
new LatestEntryView(
rs.getString("resource_type"),
rs.getString("title"),
rs.getString("summary"),
rs.getString("navigation_path"),
rs.getString("topic_slug") == null
? null
: new TopicSummaryView(
rs.getString("topic_name"), rs.getString("topic_slug")),
rs.getString("project_slug") == null
? null
: new ProjectSummaryView(
rs.getString("project_name"),
rs.getString("project_slug"),
"/projects/" + rs.getString("project_slug")),
rs.getTimestamp("published_at").toInstant()))
.list();
}
static RelatedEntryView relatedEntry(java.sql.ResultSet rs, int rowNum)
throws java.sql.SQLException {
return new RelatedEntryView(
rs.getString("resource_type"),
rs.getString("title"),
rs.getString("summary"),
rs.getString("navigation_path"));
}
}
@@ -0,0 +1,81 @@
package dev.caskeleton.adapter.outbound.persistence.techlog.publicsite;
import dev.caskeleton.application.techlog.publicsite.model.ContactLinkView;
import dev.caskeleton.application.techlog.publicsite.model.ProfileView;
import dev.caskeleton.shared.error.MappingException;
import java.util.ArrayList;
import java.util.List;
import tools.jackson.core.JacksonException;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
/**
* 공개 조회가 읽는 jsonb 컬럼을 푼다.
*
* <p>Jackson 의 POJO 바인딩을 쓰지 않고 key 를 명시적으로 읽는다 — 이 값들은 DB 에 영속된 모양이라 application record 의 필드 이름이
* 바뀌면 이미 저장된 행을 못 읽게 된다.
*/
final class PublicJson {
private final ObjectMapper mapper;
PublicJson(ObjectMapper mapper) {
this.mapper = mapper;
}
List<String> strings(String json) {
List<String> out = new ArrayList<>();
for (JsonNode node : array(json)) {
// 설계의 배열 컬럼은 문자열이거나 {text: ...} 모양일 수 있다. 둘 다 받는다.
out.add(node.isString() ? node.asString("") : node.path("text").asString(node.toString()));
}
return out;
}
List<ContactLinkView> contacts(String json) {
List<ContactLinkView> out = new ArrayList<>();
for (JsonNode node : array(json)) {
out.add(
new ContactLinkView(
node.path("type").asString(""),
node.path("label").asString(""),
node.path("url").asString("")));
}
return out;
}
List<ProfileView.NamedDescription> namedDescriptions(String json) {
List<ProfileView.NamedDescription> out = new ArrayList<>();
for (JsonNode node : array(json)) {
out.add(
new ProfileView.NamedDescription(
node.path("name").asString(node.path("title").asString("")),
node.path("description").asString("")));
}
return out;
}
List<ProfileView.Territory> territories(String json) {
List<ProfileView.Territory> out = new ArrayList<>();
for (JsonNode node : array(json)) {
out.add(
new ProfileView.Territory(
node.path("name").asString(""),
node.path("currentQuestion").asString(null),
node.path("topicPath").asString(null)));
}
return out;
}
private Iterable<JsonNode> array(String json) {
if (json == null || json.isBlank()) {
return List.of();
}
try {
JsonNode node = mapper.readTree(json);
return node.isArray() ? node : List.of();
} catch (JacksonException e) {
throw new MappingException("failed to read a public jsonb column", e);
}
}
}
@@ -0,0 +1,87 @@
package dev.caskeleton.adapter.outbound.persistence.techlog.publicsite;
import dev.caskeleton.application.techlog.publicsite.model.RelatedEntryView;
import java.util.List;
import java.util.UUID;
import org.springframework.jdbc.core.simple.JdbcClient;
/**
* 공개 상세가 보여주는 관계.
*
* <p><b>어디서 읽는지가 중요하다.</b> 설계 스키마에는 유형별 링크 테이블({@code document_relation}, {@code
* question_document_link})이 있지만 <b>그 테이블들에 쓰는 경로가 없다</b> — Studio 편집기가 만드는 관계는 전부 {@code
* studio_relation} 에 들어간다(계약의 relations[] 가 네 유형 공통이라 그렇게 설계했다). 그래서 공개도 같은 곳에서 읽는다. 링크 테이블을 읽으면
* 관계가 항상 비어 보인다.
*
* <p>관계의 종류는 저장돼 있지 않으므로 <b>대상의 유형</b>으로 나눈다 — 계약이 관계를 유형별 묶음 (relatedCases / derivedReferences /
* projectDecisions / originQuestion)으로 요구하기 때문이다. 공개되지 않은 대상은 제외한다.
*/
final class PublicRelationLookup {
private final JdbcClient jdbcClient;
PublicRelationLookup(JdbcClient jdbcClient) {
this.jdbcClient = jdbcClient;
}
/** {@code sourceKind} 문서가 가리키는 관계 중 대상이 {@code targetType} 이고 공개된 것들. */
List<RelatedEntryView> targetsOfType(String sourceKind, UUID sourceId, String targetType) {
return jdbcClient
.sql(
"SELECT p.resource_type, p.title, p.summary, p.navigation_path"
+ " FROM studio_relation r"
+ " JOIN public_resource_projection p ON p.resource_id = r.target_id"
+ " WHERE r.source_kind = :sourceKind AND r.source_id = :sourceId"
+ " AND p.resource_type = :targetType AND "
+ PublicSql.ACTIVE
+ " ORDER BY r.display_order")
.param("sourceKind", sourceKind)
.param("sourceId", sourceId)
.param("targetType", targetType)
.query(JdbcPublicTopicQueryAdapter::relatedEntry)
.list();
}
/** 같은 조회의 단수형. 계약이 하나만 받는 자리(originQuestion 등)에 쓴다. */
RelatedEntryView firstTargetOfType(String sourceKind, UUID sourceId, String targetType) {
return targetsOfType(sourceKind, sourceId, targetType).stream().findFirst().orElse(null);
}
/** 이 기록을 가리키는 <b>역방향</b> 관계. "이 Reference 를 적용한 Case" 같은 자리에 쓴다. */
List<RelatedEntryView> sourcesOfType(UUID targetId, String sourceType) {
return jdbcClient
.sql(
"SELECT p.resource_type, p.title, p.summary, p.navigation_path"
+ " FROM studio_relation r"
+ " JOIN public_resource_projection p ON p.resource_id = r.source_id"
+ " WHERE r.target_id = :targetId AND p.resource_type = :sourceType"
+ " AND "
+ PublicSql.ACTIVE
+ " ORDER BY p.published_at DESC")
.param("targetId", targetId)
.param("sourceType", sourceType)
.query(JdbcPublicTopicQueryAdapter::relatedEntry)
.list();
}
/** 이 기록이 속한 프로젝트. {@code project_*_link} 의 PRIMARY 를 따른다. */
RelatedEntryView primaryProject(String linkTable, String idColumn, UUID id) {
return jdbcClient
.sql(
"SELECT p.resource_type, p.title, p.summary, p.navigation_path"
+ " FROM "
+ linkTable
+ " l"
+ " JOIN public_resource_projection p"
+ " ON p.resource_type = 'PROJECT' AND p.resource_id = l.project_id"
+ " WHERE l."
+ idColumn
+ " = :id AND l.relation_type = 'PRIMARY'"
+ " AND "
+ PublicSql.ACTIVE)
.param("id", id)
.query(JdbcPublicTopicQueryAdapter::relatedEntry)
.optional()
.orElse(null);
}
}
@@ -0,0 +1,36 @@
package dev.caskeleton.adapter.outbound.persistence.techlog.publicsite;
/**
* 공개 조회가 공유하는 SQL 조각.
*
* <p>"무엇이 공개인가"의 정의를 한 곳에 둔다. 각 쿼리가 조건을 따로 쓰면 어느 하나가 {@code publication_state} 를 빠뜨려도 드러나지 않고, 그
* 결과는 게시 취소한 문서가 계속 보이는 사고다.
*/
final class PublicSql {
/** 공개 노출 조건. 게시 취소({@code WITHDRAWN})와 비공개({@code UNLISTED})를 함께 배제한다. */
static final String ACTIVE = " p.publication_state = 'ACTIVE' AND p.visibility = 'PUBLIC' ";
/**
* 계약 {@code LatestEntry.entryType} 이 허용하는 값 중 이 projection 에 실제로 담기는 것들. 홈과 주제 상세가 같은 목록 의미를 쓰므로
* 조건도 한 곳에서 정의한다.
*/
static final String LATEST_ENTRY_TYPES =
" p.resource_type IN ('CASE', 'REFERENCE', 'PROJECT_ACTIVITY') ";
private PublicSql() {}
/** 유형별 공개 경로. 게시 시 {@code navigation_path} 에 저장된 값을 그대로 쓴다. */
static String pathOf(String resourceType, String slug, String projectSlug) {
return switch (resourceType) {
case "CASE" -> "/cases/" + slug;
case "REFERENCE" -> "/references/" + slug;
case "QUESTION" -> "/questions/" + slug;
case "PROJECT" -> "/projects/" + slug;
case "PROJECT_DECISION" ->
projectSlug == null ? null : "/projects/" + projectSlug + "/decisions/" + slug;
case "RELEASE" -> "/releases/" + slug;
default -> null;
};
}
}
@@ -0,0 +1,78 @@
package dev.caskeleton.adapter.outbound.persistence.techlog.query;
import dev.caskeleton.application.techlog.studio.port.out.CatalogQueryPort;
import dev.caskeleton.application.techlog.studio.query.CatalogEntryType;
import dev.caskeleton.application.techlog.studio.query.CatalogEntryView;
import dev.caskeleton.application.techlog.studio.query.CatalogPageView;
import java.util.List;
import java.util.Locale;
import java.util.UUID;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Repository;
/**
* catalog는 도메인 repository를 거치지 않고 전용 union query를 쓴다 (설계 08장 §4).
*
* <p>RELATION / EVIDENCE는 슬라이스 2·5에서 채운다. 그때까지 빈 페이지를 반환하며 이는 계약상 유효한 응답이다.
*/
@Repository
public class JdbcCatalogQueryAdapter implements CatalogQueryPort {
private final JdbcClient jdbcClient;
public JdbcCatalogQueryAdapter(JdbcClient jdbcClient) {
this.jdbcClient = jdbcClient;
}
@Override
public CatalogPageView search(CatalogEntryType type, String query, String cursor, int limit) {
String pattern =
(query == null || query.isBlank()) ? "%" : "%" + query.toLowerCase(Locale.ROOT) + "%";
List<CatalogEntryView> items =
switch (type) {
case TOPIC -> searchTopics(pattern, limit);
case PROJECT -> searchProjects(pattern, limit);
case RELATION, EVIDENCE -> List.of();
};
return new CatalogPageView(items, null);
}
private List<CatalogEntryView> searchTopics(String pattern, int limit) {
return jdbcClient
.sql(
"SELECT id, name, updated_at FROM topic "
+ "WHERE status = 'ACTIVE' AND lower(name) LIKE :pattern "
+ "ORDER BY name LIMIT :limit")
.param("pattern", pattern)
.param("limit", limit)
.query(
(rs, rowNum) ->
new CatalogEntryView(
UUID.fromString(rs.getString("id")),
CatalogEntryType.TOPIC,
rs.getString("name"),
null,
null,
"topic:" + rs.getTimestamp("updated_at").toInstant()))
.list();
}
private List<CatalogEntryView> searchProjects(String pattern, int limit) {
return jdbcClient
.sql(
"SELECT id, name, updated_at FROM project "
+ "WHERE lower(name) LIKE :pattern ORDER BY name LIMIT :limit")
.param("pattern", pattern)
.param("limit", limit)
.query(
(rs, rowNum) ->
new CatalogEntryView(
UUID.fromString(rs.getString("id")),
CatalogEntryType.PROJECT,
rs.getString("name"),
"PROJECT",
null,
"project:" + rs.getTimestamp("updated_at").toInstant()))
.list();
}
}
@@ -0,0 +1,41 @@
package dev.caskeleton.adapter.outbound.persistence.techlog.query;
import dev.caskeleton.application.techlog.error.StudioError;
import dev.caskeleton.application.techlog.error.StudioException;
import dev.caskeleton.application.techlog.studio.model.RecordKind;
import dev.caskeleton.application.techlog.studio.port.out.DependencyRevisionPort;
import java.util.UUID;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Repository;
/**
* 의존 상태 해시를 {@link StudioDocumentSql}의 정의로 계산한다.
*
* <p>목록이 쓰는 SQL과 <b>같은 식</b>을 쓴다. 여기서 다른 식을 쓰면 상세 화면이 계산한 값과 목록이 계산한 값이 달라져, 검증을 막 통과한 문서가 목록에서는
* "다시 검증하라"로 보인다.
*/
@Repository
public class JdbcDependencyRevisionAdapter implements DependencyRevisionPort {
private final JdbcClient jdbcClient;
public JdbcDependencyRevisionAdapter(JdbcClient jdbcClient) {
this.jdbcClient = jdbcClient;
}
@Override
public String revisionFor(RecordKind kind, UUID documentId) {
return jdbcClient
.sql(
StudioDocumentSql.documentProjectionCte()
+ " SELECT dependency_revision FROM studio_document WHERE id = :id")
.param("id", documentId)
.query(String.class)
.optional()
.orElseThrow(
() ->
StudioException.of(
StudioError.DOCUMENT_NOT_FOUND,
"cannot compute a dependency revision for unknown document " + documentId));
}
}
@@ -0,0 +1,42 @@
package dev.caskeleton.adapter.outbound.persistence.techlog.query;
import dev.caskeleton.application.techlog.studio.model.PublicationAggregateStatus;
import dev.caskeleton.application.techlog.studio.model.PublicationAggregateView;
import dev.caskeleton.application.techlog.studio.port.out.PublicationQueryPort;
import java.util.Optional;
import java.util.UUID;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Repository;
/** 현재 게시 상태 조회. {@code publication}은 (source_kind, source_id)당 한 행이다. */
@Repository
public class JdbcPublicationQueryAdapter implements PublicationQueryPort {
private final JdbcClient jdbcClient;
public JdbcPublicationQueryAdapter(JdbcClient jdbcClient) {
this.jdbcClient = jdbcClient;
}
@Override
public Optional<PublicationAggregateView> currentFor(UUID documentId) {
return jdbcClient
.sql(
"SELECT publication_id, source_id, status, published_version, publication_revision,"
+ " latest_event_id, public_path, updated_at"
+ " FROM publication WHERE source_id = :id")
.param("id", documentId)
.query(
(rs, rowNum) ->
new PublicationAggregateView(
rs.getObject("publication_id", UUID.class),
rs.getObject("source_id", UUID.class),
PublicationAggregateStatus.valueOf(rs.getString("status")),
rs.getLong("published_version"),
rs.getLong("publication_revision"),
rs.getObject("latest_event_id", UUID.class),
rs.getString("public_path"),
rs.getTimestamp("updated_at").toInstant()))
.optional();
}
}
@@ -0,0 +1,57 @@
package dev.caskeleton.adapter.outbound.persistence.techlog.query;
import dev.caskeleton.application.techlog.studio.model.DashboardTotalsView;
import dev.caskeleton.application.techlog.studio.model.DocumentSummaryView;
import dev.caskeleton.application.techlog.studio.model.NextAction;
import dev.caskeleton.application.techlog.studio.port.out.StudioDashboardQueryPort;
import java.util.List;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Repository;
/**
* 대시보드 집계. 목록과 <b>같은</b> {@code studio_document} 정의를 쓴다 — 대시보드가 "게시 준비됨"이라고 센 문서와 목록에서 그 필터로 나오는
* 문서가 달라지면 숫자를 믿을 수 없다.
*/
@Repository
public class JdbcStudioDashboardQueryAdapter implements StudioDashboardQueryPort {
private final JdbcClient jdbcClient;
public JdbcStudioDashboardQueryAdapter(JdbcClient jdbcClient) {
this.jdbcClient = jdbcClient;
}
@Override
public List<DocumentSummaryView> topByNextAction(List<NextAction> actions, int limit) {
return jdbcClient
.sql(
StudioDocumentSql.documentProjectionCte()
+ " SELECT * FROM studio_document WHERE next_action IN (:actions)"
+ " ORDER BY updated_at DESC, id DESC LIMIT :limit")
.param("actions", actions.stream().map(Enum::name).toList())
.param("limit", limit)
.query((rs, rowNum) -> StudioDocumentRowMapper.read(rs))
.list();
}
@Override
public DashboardTotalsView totals() {
return jdbcClient
.sql(
StudioDocumentSql.documentProjectionCte()
+ " SELECT count(*) AS documents,"
+ " count(*) FILTER (WHERE next_action IN ('VALIDATE', 'FIX_VALIDATION'))"
+ " AS needs_validation,"
+ " count(*) FILTER (WHERE next_action = 'PUBLISH') AS ready_to_publish,"
+ " (SELECT count(*) FROM publication WHERE status = 'PUBLISHED') AS publications"
+ " FROM studio_document")
.query(
(rs, rowNum) ->
new DashboardTotalsView(
rs.getInt("documents"),
rs.getInt("needs_validation"),
rs.getInt("ready_to_publish"),
rs.getInt("publications")))
.single();
}
}
@@ -0,0 +1,225 @@
package dev.caskeleton.adapter.outbound.persistence.techlog.query;
import dev.caskeleton.application.techlog.studio.model.DisplayTargetView;
import dev.caskeleton.application.techlog.studio.model.PublicPaths;
import dev.caskeleton.application.techlog.studio.model.RecordKind;
import dev.caskeleton.application.techlog.studio.model.RelationView;
import dev.caskeleton.application.techlog.studio.model.ResolvedAssetView;
import dev.caskeleton.application.techlog.studio.model.ResolvedRelationView;
import dev.caskeleton.application.techlog.studio.model.WorkingCopyView;
import dev.caskeleton.application.techlog.studio.port.out.StudioDependencyResolverPort;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Repository;
/** 편집본이 의존하는 바깥 상태를 한 번에 읽는다. 검증과 렌더링이 같은 결과를 공유하도록 조회는 이 한 곳에서만 한다. */
@Repository
public class JdbcStudioDependencyResolverAdapter implements StudioDependencyResolverPort {
private final JdbcClient jdbcClient;
public JdbcStudioDependencyResolverAdapter(JdbcClient jdbcClient) {
this.jdbcClient = jdbcClient;
}
@Override
public Resolved resolve(WorkingCopyView document, Set<String> referencedAssetKeys) {
UUID topicId = document.base().topicId();
UUID projectId = document.base().projectId();
DisplayTargetView topic = topicId == null ? null : findTopic(topicId);
DisplayTargetView project = projectId == null ? null : findProject(projectId);
List<ResolvedRelationView> relations = new ArrayList<>();
List<UUID> missingTargets = new ArrayList<>();
resolveRelations(document, relations, missingTargets);
Map<String, ResolvedAssetView> assetsByKey = new LinkedHashMap<>();
Map<String, String> assetStatusByKey = new LinkedHashMap<>();
resolveAssets(referencedAssetKeys, assetsByKey, assetStatusByKey);
String projectSlug = projectId == null ? null : findProjectSlug(projectId);
String publicPath = PublicPaths.forKind(document.kind(), document.base().slug(), projectSlug);
return new Resolved(
topic,
topicId != null && topic == null,
project,
projectId != null && project == null,
relations,
missingTargets,
assetsByKey,
assetStatusByKey,
resolveEvidenceTarget(document),
publicPath,
findSlugOwner(document));
}
private DisplayTargetView findTopic(UUID topicId) {
return jdbcClient
.sql("SELECT id, name, slug FROM topic WHERE id = :id")
.param("id", topicId)
.query(
(rs, rowNum) ->
new DisplayTargetView(
rs.getObject("id", UUID.class),
rs.getString("name"),
"/topics/" + rs.getString("slug")))
.optional()
.orElse(null);
}
private DisplayTargetView findProject(UUID projectId) {
return jdbcClient
.sql("SELECT id, name, slug FROM project WHERE id = :id")
.param("id", projectId)
.query(
(rs, rowNum) ->
new DisplayTargetView(
rs.getObject("id", UUID.class),
rs.getString("name"),
rs.getString("slug") == null ? null : "/projects/" + rs.getString("slug")))
.optional()
.orElse(null);
}
private String findProjectSlug(UUID projectId) {
return jdbcClient
.sql("SELECT slug FROM project WHERE id = :id")
.param("id", projectId)
.query(String.class)
.optional()
.orElse(null);
}
/**
* 관계 대상은 네 유형 어디에도 있을 수 있고 프로젝트일 수도 있다(계약 {@code ResolvedRelation.targetKind} 가 {@code
* RecordKind} 보다 하나 넓다). UNION 으로 한 번에 찾는다.
*/
private void resolveRelations(
WorkingCopyView document, List<ResolvedRelationView> resolved, List<UUID> missing) {
for (RelationView relation : document.base().relations()) {
if (relation.targetId() == null) {
continue;
}
TargetRow row = findTarget(relation.targetId());
if (row == null) {
missing.add(relation.targetId());
continue;
}
resolved.add(
new ResolvedRelationView(
relation.id(),
relation.targetId(),
row.kind(),
row.title(),
row.publicPath(),
relation.reason() == null ? "" : relation.reason(),
relation.order()));
}
}
private record TargetRow(String kind, String title, String publicPath) {}
private TargetRow findTarget(UUID targetId) {
return jdbcClient
.sql(
"SELECT document_type AS kind, title, slug, NULL::text AS project_slug FROM document"
+ " WHERE id = :id"
+ " UNION ALL SELECT 'QUESTION', question, slug, NULL::text FROM open_question"
+ " WHERE id = :id"
+ " UNION ALL SELECT 'PROJECT', name, slug, NULL::text FROM project"
+ " WHERE id = :id"
+ " UNION ALL SELECT 'PROJECT_DECISION', pd.title, pd.slug, p.slug"
+ " FROM project_decision pd LEFT JOIN project p ON p.id = pd.project_id"
+ " WHERE pd.id = :id")
.param("id", targetId)
.query(
(rs, rowNum) -> {
String kind = rs.getString("kind");
String slug = rs.getString("slug");
String publicPath =
"PROJECT".equals(kind)
? (slug == null ? null : "/projects/" + slug)
: PublicPaths.forKind(
RecordKind.valueOf(kind), slug, rs.getString("project_slug"));
return new TargetRow(kind, rs.getString("title"), publicPath);
})
.optional()
.orElse(null);
}
private void resolveAssets(
Set<String> keys, Map<String, ResolvedAssetView> assets, Map<String, String> statuses) {
if (keys.isEmpty()) {
return;
}
jdbcClient
.sql(
"SELECT id, asset_key, content_type, object_key, width, height, decorative,"
+ " management_status FROM asset WHERE asset_key IN (:keys)")
.param("keys", keys)
.query(
(rs, rowNum) -> {
String key = rs.getString("asset_key");
statuses.put(key, rs.getString("management_status"));
assets.put(
key,
new ResolvedAssetView(
rs.getObject("id", UUID.class),
key,
rs.getString("content_type"),
// 본문에는 object storage 경로가 아니라 안정적인 전송 경로를 싣는다
// (설계 05장 §3.1).
"/media/" + rs.getString("id"),
(Integer) rs.getObject("width"),
(Integer) rs.getObject("height"),
rs.getBoolean("decorative")));
return key;
})
.list();
}
private DisplayTargetView resolveEvidenceTarget(WorkingCopyView document) {
if (!(document instanceof WorkingCopyView.QuestionWorkingCopyView value)
|| value.resolution() == null
|| value.resolution().evidenceTargetId() == null) {
return null;
}
TargetRow row = findTarget(value.resolution().evidenceTargetId());
return row == null
? null
: new DisplayTargetView(
value.resolution().evidenceTargetId(), row.title(), row.publicPath());
}
/**
* 같은 공개 경로 이름공간(= 같은 유형)에서 이 slug 를 이미 쓰는 다른 기록. 유형이 다르면 경로 접두사가 달라 충돌하지 않는다({@code /cases/x} 와
* {@code /questions/x} 는 다른 주소다).
*/
private UUID findSlugOwner(WorkingCopyView document) {
String slug = document.base().slug();
if (slug == null || slug.isBlank()) {
return null;
}
String sql =
switch (document.kind()) {
case CASE, REFERENCE ->
"SELECT id FROM document WHERE slug = :slug AND document_type = :kind AND id <> :id";
case QUESTION -> "SELECT id FROM open_question WHERE slug = :slug AND id <> :id";
case PROJECT_DECISION ->
"SELECT id FROM project_decision WHERE slug = :slug AND id <> :id";
};
var spec = jdbcClient.sql(sql).param("slug", slug).param("id", document.id());
if (document.kind() == RecordKind.CASE || document.kind() == RecordKind.REFERENCE) {
spec = spec.param("kind", document.kind().name());
}
return spec.query(UUID.class).optional().orElse(null);
}
}
@@ -0,0 +1,141 @@
package dev.caskeleton.adapter.outbound.persistence.techlog.query;
import dev.caskeleton.application.techlog.studio.model.DocumentPageView;
import dev.caskeleton.application.techlog.studio.model.DocumentSort;
import dev.caskeleton.application.techlog.studio.model.DocumentSummaryView;
import dev.caskeleton.application.techlog.studio.port.out.StudioDocumentQueryPort;
import dev.caskeleton.application.techlog.studio.query.DocumentCursorPosition;
import dev.caskeleton.application.techlog.studio.query.ListDocumentsQuery;
import java.sql.Timestamp;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Repository;
/**
* {@code listStudioDocuments}. 네 유형이 서로 다른 테이블에 살기 때문에 공통 repository 대신 union projection을 돌린다(계약
* 설명, spec §8.3).
*
* <p>정렬 키에 항상 {@code id}를 붙인다. {@code updated_at}만으로 자르면 같은 시각의 행들이 페이지 경계에서 중복되거나 누락된다 — 대량 저장 직후에
* 실제로 일어나는 일이다.
*/
@Repository
public class JdbcStudioDocumentQueryAdapter implements StudioDocumentQueryPort {
private final JdbcClient jdbcClient;
public JdbcStudioDocumentQueryAdapter(JdbcClient jdbcClient) {
this.jdbcClient = jdbcClient;
}
@Override
public DocumentPageView list(ListDocumentsQuery query) {
StringBuilder sql = new StringBuilder(StudioDocumentSql.documentProjectionCte());
sql.append(" SELECT * FROM studio_document WHERE 1 = 1");
Map<String, Object> params = new java.util.HashMap<>();
if (query.kind() != null) {
sql.append(" AND kind = :kind");
params.put("kind", query.kind().name());
}
if (query.projectId() != null) {
sql.append(" AND project_id = :projectId");
params.put("projectId", query.projectId());
}
if (query.nextAction() != null) {
sql.append(" AND next_action = :nextAction");
params.put("nextAction", query.nextAction().name());
}
if (query.publicationStatus() != null) {
sql.append(publicationStatusPredicate());
params.put("publicationStatus", query.publicationStatus().name());
}
if (query.query() != null && !query.query().isBlank()) {
sql.append(" AND lower(title) LIKE :titlePattern");
params.put("titlePattern", "%" + query.query().toLowerCase(Locale.ROOT) + "%");
}
appendCursorPredicate(sql, params, query);
sql.append(orderBy(query.sort()));
// limit + 1 을 읽어 "다음 쪽이 있는가"를 별도 count 없이 판정한다.
sql.append(" LIMIT :limitPlusOne");
params.put("limitPlusOne", query.limit() + 1);
var spec = jdbcClient.sql(sql.toString());
for (Map.Entry<String, Object> param : params.entrySet()) {
spec = spec.param(param.getKey(), param.getValue());
}
List<Row> rows = spec.query((rs, rowNum) -> readRow(rs)).list();
boolean hasMore = rows.size() > query.limit();
List<Row> page = hasMore ? rows.subList(0, query.limit()) : rows;
List<DocumentSummaryView> items = new ArrayList<>(page.size());
for (Row row : page) {
items.add(row.summary());
}
return new DocumentPageView(
items, hasMore ? cursorPayload(page.getLast(), query.sort()) : null);
}
/**
* 게시 이력이 없는 문서는 {@code publication} 행 자체가 없다 — {@code NEVER_PUBLISHED}는 "행이 없음"이지 특정 status 값이
* 아니다.
*/
private static String publicationStatusPredicate() {
return " AND ((:publicationStatus = 'NEVER_PUBLISHED' AND publication_status IS NULL)"
+ " OR publication_status = :publicationStatus)";
}
private static String orderBy(DocumentSort sort) {
return switch (sort) {
case UPDATED_DESC -> " ORDER BY updated_at DESC, id DESC";
case UPDATED_ASC -> " ORDER BY updated_at ASC, id ASC";
case TITLE_ASC -> " ORDER BY title ASC, id ASC";
};
}
private static void appendCursorPredicate(
StringBuilder sql, Map<String, Object> params, ListDocumentsQuery query) {
DocumentCursorPosition position = query.position();
if (position == null) {
return;
}
// switch 문이 아니라 식이다 — 열거 전부를 다루면 default 가 필요 없고, 정렬이 늘면 컴파일러가
// 여기서 막아 준다(문이면 커서 조건 없이 조용히 첫 페이지를 다시 준다).
String predicate =
switch (query.sort()) {
case UPDATED_DESC -> {
params.put("cursorUpdatedAt", Timestamp.from(position.updatedAt()));
yield " AND (updated_at, id) < (:cursorUpdatedAt, :cursorId)";
}
case UPDATED_ASC -> {
params.put("cursorUpdatedAt", Timestamp.from(position.updatedAt()));
yield " AND (updated_at, id) > (:cursorUpdatedAt, :cursorId)";
}
case TITLE_ASC -> {
params.put("cursorTitle", position.title());
yield " AND (title, id) > (:cursorTitle, :cursorId)";
}
};
params.put("cursorId", position.id());
sql.append(predicate);
}
/** 다음 쪽의 시작 위치. web 계층이 이 값을 서명해 opaque cursor 로 만든다. */
private static String cursorPayload(Row last, DocumentSort sort) {
return switch (sort) {
case UPDATED_DESC, UPDATED_ASC -> last.updatedAt().toInstant() + "|" + last.summary().id();
case TITLE_ASC -> last.title() + "|" + last.summary().id();
};
}
/** 커서 계산에 필요한 정렬 키만 요약과 함께 들고 다닌다. */
private record Row(DocumentSummaryView summary, java.sql.Timestamp updatedAt, String title) {}
private static Row readRow(java.sql.ResultSet rs) throws java.sql.SQLException {
return new Row(
StudioDocumentRowMapper.read(rs), rs.getTimestamp("updated_at"), rs.getString("title"));
}
}

Some files were not shown because too many files have changed in this diff Show More