Compare commits

..
9 Commits
Author SHA1 Message Date
DongHyeonkaandClaude Opus 5 d5889d644a merge: feature/techlog-studio-slices-2-5 — Tech Log Studio 백엔드 17개 operation (슬라이스 2~5)
studio-v1.yaml 19개 operation을 19/19로 완성한다. Plan 01이 남긴 17개(문서 CRUD,
검증·미리보기, 게시, Asset)와 Plan 02로 미뤘던 생성기 union 차단 요인 해소.

fix/release-gate-backend의 e615c24(Studio를 계약 경로로 서빙)도 함께 들어온다 —
이 브랜치가 거기서 갈라져 나왔고, 컨트롤러가 /api 접두사를 중복하지 않는다는
그 수정이 이번 구현의 전제다.

범위는 spec §2.1의 studio-v1 19개다. public-v1(18)/studio-management-v1(79)은
spec §2.2가 선언한 out of scope로 남는다.

AGENTS.md의 commit 정책은 human-only다. 사용자가 명시적으로 지시해 예외로 수행한다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 23:22:07 +09:00
DongHyeonkaandClaude Opus 5 48ff648112 feat: Tech Log Studio 백엔드 — 남은 17개 operation 구현 (슬라이스 2~5)
studio-v1.yaml 19개 operation 중 Plan 01이 남긴 17개를 구현한다. 문서 CRUD,
검증·미리보기, 게시, Asset. 이로써 studio-v1은 19/19다.

Plan 01이 Plan 02로 미룬 생성기 union 차단 요인
- 계약 원본은 그대로 두고 prepareStudioCodegenSpec이 생성 직전에 사본을 파생시킨다.
  oneOf+discriminator를 가진 스키마의 하위 타입에 x-implements를 주입하고, union 자체는
  생성을 억제한 뒤 같은 package에 Jackson 다형성 인터페이스를 계약에서 파생해 써 넣는다
- 파생 규칙을 계약의 oneOf/discriminator.mapping에서 읽으므로 union 목록을 손으로
  관리하지 않는다. 계약에 union이 늘면 따라온다
- openApiNullable=false. JsonNullable을 읽는 모듈은 Jackson 2용인데 이 앱의 HTTP
  변환기는 Jackson 3(tools.jackson)다 — 등록될 수 없어 직렬화가 POJO로 새고
  역직렬화가 깨진다. 해당 필드는 계약상 required라 "없음"과 "null"을 구분할 필요도 없다
- 모든 분기가 type:string인 이름 없는 oneOf는 접는다. 안 접으면 필드 0개 껍데기
  클래스가 나와 slug가 {}로 직렬화된다
- oneOf:[X,null]도 접는다. 그대로 두면 같은 모양의 래퍼 타입이 7벌 더 생긴다
- StudioContractUnionJacksonTest가 이 배선을 지킨다. 파생이 깨지면 컴파일이 깨진다

설계 스키마의 구멍 — V8__techlog_studio_working_copy.sql
V7(설계 패키지 database/V1__init.sql)은 유형마다 다른 물리 모델인데 계약은 네 유형을
공통 base + 유형별 확장이라는 하나의 편집 흐름으로 다룬다. 계약이 요구하는데 없던 것:
- document.summary / case_detail.environment,reproduction / reference_detail.rules,examples
- open_question.options,resolution_evidence_target_id,resolution_link_label
- project_decision.title,slug,summary,primary_topic_id
- problem/conclusion/scope_summary/statement가 varchar라 계약의 100000자를 담을 수 없어 text로 넓힘
- project_decision.project_id NOT NULL은 계약이 명시적으로 허용한 초안 저장을
  구조적으로 막고 있었다(게시 필수 여부는 검증이 판단한다) — 풀었다
- studio_relation: 계약의 relations[]는 네 유형 공통이고 항목마다 자체 id와 reason이
  있다. document_relation은 복합 PK라 둘 다 없고 문서끼리만 성립한다.
  (source_kind, source_id) 다형 참조는 studio_validation/studio_preview가 이미 쓰는 방식이다

영속은 JdbcClient
spec §8.3은 쓰기에 JPA @Version을 적었지만 이 네 aggregate는 Studio 저장 경로에서만
쓰이고 UPDATE ... WHERE version = :expectedVersion의 갱신 행 수가 정확히 같은 의미를
준다. 여덟 개 넘는 테이블에 엔티티를 세우는 비용에 상응하는 이득이 없다. 포트 계약이
같으므로 나중에 JPA가 필요하면 어댑터만 바뀐다.

nextAction/dependencyRevision 계산은 SQL 한 벌(StudioDocumentSql)
목록과 상세가 각자 계산하면 "목록에선 게시하라더니 열어보니 검증하라"가 된다.
계약의 nextAction 필터도 SQL이라야 페이지네이션을 깨지 않고 걸 수 있다.

렌더러 (ADR-005)
- commonmark + GFM 확장. 설계 05장 §16대로 라이브러리는 render 패키지 밖으로 안 나간다.
  프론트가 remark 계열로 같은 CommonMark+GFM 기준을 쓰므로 동등성이 유지된다
- ::: directive는 줄 단위 스캔이다. v1 문법에서 중첩이 없고 줄 맨 앞에서만 열린다.
  코드 펜스 안의 :::는 directive로 보지 않는다
- 컨테이너/leaf 판정은 닫는 줄이 실제로 있는지로 한다. 이름 목록으로 정하면 directive를
  더할 때마다 목록을 고쳐야 하고, "닫는 줄 없으면 문서 끝까지"면 닫기를 빠뜨린
  directive 하나가 뒤 내용을 통째로 삼킨다
- 계약이 표현 못 하는 것은 조용히 바꾸지 않고 경고로 남긴다 — 수평선, 머리글 없는 표,
  알 수 없는 directive, 미해결 asset key(경로를 지어내지 않고 버린다)
- RenderModelPort 구현이 inbound web에 있다. 렌더 모델은 계약 DTO이고 그 타입을 소유한
  모듈이 거기다. application에 같은 모양을 한 벌 더 두면 두 정의가 갈라진다

검증 체인
판정 기준은 하나다 — 이 편집본으로 계약이 요구하는 PublicRenderModel을 만들 수 있는가.
각 규칙은 렌더 모델의 required/minLength/minItems에서 나온다. 다른 기준을 쓰면 검증을
통과한 문서가 렌더 단계에서 계약을 위반한다. 첫 오류에서 멈추지 않고 끝까지 모은다.

게시 (spec §7.5 20단계)
- Snapshot의 렌더 모델은 게시 시점에 다시 렌더링하지 않고 사용자가 확인한 미리보기의
  것을 그대로 쓴다. 다시 렌더링하면 승인한 화면과 공개된 화면이 달라질 수 있다
- 단계별 실패가 서로 다른 계약 코드로 나간다. DOCUMENT_VALIDATION_FAILED(지금 검증하면
  실패)와 VALIDATION_STALE(통과했으나 전제가 바뀜)은 다른 사건이고 할 일도 다르다
- 게시 취소는 route도 Snapshot도 지우지 않는다. 지우면 공개된 링크가 끊긴다

Asset
- 확장자와 클라이언트 Content-Type을 신뢰하지 않고 파일 시작 바이트로 판정한다.
  모르는 형식은 저장하지 않고 415로 거절한다
- 바이너리는 기존 object storage 어댑터에 위임한다(spec §9). ObjectStoragePort는
  deprecated지만 이 저장소에서 실제 구현이 붙어 있는 유일한 포트다 — 선택을 브리지
  한 클래스에 가뒀다. 저장 백엔드가 없는 배포는 업로드·삭제만 503이고 나머지는 동작한다
- 공개 이력이 있거나 사용 중인 Asset은 hard delete하지 않는다

검증 — "통과하는데 동작 안 함"을 세 겹으로 막았다
- StudioContractDriftTest에 반대 방향(계약 → published)을 추가했다. 기존 한 방향은
  사라진 operation을 못 잡는다. 양방향 모두 실제로 RED가 되는 것을 확인했다
- postgresqlTechLogStudioPersistenceIntegrationTest 신규 11개. 이 저장소의 check는
  Testcontainers를 돌리지 않아 이 테스트가 없으면 SQL이 한 번도 실행되지 않는다.
  첫 실행에서 실제 결함을 잡았다: fk_publication_latest_event의 지연 검사는 트랜잭션
  끝에 일어나므로 autocommit이면 첫 INSERT에서 위반된다 → 어댑터가 진입 시 활성
  트랜잭션을 확인하고 아니면 원인을 그대로 말하며 실패한다
- 실제 앱 부팅으로 두 건을 더 잡았다. check에 전체 앱 부팅 테스트가 없어 생긴 구멍이다
  1. 생성자 모호성 — 프로덕션/테스트 두 생성자에 표시가 없어 기본 생성자를 찾다 실패
  2. final 클래스 + AOP — @RequiresPermission은 CGLIB 프록시를 쓰는데 final은 subclass
     불가. 템플릿의 NotificationDispatchUseCase가 final이면서 무사한 것은 그 능력이 꺼진
     배포에서 빈으로 등록되지 않아서다. Studio use case는 항상 등록된다

가드레일이 잡은 것
- MUTATING_USE_CASES_DECLARE_REQUIRED_PERMISSION → studio:write 부여, role 매핑은
  프로파일에. application.yml의 role-permissions:{} 기준선은 SampleRemovalSmokeContractTest가 지킨다
- NO_CONTEXT_DEPENDS_ON_STUDIO_FACADE 223건 → 어댑터 패키지를 persistence.techlog.studio.*로
  옮겼다. 규칙을 고치지 않았고, 그 이름이 우회가 아니라 더 정확하다
- verifyEnvKeys → 새 APP_ 키 4건 등록

범위
studio-v1의 19개 전부. public-v1(18) / studio-management-v1(79)은 spec §2.2가 선언한
out of scope다 — 전자는 소비자가 아직 없고 후자는 secondary capability 보존 계약이다.

검증: ./gradlew check BUILD SUCCESSFUL (245 task), 전체 3,721 테스트 실패 0,
techlog PostgreSQL 통합 테스트 3종 통과, 실제 앱 부팅 확인.

AGENTS.md의 commit 정책은 human-only다. 이 커밋은 사용자가 "전부 커밋하고 머지
진행하세요"로 명시적으로 지시해 예외로 수행한다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 23:21:43 +09:00
DongHyeonkaandClaude Opus 5 e615c24152 fix: serve Studio at the contract path instead of /api/api/v1/...
PresentationWebConfig prefixes every controller mapping with
ca-skeleton.presentation.api-base-path ("/api"), which is why every other
controller in this repository declares its path without it — healthcheck
is "/healthcheck", uploads are "/v1/uploads", files are "/v1/files". The
two Studio controllers declared "/api/v1/studio/..." instead, so the
prefix landed on top of a path that already had it and both operations
were served at /api/api/v1/studio/... — nowhere near the address
studio-v1.yaml declares (servers: "/", paths: /api/v1/studio/...). The
frontend calls the contract path, so nothing connected.

Verified against a running backend on PostgreSQL behind a real Keycloak:

  /api/v1/studio/catalog?type=TOPIC      200, 3 items   (was 404)
  /api/api/v1/studio/catalog?type=TOPIC  404            (was 200)

Why the tests were green while production was broken: the three slice
tests and both nested apps in StudioContractDriftTest build contexts that
never include PresentationWebConfig, so no prefix was applied and the
controllers' literal "/api/v1/..." matched. They now import it and supply
the same "/api" the real app uses, which makes the paths they exercise the
effective ones. Re-introducing the bug fails five of them.

StudioContractDriftTest needed two more repairs to stay meaningful:
springdoc's own endpoint is prefixed too, so the published document is
read from /api/v3/api-docs; and publishedStudioOperationsMatchTheContract
skips any path not starting with /api/v1/studio/, so a missing prefix
would have made it compare nothing and pass. It now asserts it compared at
least one path — a gate that cannot see drift is not the same as no drift.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 18:26:34 +09:00
DongHyeonka ab0447a0f9 fix: 설계 문서 제거 2026-08-19 15:56:29 +09:00
DongHyeonkaandClaude Opus 5 6eec8a0656 merge: feature/techlog-studio-backend — Tech Log Studio 백엔드 기반 (Plan 01)
계약 배선·오류 코드 23종·bounded context 경계 규칙 7종·V7 스키마 28테이블·
엔드포인트 2종(getStudioSession, listStudioCatalog)·드리프트 게이트 4종.

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 15:15:07 +09:00
DongHyeonkaandClaude Opus 5 91e6d99654 feat: Tech Log Studio 백엔드 기반 — 계약 배선, 오류 코드, 경계 규칙, 스키마, 엔드포인트 2종
설계 패키지의 studio-v1.yaml(v3.0.0, 응답 봉투)을 이 저장소에 배선하고
슬라이스 1의 기반을 세운다. 19개 operation 중 getStudioSession과
listStudioCatalog를 구현했다.

계약과 생성
- src/config/openapi/studio-v1.yaml 을 vendor하고 MANIFEST에 출처 커밋을 기록
- openapi-generator로 DTO(model)만 생성한다. generateApis 대신
  globalProperties.set(['models': '']) — 그 두 속성은 플러그인 7.18.0에 없다
- useOneOfInterfaces=false. 그 대가로 discriminator union 5종의 Jackson 배선이
  깨진다(spec §3.1). 그 5종을 쓰는 7개 operation은 Plan 02에서 전략을 정한 뒤 구현한다
- 생성 코드는 별도 generatedOpenapi sourceSet에 둔다. -Werror가 생성물의 deprecated
  API 사용을 빌드 실패로 승격하기 때문이다. jar와 test 클래스패스에 별도로 얹는다

오류 계약
- StudioError 23종(계약 ApiError.code와 1:1) + StudioException(ApiErrorCarrier)
- StudioExceptionHandler는 techlog 패키지로 범위를 좁힌다. 다른 기능의 오류 응답을
  바꾸지 않기 위해서다
- 클라이언트 문구는 레지스트리의 client_safe_message에서 가져오고 예외 메시지는
  로그 전용이다(ApiErrorCarrier javadoc의 요구)
- 바인딩 예외를 봉투로 옮긴다. 그러지 않으면 bare RFC 7807이 새어 나가 ADR-006을 위반한다

게이트
- TechLogBoundaryArchTest 7종 — spec §4.3의 bounded context 경계. Gradle leaf를
  늘릴 수 없어 이 규칙이 경계의 유일한 방어선이다
- StudioErrorRegistryTest — enum ↔ 레지스트리 ↔ 계약 3축 대조, vendor 사본 해시 검증
- StudioContractDriftTest — springdoc 표면이 계약을 벗어나면 실패. @ComponentScan이라
  새 컨트롤러가 자동으로 걸린다
- StudioSessionCsrfHeaderProfileContractTest — 배포 가능한 세 프로파일이 계약의
  csrf-header-name const로 해소되는지 고정. 이 저장소는 실제 composition root를
  테스트에서 부팅할 수 없어 파일 단언으로 그 층을 덮는다

스키마
- V7__techlog_core.sql, 28 테이블. 설계 DDL에서 studio_idempotency(기존
  idempotency_record 재사용)와 범위 밖 6종을 제외했다
- 원본의 tech_log 스키마 대신 public을 쓴다. 원본의 SET search_path는 Flyway
  세션에만 적용되고 런타임 커넥션 풀은 상속하지 않는다

알려진 제약
- getStudioSession은 세션 인프라(redis-session)가 없어 503 STUDIO_UNAVAILABLE을
  반환한다. 계약이 이 operation에 허용하는 유일한 실패 코드다. 가짜 CSRF 토큰으로
  200을 만들지 않았다
- 따라서 슬라이스 1의 "프론트 로그인 실동작" 목표는 아직 달성되지 않았다

이 커밋은 AGENTS.md의 human-only 커밋 정책에 대한 저장소 소유자의 명시적 지시로
작성됐다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 15:14:52 +09:00
DongHyeonka 697fc740e6 chore: remove sample portfolio module 2026-08-13 21:47:15 +09:00
DongHyeonka e552e317d6 chore: initialize tech log backend 2026-08-13 20:32:52 +09:00
DongHyeonka e64e701fe5 chore: initialize from backend template 0a6dd0e 2026-08-13 20:31:02 +09:00
543 changed files with 21638 additions and 34522 deletions
-7
View File
@@ -178,13 +178,6 @@ gates:
workflow: object-storage-qualification.yml workflow: object-storage-qualification.yml
job: minio-managed-contract job: minio-managed-contract
execution: explicit execution: explicit
- id: poster-image-migration
release_blocking: true
mechanism: gradle-custom-task
ref: posterImageMigrationTest
workflow: object-storage-qualification.yml
job: poster-image-v7-migration
execution: explicit
- id: object-storage-minio-managed-fault - id: object-storage-minio-managed-fault
release_blocking: conditional release_blocking: conditional
mechanism: gradle-custom-task mechanism: gradle-custom-task
+3 -2
View File
@@ -25,10 +25,11 @@ fi
readonly REPO_ROOT readonly REPO_ROOT
readonly MATRIX="${REPO_ROOT}/.github/ci-gate-matrix.yml" readonly MATRIX="${REPO_ROOT}/.github/ci-gate-matrix.yml"
# Deliberately a literal: a gate silently appearing or disappearing is the drift this lint exists to # Deliberately a literal: a gate silently appearing or disappearing is the drift this lint exists to
# catch, so growing the matrix is an explicit edit here. 38 as of the HTTP Client platform hardening, # catch, so growing the matrix is an explicit edit here. 37 after removing the sample-only Poster
# migration gate; the HTTP Client platform hardening
# which registered httpclient-spring62-runtime as a delegated-pending control — the 6.2 *runtime* # which registered httpclient-spring62-runtime as a delegated-pending control — the 6.2 *runtime*
# claim, distinct from the API-surface scan that was standing in for it. # claim, distinct from the API-surface scan that was standing in for it.
readonly EXPECTED_GATE_COUNT=38 readonly EXPECTED_GATE_COUNT=37
if [[ ! -f "${MATRIX}" ]]; then if [[ ! -f "${MATRIX}" ]]; then
printf '::error::gate-matrix-lint: missing %s\n' "${MATRIX}" >&2 printf '::error::gate-matrix-lint: missing %s\n' "${MATRIX}" >&2
+1 -1
View File
@@ -26,7 +26,7 @@ readonly EXPECTED_WORKFLOW_LOCK=(
'ad84000efc438ee7439517b8f85819e62b13dab0aa4f94066c2905060f3bb581 .github/workflows/httpclient-release.yml' 'ad84000efc438ee7439517b8f85819e62b13dab0aa4f94066c2905060f3bb581 .github/workflows/httpclient-release.yml'
'59cb3a0ffc687a15eefe96bc5e3a70d42be78e1cc85d2e7f7880dac6124ca4c7 .github/workflows/jpa-r2-evidence.yml' '59cb3a0ffc687a15eefe96bc5e3a70d42be78e1cc85d2e7f7880dac6124ca4c7 .github/workflows/jpa-r2-evidence.yml'
'5be7e931db749029d89787da042d6d7cf8e683d60698bd8a2993c29db26355fb .github/workflows/link-check.yml' '5be7e931db749029d89787da042d6d7cf8e683d60698bd8a2993c29db26355fb .github/workflows/link-check.yml'
'64245586cd5936f1a5647b57f2cd9acd316f96fd75f713b1890decb812e7d5fe .github/workflows/object-storage-qualification.yml' '2f1c46cc8b7b39890e5debe90f78d19b9347ed53f499b282539dae88aefdd574 .github/workflows/object-storage-qualification.yml'
'cbc104ea486c746229895e804e3be7716e056a02cce0588c537bce9f442f8b38 .github/workflows/redis-sdk-topology.yml' 'cbc104ea486c746229895e804e3be7716e056a02cce0588c537bce9f442f8b38 .github/workflows/redis-sdk-topology.yml'
) )
readonly EXPECTED_WRAPPER_PROPERTIES=( readonly EXPECTED_WRAPPER_PROPERTIES=(
@@ -19,26 +19,6 @@ env:
TESTCONTAINERS_REUSE_ENABLE: "false" TESTCONTAINERS_REUSE_ENABLE: "false"
jobs: jobs:
poster-image-v7-migration:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- name: Validate Gradle wrapper
id: gradle-wrapper-validation
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- name: Run non-skipping Poster image migration qualification
working-directory: src
run: ./gradlew :sample-portfolio:posterImageMigrationTest --no-daemon --stacktrace
minio-managed-contract: minio-managed-contract:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
+6 -8
View File
@@ -4,7 +4,7 @@
이 저장소는 단순한 예제 블로그 애플리케이션이 아니라, Java 21 + Spring Boot 4.0.0 + Gradle 멀티모듈 기반의 Clean Architecture 템플릿이다. 이 저장소는 단순한 예제 블로그 애플리케이션이 아니라, Java 21 + Spring Boot 4.0.0 + Gradle 멀티모듈 기반의 Clean Architecture 템플릿이다.
기본 패키지는 `dev.caskeleton`이며, 예시 도메인은 production 모듈이 아니라 `sample-portfolio` 모듈(WorkLog 엔지니어링 작업 기록 게시판)에 격리한다. 새 프로젝트를 시작할 때는 도메인 이름, 패키지, 엔티티, 유스케이스를 교체할 수 있지만, 모듈 경계와 의존성 방향은 유지해야 한다. 기본 패키지는 `dev.caskeleton`이며 구체적인 예시 도메인은 제거되어 있다. 새 프로젝트를 시작할 때는 production 모듈에 도메인, 엔티티, 유스케이스를 추가할 수 있지만, 모듈 경계와 의존성 방향은 유지해야 한다.
## Prime Directive ## Prime Directive
@@ -49,8 +49,8 @@ root `CLAUDE.md`는 이 목록의 동기화된 요약이다. 두 문서가 어
## Gradle 정책 권위 ## Gradle 정책 권위
- `src/config/architecture/modules.json`: 정확히 19개 leaf의 ID, repository-relative 소스 경로, - `src/config/architecture/modules.json`: 정확히 18개 leaf의 ID, repository-relative 소스 경로,
Gradle path, 허용 production project dependency edge, composition root의 실제 runtime Gradle path, 허용 production project dependency edge, production composition root의 실제 runtime
membership membership
- `src/settings.gradle`: registry를 fail-closed로 검증하고 등록된 Gradle project를 include/mapping - `src/settings.gradle`: registry를 fail-closed로 검증하고 등록된 Gradle project를 include/mapping
- `src/build.gradle`: 같은 registry를 읽는 `verifyCleanArchitectureDependencies`와 그 밖의 - `src/build.gradle`: 같은 registry를 읽는 `verifyCleanArchitectureDependencies`와 그 밖의
@@ -102,7 +102,7 @@ commit 정책은 모든 플랫폼에서 `human-only`이며 agent는 stage/commit
## 모듈 책임 ## 모듈 책임
19개 leaf 모듈의 ID, 실제 소스 경로, Gradle path, 허용 production 의존성, runtime membership은 18개 leaf 모듈의 ID, 실제 소스 경로, Gradle path, 허용 production 의존성, runtime membership은
`src/config/architecture/modules.json`이 SSOT다. focused test는 소유 leaf의 `gradle_path`에서 `src/config/architecture/modules.json`이 SSOT다. focused test는 소유 leaf의 `gradle_path`에서
파생한다. 이 문서는 leaf 목록을 복제하지 않고 family 책임만 정의한다. 작업 파일에서는 가장 가까운 파생한다. 이 문서는 leaf 목록을 복제하지 않고 family 책임만 정의한다. 작업 파일에서는 가장 가까운
`src/**/CLAUDE.md`를 함께 읽는다. `src/**/CLAUDE.md`를 함께 읽는다.
@@ -118,7 +118,6 @@ commit 정책은 모든 플랫폼에서 `human-only`이며 agent는 stage/commit
file server, HTTP client, identifier 능력을 port 뒤에서 구현한다. adapter 간 허용 edge는 file server, HTTP client, identifier 능력을 port 뒤에서 구현한다. adapter 간 허용 edge는
registry만 따른다. registry만 따른다.
- `shared-contract`: skeleton-wide 운영 계약. business/domain 개념 저장 금지. - `shared-contract`: skeleton-wide 운영 계약. business/domain 개념 저장 금지.
- `sample-portfolio`: 샘플/fixture consumer. production leaf가 의존하면 안 된다.
- `app-bootstrap`: Spring Boot entrypoint와 composition root. 비즈니스 유스케이스 금지. - `app-bootstrap`: Spring Boot entrypoint와 composition root. 비즈니스 유스케이스 금지.
## 의존성 방향 ## 의존성 방향
@@ -129,7 +128,6 @@ family 수준 기본 방향:
app-bootstrap -> adapter:inbound:* -> application-core -> domain-core app-bootstrap -> adapter:inbound:* -> application-core -> domain-core
app-bootstrap -> adapter:outbound:* -> application-core -> domain-core app-bootstrap -> adapter:outbound:* -> application-core -> domain-core
runtime modules -> shared-contract runtime modules -> shared-contract
sample-portfolio -> registered runtime leaves (fixture consumer only)
``` ```
개별 edge는 `src/config/architecture/modules.json``allowed_dependencies`가 유일한 목록이다. 개별 edge는 `src/config/architecture/modules.json``allowed_dependencies`가 유일한 목록이다.
@@ -182,7 +180,7 @@ cd src
``` ```
소유 leaf의 정확한 Gradle path는 `src/config/architecture/modules.json`에서 읽고 focused test 소유 leaf의 정확한 Gradle path는 `src/config/architecture/modules.json`에서 읽고 focused test
명령을 파생한다. root 문서에 19개 명령 목록을 복제하지 않는다. 명령을 파생한다. root 문서에 18개 명령 목록을 복제하지 않는다.
## 설정과 런타임 ## 설정과 런타임
@@ -208,7 +206,7 @@ Docker/runtime 규칙:
1. `src/settings.gradle``rootProject.name`을 새 프로젝트명으로 바꾼다. 1. `src/settings.gradle``rootProject.name`을 새 프로젝트명으로 바꾼다.
2. Java package `dev.caskeleton`을 새 organization/project package로 바꾼다. 2. Java package `dev.caskeleton`을 새 organization/project package로 바꾼다.
3. `CaSkeletonApplication` 이름을 새 애플리케이션 이름으로 바꾼다. 3. `CaSkeletonApplication` 이름을 새 애플리케이션 이름으로 바꾼다.
4. production 모듈에는 목표 도메인의 entity, repository port, use case, adapter만 추가하고, 예시 코드는 `sample-portfolio`에 격리한다. 4. production 모듈에는 목표 도메인의 entity, repository port, use case, adapter만 추가한다.
5. 모듈 이름과 경계는 유지한다. 5. 모듈 이름과 경계는 유지한다.
6. Docker image/application 이름을 새 프로젝트 기준으로 수정한다. 6. Docker image/application 이름을 새 프로젝트 기준으로 수정한다.
7. `.env`, `.env.local`, `application.yml`의 예시 값을 새 런타임 요구사항에 맞춘다. 7. `.env`, `.env.local`, `application.yml`의 예시 값을 새 런타임 요구사항에 맞춘다.
+3 -4
View File
@@ -21,9 +21,9 @@ If this summary drifts from `AGENTS.md`, `AGENTS.md` wins and this summary must
## Gradle policy authorities ## Gradle policy authorities
- `src/config/architecture/modules.json`: exactly 19 leaf identities, repository-relative source - `src/config/architecture/modules.json`: exactly 18 leaf identities, repository-relative source
paths, Gradle paths, allowed production project dependency edges, and the exact runtime paths, Gradle paths, allowed production project dependency edges, and the exact runtime
memberships of both composition roots. membership of the production composition root.
- `src/settings.gradle`: fail-closed registry validation, project inclusion, and directory mapping. - `src/settings.gradle`: fail-closed registry validation, project inclusion, and directory mapping.
- `src/build.gradle`: `verifyCleanArchitectureDependencies` and the other architecture-wide - `src/build.gradle`: `verifyCleanArchitectureDependencies` and the other architecture-wide
verification tasks. verification tasks.
@@ -43,7 +43,7 @@ count.
## Module families ## Module families
`src/config/architecture/modules.json` owns the complete 19-leaf list. Root guidance summarizes `src/config/architecture/modules.json` owns the complete 18-leaf list. Root guidance summarizes
families; the nearest `src/**/CLAUDE.md` owns local rules. families; the nearest `src/**/CLAUDE.md` owns local rules.
| Family | Responsibility | Stable dependency direction | | Family | Responsibility | Stable dependency direction |
@@ -54,7 +54,6 @@ families; the nearest `src/**/CLAUDE.md` owns local rules.
| `adapter:outbound:persistence-*` | JPA/PostgreSQL and MongoDB persistence adapters | application/domain/shared contracts as registered | | `adapter:outbound:persistence-*` | JPA/PostgreSQL and MongoDB persistence adapters | application/domain/shared contracts as registered |
| `adapter:outbound:*` | support, messaging, cache, notification, storage, file, HTTP client, identifier capabilities | application/domain/shared and registered support edge | | `adapter:outbound:*` | support, messaging, cache, notification, storage, file, HTTP client, identifier capabilities | application/domain/shared and registered support edge |
| `shared-contract` | Skeleton-wide operational contracts | Java stdlib only | | `shared-contract` | Skeleton-wide operational contracts | Java stdlib only |
| `sample-portfolio` | Fixture/reference consumer | registered runtime leaves; never a production dependency |
| `app-bootstrap` | Spring Boot entrypoint and composition root | registered runtime leaves | | `app-bootstrap` | Spring Boot entrypoint and composition root | registered runtime leaves |
Never infer an individual leaf's Gradle path, allowed dependency, or test command from this table. Never infer an individual leaf's Gradle path, allowed dependency, or test command from this table.
+12 -10
View File
@@ -1,6 +1,10 @@
# ca-skeleton — Clean Architecture Spring Boot 템플릿 # Tech Log Backend
ca-skeleton은 Java 21, Spring Boot 4.0.0, Gradle 멀티모듈 기반의 Clean Architecture 백엔드 템플릿입니다. fork해서 도메인·패키지·엔티티·유스케이스만 교체하면 새 서비스를 시작할 수 있고, 모듈 경계와 의존 방향은 그대로 유지합니다. 기본 패키지는 `dev.caskeleton`이며, 예시 도메인은 production 모듈이 아니라 `sample-portfolio` 모듈(WorkLog 작업 기록 게시판)에 격리합니다. Initialized from `clean-architecture-backend-template` revision
`0a6dd0e419620683de48f69b5c6d22d9964b6f44` as a tracked snapshot. Template
updates are applied explicitly and recorded in `template.lock.json`.
ca-skeleton은 Java 21, Spring Boot 4.0.0, Gradle 멀티모듈 기반의 Clean Architecture 백엔드 템플릿입니다. fork해서 도메인·패키지·엔티티·유스케이스를 추가하면 새 서비스를 시작할 수 있고, 모듈 경계와 의존 방향은 그대로 유지합니다. 기본 패키지는 `dev.caskeleton`이며, 구체적인 예시 도메인은 제거되어 있습니다.
이 문서는 전체 구조와 첫 실행만 다룹니다. 모듈별 상세 규칙과 설계 근거는 각 모듈의 README와 `CLAUDE.md`가, 빌드·환경 변수 상세는 [src/README.md](src/README.md)가 소유합니다. 이 문서는 전체 구조와 첫 실행만 다룹니다. 모듈별 상세 규칙과 설계 근거는 각 모듈의 README와 `CLAUDE.md`가, 빌드·환경 변수 상세는 [src/README.md](src/README.md)가 소유합니다.
@@ -12,7 +16,6 @@ ca-skeleton은 Java 21, Spring Boot 4.0.0, Gradle 멀티모듈 기반의 Clean A
app-bootstrap -> adapter:inbound:* -> application-core -> domain-core app-bootstrap -> adapter:inbound:* -> application-core -> domain-core
app-bootstrap -> adapter:outbound:* -> application-core -> domain-core app-bootstrap -> adapter:outbound:* -> application-core -> domain-core
모든 런타임 모듈 -> shared-contract 모든 런타임 모듈 -> shared-contract
sample-portfolio -> 등록된 런타임 리프 (fixture 소비자 전용)
``` ```
family 수준의 책임은 다음과 같습니다. family 수준의 책임은 다음과 같습니다.
@@ -26,9 +29,8 @@ family 수준의 책임은 다음과 같습니다.
| `adapter:outbound:*` | support(공유 베이스)·messaging·cache·notification·object storage·file·HTTP client·identifier 능력을 port 뒤에서 구현. 외부 연동 어댑터(messaging·cache·notification·HTTP client)는 기본 비활성 | | `adapter:outbound:*` | support(공유 베이스)·messaging·cache·notification·object storage·file·HTTP client·identifier 능력을 port 뒤에서 구현. 외부 연동 어댑터(messaging·cache·notification·HTTP client)는 기본 비활성 |
| `shared-contract` | skeleton 전역 운영 계약. business/domain 개념 저장 금지 | | `shared-contract` | skeleton 전역 운영 계약. business/domain 개념 저장 금지 |
| `app-bootstrap` | Spring Boot entrypoint와 composition root | | `app-bootstrap` | Spring Boot entrypoint와 composition root |
| `sample-portfolio` | WorkLog 예시 도메인(fixture/reference). production이 의존하지 않음 |
정확한 19개 leaf 목록과 각 leaf의 Gradle path·소스 경로·허용 production 의존 edge는 정확한 18개 leaf 목록과 각 leaf의 Gradle path·소스 경로·허용 production 의존 edge는
[src/config/architecture/modules.json](src/config/architecture/modules.json)이 SSOT입니다. focused [src/config/architecture/modules.json](src/config/architecture/modules.json)이 SSOT입니다. focused
test는 해당 Gradle path에서 `./gradlew <gradle-path>:test --console=plain` 형태로 파생하며, root test는 해당 Gradle path에서 `./gradlew <gradle-path>:test --console=plain` 형태로 파생하며, root
문서나 기억에서 개별 leaf edge를 추론하지 않습니다. 문서나 기억에서 개별 leaf edge를 추론하지 않습니다.
@@ -42,7 +44,7 @@ cd src
./gradlew bootstrap ./gradlew bootstrap
``` ```
`bootstrap`은 compile 검사, PostgreSQL Compose 기동, 애플리케이션 이미지 build·기동(startup Flyway 포함), sample 격리 검증, `GET /api/healthcheck` HTTP smoke를 순서대로 실행합니다. 각 단계가 별도 Gradle task라 실패 단계가 task 이름으로 드러납니다. 기동을 확인하려면 health endpoint를 호출합니다. `bootstrap`은 compile 검사, PostgreSQL Compose 기동, 애플리케이션 이미지 build·기동(startup Flyway 포함), `GET /api/healthcheck` HTTP smoke를 순서대로 실행합니다. 각 단계가 별도 Gradle task라 실패 단계가 task 이름으로 드러납니다. 기동을 확인하려면 health endpoint를 호출합니다.
```bash ```bash
curl -fsS http://localhost:8080/api/healthcheck curl -fsS http://localhost:8080/api/healthcheck
@@ -87,10 +89,10 @@ cd src
애플리케이션 이름 등 나머지 rename 단계는 위 체크리스트를 따릅니다. 애플리케이션 이름 등 나머지 rename 단계는 위 체크리스트를 따릅니다.
3. `CaSkeletonApplication`을 새 애플리케이션 이름으로 바꾸고, 목표 도메인의 엔티티·repository port·유스케이스·adapter를 production 모듈에 추가합니다. 예시 코드는 `sample-portfolio`에만 둡니다. 3. `CaSkeletonApplication`을 새 애플리케이션 이름으로 바꾸고, 목표 도메인의 엔티티·repository port·유스케이스·adapter를 production 모듈에 추가합니다.
4. 모듈 이름과 경계는 그대로 유지합니다. 4. 모듈 이름과 경계는 그대로 유지합니다.
검증은 sample-on과 sample-off 모두 통과시킵니다. 검증은 전체 테스트와 sample-off 재유입 방지 계약을 모두 통과시킵니다.
```bash ```bash
cd src cd src
@@ -98,7 +100,7 @@ cd src
./gradlew :app-bootstrap:sampleOffTest ./gradlew :app-bootstrap:sampleOffTest
``` ```
`sample-portfolio`는 템플릿이 유지하는 fixture/reference 모듈이라 production 모듈이 의존하지 않고, runtime에 sample bean이나 endpoint를 넣지 않습니다. 다운스트림 fork에서 fixture가 더 필요 없을 때만 sample-off 테스트를 통과시킨 뒤 정리합니다. `sampleOffTest`는 삭제된 샘플 타입이 production bootstrap classpath에 다시 들어오지 않는지 검증합니다.
## 아키텍처 규칙과 검증 ## 아키텍처 규칙과 검증
@@ -125,7 +127,7 @@ cd src
## 더 알아보기 ## 더 알아보기
- 빌드·검증 게이트·환경 변수 상세: [src/README.md](src/README.md) - 빌드·검증 게이트·환경 변수 상세: [src/README.md](src/README.md)
- 모듈 레지스트리(19개 leaf SSOT): [src/config/architecture/modules.json](src/config/architecture/modules.json) - 모듈 레지스트리(18개 leaf SSOT): [src/config/architecture/modules.json](src/config/architecture/modules.json)
- 에이전트·기여자 작업 규칙: [AGENTS.md](AGENTS.md) · [CLAUDE.md](CLAUDE.md) - 에이전트·기여자 작업 규칙: [AGENTS.md](AGENTS.md) · [CLAUDE.md](CLAUDE.md)
- 빌드·릴리스 공급망 파이프라인은 현재 Mode B 복구 범위에 포함되지 않았다. 현재 저장소가 - 빌드·릴리스 공급망 파이프라인은 현재 Mode B 복구 범위에 포함되지 않았다. 현재 저장소가
제공하는 canonical workflow는 품질·의존성 취약점·링크 검사이며, release/publish 자동화는 별도 제공하는 canonical workflow는 품질·의존성 취약점·링크 검사이며, release/publish 자동화는 별도
+2 -2
View File
@@ -32,8 +32,8 @@ services:
RELEASE_VERSION: "${RELEASE_VERSION:-0.0.1}" RELEASE_VERSION: "${RELEASE_VERSION:-0.0.1}"
BUILD_VERSION: "${BUILD_VERSION:-0.0.1+0000000}" BUILD_VERSION: "${BUILD_VERSION:-0.0.1+0000000}"
GIT_SHA: "${GIT_SHA:-0000000}" GIT_SHA: "${GIT_SHA:-0000000}"
SOURCE_URL: "${SOURCE_URL:-https://example.invalid/ca-tmpl}" SOURCE_URL: "${SOURCE_URL:-https://git.learn.hyeonworks.com/donghyeon.kang/tech-log-backend}"
image: caskeleton:${BUILD_VERSION:-0.0.1_local_0000000} image: tech-log-backend:${BUILD_VERSION:-0.0.1_local_0000000}
ports: ports:
- "${APP_SERVER_PORT:-8080}:8080" - "${APP_SERVER_PORT:-8080}:8080"
- "9001:9001" - "9001:9001"
+69 -1
View File
@@ -632,7 +632,7 @@ env_keys:
- name: APP_LOG_FILE_PATH - name: APP_LOG_FILE_PATH
# source: feature-log-management-contract — file path (relative to bootRun cwd or absolute) # source: feature-log-management-contract — file path (relative to bootRun cwd or absolute)
type: string type: string
default: logs/ca-skeleton.json default: logs/tech-log-backend.json
allowed_values: null allowed_values: null
classification: public-config classification: public-config
required: false required: false
@@ -3063,6 +3063,11 @@ env_keys:
required_test: cache-contract:redis-soft-hard-ttl-order required_test: cache-contract:redis-soft-hard-ttl-order
- name: APP_CACHE_REDIS_TTL_JITTER - name: APP_CACHE_REDIS_TTL_JITTER
# DEPRECATED 2026-08-13: removing the installed sample portfolio also removed the only
# application.yml binding for this key; app-bootstrap has no TTL-jitter consumer. The row is
# retained so deployments still setting it can be warned until its scheduled removal.
deprecated_orphaned: true
removal_deadline: 2026-11-30
type: float type: float
default: 0.10 default: 0.10
allowed_values: null allowed_values: null
@@ -4244,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
+312
View File
@@ -916,3 +916,315 @@ 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
+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]]
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())
+2 -2
View File
@@ -4,7 +4,7 @@
# ---------------------------------------------------------------------------- # ----------------------------------------------------------------------------
# ----- App identity ----- # ----- App identity -----
APP_NAME=ca-skeleton APP_NAME=tech-log-backend
SPRING_PROFILES_ACTIVE=local SPRING_PROFILES_ACTIVE=local
# ----- Runtime safety (StartupSafetyValidator, D8) ----- # ----- Runtime safety (StartupSafetyValidator, D8) -----
@@ -51,7 +51,7 @@ APP_LOG_LEVEL_SQL=WARN
# ----- Logging: file output + rolling ----- # ----- Logging: file output + rolling -----
APP_LOG_FILE_ENABLED=false APP_LOG_FILE_ENABLED=false
APP_LOG_FILE_PATH=logs/ca-skeleton.json APP_LOG_FILE_PATH=logs/tech-log-backend.json
APP_LOG_FILE_MAX_SIZE=100MB APP_LOG_FILE_MAX_SIZE=100MB
APP_LOG_FILE_MAX_HISTORY=14 APP_LOG_FILE_MAX_HISTORY=14
APP_LOG_FILE_TOTAL_SIZE_CAP=3GB APP_LOG_FILE_TOTAL_SIZE_CAP=3GB
+2 -2
View File
@@ -3,7 +3,7 @@
# feature-container-runtime-contract — multi-stage image build # feature-container-runtime-contract — multi-stage image build
# #
# Build requirements: # Build requirements:
# docker build -f src/Dockerfile src/ -t caskeleton:local \ # docker build -f src/Dockerfile src/ -t tech-log-backend:local \
# --build-arg RELEASE_VERSION=1.2.3 \ # --build-arg RELEASE_VERSION=1.2.3 \
# --build-arg BUILD_VERSION=1.2.3+a1b2c3d4e5f6 \ # --build-arg BUILD_VERSION=1.2.3+a1b2c3d4e5f6 \
# --build-arg GIT_SHA=a1b2c3d4e5f6 \ # --build-arg GIT_SHA=a1b2c3d4e5f6 \
@@ -64,7 +64,7 @@ ARG GIT_SHA
ARG SOURCE_URL ARG SOURCE_URL
# OCI image labels (build-arg placeholders — supply at docker build time). # OCI image labels (build-arg placeholders — supply at docker build time).
LABEL org.opencontainers.image.title="caskeleton" \ LABEL org.opencontainers.image.title="tech-log-backend" \
org.opencontainers.image.source="${SOURCE_URL}" \ org.opencontainers.image.source="${SOURCE_URL}" \
org.opencontainers.image.revision="${GIT_SHA}" \ org.opencontainers.image.revision="${GIT_SHA}" \
org.opencontainers.image.version="${BUILD_VERSION}" org.opencontainers.image.version="${BUILD_VERSION}"
-129
View File
@@ -1,129 +0,0 @@
# syntax=docker/dockerfile:1.7-labs@sha256:b99fecfe00268a8b556fad7d9c37ee25d716ae08a5d7320e6d51c4dd83246894
# =============================================================================
# sample-portfolio standalone demo image — twin of src/Dockerfile.
#
# Builds and runs the REFERENCE app (SamplePortfolioApplication), not the
# production composition root (CaSkeletonApplication) that src/Dockerfile builds.
# The sample is the demo-friendly entrypoint: its application.yml self-provides
# defaults for every env placeholder, so the only external dependency it needs
# to boot is a reachable PostgreSQL (datasource + Flyway sample migrations).
#
# Build (no build-args required — this is a disposable demo, not a release artifact):
# docker build -f src/Dockerfile.sample src/ -t ca-sample:local
#
# Run (point APP_DATASOURCE_URL at a reachable Postgres; localhost default shown):
# docker run --rm -p 8080:8080 -p 9001:9001 \
# -e APP_DATASOURCE_URL=jdbc:postgresql://host.docker.internal:5432/ca_skeleton \
# ca-sample:local
#
# The multi-stage build, --parents descriptor glob, STRICT lock verification,
# non-root user, read-only-root-fs writable mounts, and JVM container ergonomics
# are all identical to src/Dockerfile — only the bootJar target differs. Keep the
# two files' builder stages in sync.
# =============================================================================
# Demo defaults so the image builds with zero build-args. Two format constraints from the root
# build.gradle configuration guard (feature-build-release-supply-chain-contract D1/D9):
# - RELEASE_VERSION must be bare MAJOR.MINOR.PATCH — no pre-release/build suffix (build.gradle L21).
# The "-sample" marker therefore lives only on BUILD_VERSION, which is a label, not a gradle prop.
# - GIT_SHA must be 7-40 hex chars (build.gradle L35); 0000000 is the placeholder.
ARG RELEASE_VERSION=0.0.0
ARG BUILD_VERSION=0.0.0-sample
ARG GIT_SHA=0000000
ARG SOURCE_URL=https://example.invalid/ca-tmpl-sample
# ---- Stage 1: builder -------------------------------------------------------
# Uses the full JDK only in the build stage, never in the final image.
FROM eclipse-temurin:21-jdk-jammy@sha256:801b7e1a9c4befaf82bf9a2a58025ef43a7694bbc84779187ad0524d84742772 AS builder
ARG RELEASE_VERSION
ARG GIT_SHA
WORKDIR /build/src
# Copy the Gradle wrapper and every module's build descriptor + dependency lockfile FIRST,
# so the expensive dependency-resolution layer is cached and only re-runs when a build.gradle
# or gradle.lockfile changes (D8). `--parents` preserves each file's directory structure, so a
# single structure-preserving glob replaces a per-module COPY list: new modules are picked up
# automatically and this stage never drifts out of sync with settings.gradle.
# (Requires the labs Dockerfile frontend — see the `# syntax` directive at the top of this file.)
COPY gradlew ./
COPY gradle/ gradle/
COPY config/ ./config/
COPY --parents settings.gradle build.gradle **/build.gradle **/gradle.lockfile ./
# Resolve every module configuration in STRICT mode (no --write-locks in a demo build either).
RUN test -n "${RELEASE_VERSION}" \
&& test -n "${GIT_SHA}" \
&& ./gradlew verifyDependencyLocks --no-daemon --quiet \
-PreleaseVersion="${RELEASE_VERSION}" -PgitRevision="${GIT_SHA}"
# Copy full source and stage the executable sample JAR at Gradle's declared Docker output path.
COPY . .
RUN ./gradlew :sample-portfolio:stageDockerJar --no-daemon -x test \
-PreleaseVersion="${RELEASE_VERSION}" -PgitRevision="${GIT_SHA}"
# ---- Stage 2: runtime image -------------------------------------------------
# JRE-only slim image (no full JDK in the demo image either).
FROM eclipse-temurin:21-jre-jammy@sha256:199aebeb3adcde4910695cdebfe782ada38dadb6cc8013159b58d3724451befd AS runtime
ARG BUILD_VERSION
ARG GIT_SHA
ARG SOURCE_URL
# OCI image labels. Unlike the release image (src/Dockerfile), the demo image does NOT hard-fail
# on missing metadata — the ARG defaults above keep it buildable with no build-args.
LABEL org.opencontainers.image.title="caskeleton-sample" \
org.opencontainers.image.description="ca-tmpl sample-portfolio reference/demo application" \
org.opencontainers.image.source="${SOURCE_URL}" \
org.opencontainers.image.revision="${GIT_SHA}" \
org.opencontainers.image.version="${BUILD_VERSION}"
# ---- Locale / timezone ------------------------------------------------------
ENV TZ=UTC \
LANG=C.UTF-8 \
LC_ALL=C.UTF-8
# ---- Writable HOME under read-only root fs ----------------------------------
ENV HOME=/tmp
# ---- JVM ergonomics ---------------------------------------------------------
# Identical to src/Dockerfile: container-aware heap, fail-fast on OOM, heap dump to a
# writable mount, and Tomcat temp redirected to /tmp for a read-only root filesystem.
ENV JAVA_TOOL_OPTIONS="\
-XX:MaxRAMPercentage=75 \
-XX:+UseContainerSupport \
-XX:+ExitOnOutOfMemoryError \
-XX:+HeapDumpOnOutOfMemoryError \
-XX:HeapDumpPath=/var/tmp/heap \
-Dserver.tomcat.basedir=/tmp"
# ---- Filesystem layout (read-only root filesystem) --------------------------
# At runtime /var/tmp/heap and /tmp MUST be writable mounts (tmpfs/emptyDir).
RUN mkdir -p /var/tmp/heap && chmod 1777 /var/tmp/heap
# ---- Non-root user ----------------------------------------------------------
RUN groupadd --system --gid 1000 app \
&& useradd --system --uid 1000 --gid app --no-create-home --shell /usr/sbin/nologin app
WORKDIR /app
COPY --from=builder --chown=app:app /build/src/sample-portfolio/build/docker/application.jar app.jar
USER app
# ---- Ports ------------------------------------------------------------------
# 8080 — application HTTP port
# 9001 — management / actuator port
EXPOSE 8080 9001
# ---- Health check -----------------------------------------------------------
# Actuator readiness probe on the management port (9001). Ignored by Kubernetes,
# which uses its own probes — kept for docker/compose parity with src/Dockerfile.
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \
CMD wget --no-verbose --tries=1 --spider \
http://localhost:9001/actuator/health/readiness || exit 1
# ---- Entrypoint -------------------------------------------------------------
# mainClass (SamplePortfolioApplication) is baked into the bootJar manifest.
ENTRYPOINT ["java", "-jar", "/app/app.jar"]
+5 -9
View File
@@ -17,7 +17,7 @@
| 게이트 | 하는 일 | | 게이트 | 하는 일 |
| --- | --- | | --- | --- |
| `verifyCleanArchitectureDependencies` | 모듈 간 의존 방향이 허용된 범위 안에 있는지 검사 | | `verifyCleanArchitectureDependencies` | 모듈 간 의존 방향이 허용된 범위 안에 있는지 검사 |
| `verifyRuntimeModuleMembership` | registry의 composition root membership과 실제 main project dependency가 정확히 일치하는지 검사 | | `verifyRuntimeModuleMembership` | registry의 production composition root membership과 실제 main project dependency가 정확히 일치하는지 검사 |
| `verifyEnvKeys` | `env-keys.yaml``application.yml``src/.env` 가 어긋나지 않는지 검사 | | `verifyEnvKeys` | `env-keys.yaml``application.yml``src/.env` 가 어긋나지 않는지 검사 |
| `verifyOneTypePerFile` | 파일당 public 최상위 타입 1개, 파일명 == 타입명인지 검사 | | `verifyOneTypePerFile` | 파일당 public 최상위 타입 1개, 파일명 == 타입명인지 검사 |
| `verifyTrivyignore` | `.trivyignore.yaml` 의 Trivy suppression 이 사유·만료일을 갖추고 만료/기한초과가 아닌지 검사 | | `verifyTrivyignore` | `.trivyignore.yaml` 의 Trivy suppression 이 사유·만료일을 갖추고 만료/기한초과가 아닌지 검사 |
@@ -26,7 +26,7 @@
### Local bootstrap ### Local bootstrap
`./gradlew bootstrap``bootstrapCompile``bootstrapDependencies` `./gradlew bootstrap``bootstrapCompile``bootstrapDependencies`
`bootstrapMigrateAndStart` `bootstrapSampleContract` `bootstrapSmoke`를 순서대로 실행합니다. `bootstrapMigrateAndStart``bootstrapSmoke`를 순서대로 실행합니다.
DB와 app lifecycle은 저장소 루트의 base/local Compose 조합이 소유하며, app startup Flyway가 DB와 app lifecycle은 저장소 루트의 base/local Compose 조합이 소유하며, app startup Flyway가
끝나 public health endpoint가 준비되어야 다음 단계로 넘어갑니다. `src/.env`는 env 설정의 끝나 public health endpoint가 준비되어야 다음 단계로 넘어갑니다. `src/.env`는 env 설정의
SSOT이고 bootstrap이 별도 env template을 만들지 않습니다. SSOT이고 bootstrap이 별도 env template을 만들지 않습니다.
@@ -88,10 +88,10 @@ vendor/build나 container base image까지 byte-for-byte 같음을 주장하지
### `verifyRuntimeModuleMembership` ### `verifyRuntimeModuleMembership`
- **하는 일.** 같은 registry의 `runtime_compositions`와 각 leaf의 `runtime_memberships`를 읽어 - **하는 일.** 같은 registry의 `runtime_compositions`와 각 leaf의 `runtime_memberships`를 읽어
`app-bootstrap`/`sample-portfolio`의 실제 `api`/`implementation`/`compileOnly`/`runtimeOnly` `app-bootstrap`의 실제 `api`/`implementation`/`compileOnly`/`runtimeOnly`
project dependency와 정확히 대조합니다. project dependency와 정확히 대조합니다.
- **opt-in의 의미.** membership이 빈 GraphQL/gRPC/WebSocket/Mongo leaf는 독립 빌드 대상이지만 - **opt-in의 의미.** membership이 빈 GraphQL/gRPC/WebSocket/Mongo leaf는 독립 빌드 대상이지만
shipped runtime에는 없습니다. app-bootstrap의 `conditionalTransportTest` test-only classpath는 production runtime에는 없습니다. app-bootstrap의 `conditionalTransportTest` test-only classpath는
실제 채택 전에 세 inbound transport를 함께 qualification하기 위한 evidence composition입니다. 실제 채택 전에 세 inbound transport를 함께 qualification하기 위한 evidence composition입니다.
- **변경 규칙.** production edge를 추가하거나 제거할 때 `allowed_dependencies`, - **변경 규칙.** production edge를 추가하거나 제거할 때 `allowed_dependencies`,
`runtime_memberships`, 실제 Gradle dependency를 같은 변경에서 갱신하지 않으면 `check`가 실패합니다. `runtime_memberships`, 실제 Gradle dependency를 같은 변경에서 갱신하지 않으면 `check`가 실패합니다.
@@ -282,10 +282,6 @@ ca-skeleton:
- 기존 `APP_OUTBOUND_HTTP_*``app.outbound.http.*`는 canonical 설정이 아닙니다. 루트 `src/.env`, - 기존 `APP_OUTBOUND_HTTP_*``app.outbound.http.*`는 canonical 설정이 아닙니다. 루트 `src/.env`,
`app-bootstrap`의 application YAML, env-key registry에서 제거됐으며 canonical composition에 `app-bootstrap`의 application YAML, env-key registry에서 제거됐으며 canonical composition에
입력하면 상태와 무관하게 기동을 거부합니다. 입력하면 상태와 무관하게 기동을 거부합니다.
- 다만 `sample-portfolio`의 application YAML에는 legacy facade를 시연하기 위해 15개 키가 남아
있습니다. 이 모듈은 fixture/reference consumer이고 production 의존성이 아니며, 그 YAML은
`verifyEnvKeys`가 검사하는 세 파일에 포함되지 않습니다. "제거됐다"는 문장이 저장소 전체를
가리킨다고 읽히지 않도록 범위를 명시합니다.
- legacy JDK facade가 필요한 fork만 canonical composition 밖에서 - legacy JDK facade가 필요한 fork만 canonical composition 밖에서
`OutboundHttpSettings.bindLegacy(Binder)`와 legacy configuration을 명시적으로 import합니다. `OutboundHttpSettings.bindLegacy(Binder)`와 legacy configuration을 명시적으로 import합니다.
timeout/retry/CB/response-size 설정은 그 migration API 내부 계약일 뿐 canonical provider timeout/retry/CB/response-size 설정은 그 migration API 내부 계약일 뿐 canonical provider
+1 -1
View File
@@ -20,7 +20,7 @@ Package root: `dev.caskeleton.adapter.inbound.graphql`.
자동 합성/바인딩하도록 얹는 얇은 계층이다. 자동 합성/바인딩하도록 얹는 얇은 계층이다.
- feature-agnostic: `classpath:graphql/**` 스키마와 모든 `@Controller` `@QueryMapping`/ - feature-agnostic: `classpath:graphql/**` 스키마와 모든 `@Controller` `@QueryMapping`/
`@MutationMapping` 을 generic 하게 합성한다. **WorkLog 등 구체 기능을 이름으로 알지 않는다.** `@MutationMapping` 을 generic 하게 합성한다. **WorkLog 등 구체 기능을 이름으로 알지 않는다.**
- classpath opt-in: 현재 `app-bootstrap`/`sample-portfolio` production runtime 은 이 leaf 를 - classpath opt-in: 현재 `app-bootstrap` production runtime 은 이 leaf 를
의존하지 않는다. 실제 채택 시 composition root 가 GraphQL leaf 와 인증/인가·CORS 정책, 의존하지 않는다. 실제 채택 시 composition root 가 GraphQL leaf 와 인증/인가·CORS 정책,
GraphiQL/introspection 운영 설정을 함께 명시해야 한다. GraphiQL/introspection 운영 설정을 함께 명시해야 한다.
+5 -6
View File
@@ -19,18 +19,17 @@ Spring for GraphQL 은 schema-first 다. 빈 스키마로는 부팅이 실패하
## 기능(feature)은 어떻게 기여하는가 — machinery/feature 분리 ## 기능(feature)은 어떻게 기여하는가 — machinery/feature 분리
스켈레톤은 **WorkLog 를 이름으로 알지 못한다.** 향후 composition root 가 이 모듈을 classpath 에 스켈레톤은 구체적인 제품 기능을 이름으로 알지 못한다. 향후 composition root 가 이 모듈을 classpath 에
명시적으로 채택하고 feature 를 추가하면 Spring for GraphQL 이 다음 두 축으로 합성할 수 있다: 명시적으로 채택하고 feature 를 추가하면 Spring for GraphQL 이 다음 두 축으로 합성할 수 있다:
- **스키마**: `classpath:graphql/**/*.graphqls` 를 전부 병합한다. sample 모듈의 - **스키마**: `classpath:graphql/**/*.graphqls` 를 전부 병합한다. 제품 모듈의 feature 스키마는
향후 `worklog.graphqls` 같은 feature 스키마는 스켈레톤의 `skeleton.graphqls` 와 합쳐진다. 스켈레톤의 `skeleton.graphqls` 와 합쳐진다.
- **resolver(핸들러)**: 컨텍스트의 모든 `@Controller``@QueryMapping`/`@MutationMapping` - **resolver(핸들러)**: 컨텍스트의 모든 `@Controller``@QueryMapping`/`@MutationMapping`
메서드를 바인딩한다. 향후 feature 의 GraphQL controller 는 스켈레톤을 수정하지 않고 등록할 수 메서드를 바인딩한다. 향후 feature 의 GraphQL controller 는 스켈레톤을 수정하지 않고 등록할 수
있다. 있다.
현재 `app-bootstrap``sample-portfolio` production runtime 은 이 leaf 를 의존하지 않는다. 현재 `app-bootstrap` production runtime 은 이 leaf 를 의존하지 않는다. 즉 이 모듈은
즉 이 모듈은 **classpath opt-in**며, 현재 sample 에 feature GraphQL 스키마/controller 가 있다 **classpath opt-in**다. leaf 자체는 최소 health 스키마로 독립 기동할 수 있다.
뜻이 아니다. leaf 자체는 최소 health 스키마로 독립 기동할 수 있다.
## 에러 매핑 — web `GlobalExceptionHandler` / gRPC 인터셉터의 GraphQL 형제 ## 에러 매핑 — web `GlobalExceptionHandler` / gRPC 인터셉터의 GraphQL 형제
+5 -8
View File
@@ -58,8 +58,8 @@ also drives the runtime patterns reference implementations must follow.
`Optional<T>` wrappers on request records. `Optional<T>` wrappers on request records.
- **B3 — Mapper-internal failures.** Map record canonical-constructor - **B3 — Mapper-internal failures.** Map record canonical-constructor
`IllegalArgumentException`, MapStruct generated NPE, ACL normalization `IllegalArgumentException`, MapStruct generated NPE, ACL normalization
failures, etc. by throwing `MappingException` (sample implementation in failures, etc. by throwing `MappingException`; the global handler routes it to
`sample-portfolio`); the global handler routes it to `MAPPING_FAILED` (HTTP 400), `MAPPING_FAILED` (HTTP 400),
never to `BAD_PARAMETER` or `INTERNAL_ERROR`. Plain `IllegalArgumentException` never to `BAD_PARAMETER` or `INTERNAL_ERROR`. Plain `IllegalArgumentException`
remains `BAD_PARAMETER` for non-mapper callers. remains `BAD_PARAMETER` for non-mapper callers.
- **B4 — Validation layering.** Class-level Bean Validation constraints belong - **B4 — Validation layering.** Class-level Bean Validation constraints belong
@@ -93,16 +93,13 @@ Domain `@RestControllerAdvice` in a consuming module must be annotated
`@Order(Ordered.HIGHEST_PRECEDENCE)` (or otherwise ordered ahead of this `@Order(Ordered.HIGHEST_PRECEDENCE)` (or otherwise ordered ahead of this
module's base `GlobalExceptionHandler`), because the base handler's catch-all module's base `GlobalExceptionHandler`), because the base handler's catch-all
`@ExceptionHandler(Exception.class)` would otherwise resolve domain exceptions `@ExceptionHandler(Exception.class)` would otherwise resolve domain exceptions
to `INTERNAL_ERROR`. See `sample-portfolio`'s `DomainExceptionHandler` for the to `INTERNAL_ERROR`.
pattern.
The base operational handler (`error/GlobalExceptionHandler`), the error-code The base operational handler (`error/GlobalExceptionHandler`), the error-code
contract (`dev.caskeleton.shared.error.ApiErrorCode` + `OperationalError`), the contract (`dev.caskeleton.shared.error.ApiErrorCode` + `OperationalError`), the
`error/ErrorResponseFactory`, and the `envelope/EnvelopeBodyAdvice` now live in `error/ErrorResponseFactory`, and the `envelope/EnvelopeBodyAdvice` now live in
production modules (`adapter:inbound:web` / `shared-contract`), so the running application production modules (`adapter:inbound:web` / `shared-contract`). Domain-specific exception
provides them without depending on `sample-portfolio`. Domain-specific exception handlers and error codes live in the consuming module.
handlers and error codes live in the consuming module (see sample's
`DomainExceptionHandler` / `PortfolioErrorCode`).
## Schema / serialization contract ## Schema / serialization contract
+1 -2
View File
@@ -225,8 +225,7 @@ commit-aware response wrapper 계약을 구현한다. 또한 이 모듈은 HTML
### EnvelopeBodyAdvice ### EnvelopeBodyAdvice
- 컨트롤러는 도메인/DTO 타입을 반환하고, 이 advice 가 와이어 형태를 항상 - 컨트롤러는 도메인/DTO 타입을 반환하고, 이 advice 가 와이어 형태를 항상
`{success, data | error, traceId}` 로 보장한다. `{success, data | error, traceId}` 로 보장한다.
- 위치: sample 모듈이 아니라 adapter-web. 실행 앱은 adapter-web 에 의존하지만 sample-portfolio 에는 의존하지 - 위치: adapter-web. 실행 앱이 이 모듈을 의존하므로 응답 래핑이 실제 runtime에 적용된다.
않으므로, 응답 래핑이 실제로 동작하려면 여기 있어야 한다.
### CacheControlFilter ### CacheControlFilter
- 스켈레톤 기본 HTTP 캐시 정책. - 스켈레톤 기본 HTTP 캐시 정책.
+381
View File
@@ -1,3 +1,56 @@
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'))
}
// 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 +69,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 +134,322 @@ 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')
tasks.register('prepareStudioCodegenSpec') {
description = '계약에서 discriminator union 배선을 파생시켜 생성기 입력을 만든다.'
def specSource = file("${rootDir}/config/openapi/studio-v1.yaml")
def specOut = studioCodegenSpecFile
def ignoreOut = studioCodegenIgnoreFile
def unionDir = studioUnionSrcDir
def modelPackage = studioModelPackage
inputs.file(specSource)
outputs.file(specOut)
outputs.file(ignoreOut)
outputs.dir(unionDir)
doLast {
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)
// (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)
// (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])
}
if (unions.isEmpty()) {
throw new GradleException('계약에서 discriminator union 을 하나도 찾지 못했다 — 파생 규칙이 깨졌다.')
}
// 파생 계약 쓰기
def dumperOptions = new org.yaml.snakeyaml.DumperOptions()
dumperOptions.defaultFlowStyle = org.yaml.snakeyaml.DumperOptions.FlowStyle.BLOCK
dumperOptions.width = 8192
def specFile = specOut.get().asFile
specFile.parentFile.mkdirs()
specFile.setText(new org.yaml.snakeyaml.Yaml(dumperOptions).dump(doc), 'UTF-8')
// union 클래스 생성 억제
def ignoreFile = ignoreOut.get().asFile
ignoreFile.setText(
(['# prepareStudioCodegenSpec 가 생성한다 — 손으로 고치지 않는다.',
'# 이 파일들은 같은 package 의 Java interface 로 대체된다.']
+ unions.keySet().collect { "**/${it}.java" }).join('\n') + '\n',
'UTF-8')
// union interface 쓰기
def packageDir = new File(unionDir.get().asFile, modelPackage.replace('.', '/'))
project.delete(unionDir.get().asFile)
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. prepareStudioCodegenSpec 가 계약의
* {@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(
"prepareStudioCodegenSpec: union ${unions.size()}개 파생(${unions.keySet().join(', ')}), " +
"string oneOf ${collapsed[0]}건 · nullable oneOf ${nullable[0]}건 접음")
}
}
// 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=
@@ -0,0 +1,52 @@
package dev.caskeleton.adapter.inbound.web.techlog;
import dev.caskeleton.application.techlog.error.StudioError;
/**
* Studio 실패의 client-safe {@code error.message} 단일 출처.
*
* <p>{@code StudioException#getMessage()}는 Studio use case/facade가 진단용으로 채우는 원문이라 {@code
* ApiErrorCarrier} javadoc이 경고하는 대로 SQLState나 upstream detail을 실을 수 있다. 그래서 응답에는 절대 흘리지 않고, 이 클래스가
* code별 고정 문구만 내보낸다 — 스켈레톤의 {@code ClientSafeErrorMessages}가 {@code OperationalError}에 대해 하는 것과 같은
* 역할을 {@link StudioError}에 대해 한다.
*
* <p>문구는 {@code docs/registries/error-codes.yaml}의 각 row {@code client_safe_message}와 정확히 같아야 한다 —
* {@code StudioErrorRegistryTest}가 그 일치를 고정한다. {@code PAYLOAD_TOO_LARGE}/{@code
* UNSUPPORTED_MEDIA_TYPE}은 Studio 전용 row가 없고 {@code feature-api-contract-baseline}이 이미 등록한 row를
* 재사용하므로(Task 5 report 5절) 그 row의 영문 문구를 그대로 따른다 — 나머지는 Studio 전용 한국어 문구다.
*
* <p>{@link StudioError}를 exhaustive switch로 매핑하므로(default 없음) 새 상수를 추가하면 이 파일도 컴파일 타임에 고쳐야 한다 — 문구
* 누락이 생길 수 없다.
*/
public final class StudioClientSafeMessages {
private StudioClientSafeMessages() {}
public static String forError(StudioError error) {
return switch (error) {
case AUTHENTICATION_REQUIRED -> "Studio 인증이 필요합니다";
case STUDIO_ACCESS_DENIED -> "이 Studio 리소스에 접근할 권한이 없습니다";
case DOCUMENT_NOT_FOUND -> "요청한 문서를 찾을 수 없습니다";
case VERSION_CONFLICT -> "저장된 version이 더 최신입니다";
case REQUEST_VALIDATION_FAILED -> "요청 형식이 올바르지 않습니다";
case DOCUMENT_VALIDATION_FAILED -> "문서 검증에 실패했습니다";
case VALIDATION_STALE -> "검증 결과가 최신 문서 기준이 아닙니다. 다시 검증해 주세요";
case PREVIEW_NOT_FOUND -> "요청한 미리보기를 찾을 수 없습니다";
case PREVIEW_STALE -> "미리보기가 최신 문서 기준이 아닙니다. 다시 생성해 주세요";
case PREVIEW_EXPIRED -> "미리보기가 만료되었습니다. 다시 생성해 주세요";
case PUBLICATION_NOT_FOUND -> "요청한 게시물을 찾을 수 없습니다";
case PUBLICATION_CONFLICT -> "게시 작업이 다른 변경과 충돌했습니다";
case PUBLICATION_EVENT_NOT_FOUND -> "요청한 게시 이벤트를 찾을 수 없습니다";
case PUBLICATION_SNAPSHOT_NOT_FOUND -> "요청한 게시 스냅샷을 찾을 수 없습니다";
case WARNING_ACKNOWLEDGEMENT_REQUIRED -> "경고 확인이 필요합니다. 확인 후 다시 시도해 주세요";
case IDEMPOTENCY_KEY_REUSED -> "Idempotency 키가 다른 요청에 재사용되었습니다";
case ASSET_NOT_FOUND -> "요청한 자산을 찾을 수 없습니다";
case ASSET_NOT_READY -> "자산 처리가 아직 완료되지 않았습니다";
case ASSET_IN_USE -> "자산이 사용 중이라 이 작업을 수행할 수 없습니다";
case ASSET_QUARANTINED -> "자산이 격리 처리되어 사용할 수 없습니다";
case PAYLOAD_TOO_LARGE -> "Request payload is too large";
case UNSUPPORTED_MEDIA_TYPE -> "Request Content-Type is not supported";
case STUDIO_UNAVAILABLE -> "Studio 서비스를 일시적으로 사용할 수 없습니다. 잠시 후 다시 시도해 주세요";
};
}
}
@@ -0,0 +1,92 @@
package dev.caskeleton.adapter.inbound.web.techlog;
import dev.caskeleton.adapter.inbound.web.error.ErrorResponseFactory;
import dev.caskeleton.application.techlog.error.StudioError;
import dev.caskeleton.application.techlog.error.StudioException;
import dev.caskeleton.shared.response.Envelope;
import java.util.List;
import java.util.Map;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MissingServletRequestParameterException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
/**
* Studio 실패를 스켈레톤 봉투로 옮긴다. 템플릿의 {@code GlobalExceptionHandler}를 수정하지 않기 위해 별도 advice로 둔다 — 그 파일은
* template sync 대상이다.
*
* <p>{@code error.message}에는 {@link StudioClientSafeMessages}가 주는 code별 고정 문구만 싣는다 — {@link
* StudioException#getMessage()}(진단용 원문)는 SQLState·upstream detail을 실을 수 있어 client-unsafe하다({@code
* ApiErrorCarrier} javadoc). 원문은 버리지 않고 서버 로그에만 남긴다 — {@code GlobalExceptionHandler}의 {@code
* handlePersistenceFailure}/{@code handleDependencyFailure}가 분류된 하위 계층 실패를 로깅하는 것과 같은 패턴이다.
*
* <p><b>{@code basePackages} 스코프 (final whole-branch review B4).</b> 이 advice는 {@code
* dev.caskeleton.adapter.inbound.web.techlog} 아래의 컨트롤러(현재 studio 컨트롤러 전부가 여기 산다, {@code
* studio.controller})에만 적용된다. {@link #handleMissingParameter}/{@link #handleTypeMismatch}는 Spring
* MVC 표준 바인딩 예외를 계약 코드로 옮기는데, 스코프 없이 전역으로 두면 fileserver·healthcheck 같은 studio 밖 컨트롤러의 같은 예외까지 가로채 그
* 기능들의 기존 오류 응답 모양(바로 이 advice가 없었을 때의 {@code GlobalExceptionHandler} 동작)을 바꿔버린다 — 이 브랜치가 건드릴 권한이
* 없는 기능이다. {@code StudioException} 처리는 애초에 studio 코드만 이 예외를 던지므로 스코프를 좁혀도 동작이 바뀌지 않는다.
*/
@Order(Ordered.HIGHEST_PRECEDENCE)
@RestControllerAdvice(basePackages = "dev.caskeleton.adapter.inbound.web.techlog")
public class StudioExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(StudioExceptionHandler.class);
@ExceptionHandler(StudioException.class)
public ResponseEntity<Envelope<Void>> handleStudio(StudioException ex) {
StudioError error = ex.studioError();
log.error(
"studio failure classified as {} (category={}, retryable={}): {}",
error.code(),
error.category(),
error.retryable(),
ex.getMessage(),
ex);
return ErrorResponseFactory.envelope(
error, StudioClientSafeMessages.forError(error), ex.details());
}
/**
* 필수 쿼리 파라미터 누락(예: {@code GET /api/v1/studio/catalog}의 {@code type}). {@code
* GlobalExceptionHandler}는 이 예외를 오버라이드하지 않으므로 부모 {@code ResponseEntityExceptionHandler}가 그대로 bare
* {@code ProblemDetail}(content-type {@code application/problem+json})을 만들고, {@code
* EnvelopeBodyAdvice}의 JSON 미디어타입 검사에 걸려 봉투를 못 씌운다 — ADR-006이 쓰지 않기로 한 RFC 7807이 그대로 나간다(final
* whole-branch review B4). studio 스코프에서 계약 코드 {@link StudioError#REQUEST_VALIDATION_FAILED}(422)로
* 옮긴다.
*/
@ExceptionHandler(MissingServletRequestParameterException.class)
public ResponseEntity<Envelope<Void>> handleMissingParameter(
MissingServletRequestParameterException ex) {
return requestValidationFailed(ex.getParameterName(), "Required parameter is missing");
}
/**
* 쿼리 파라미터 타입 불일치(예: {@code type=BOGUS}, {@code limit=abc}). {@code GlobalExceptionHandler}도 이 예외를
* 처리하지만 {@code OperationalError.BAD_PARAMETER}를 낸다 — Studio 계약 23종에 없는 코드다. studio 스코프에서 계약 코드로
* 옮긴다.
*/
@ExceptionHandler(MethodArgumentTypeMismatchException.class)
public ResponseEntity<Envelope<Void>> handleTypeMismatch(MethodArgumentTypeMismatchException ex) {
return requestValidationFailed(ex.getName(), "Parameter value is invalid");
}
/**
* {@code details}를 계약의 {@code ValidationErrorDetails}({@code fieldErrors: [{path, message}]}) 모양에
* 맞춰 싣는다 — 자유형 {@code Object}로 아무 모양이나 실으면 계약의 {@code oneOf} 제약을 위반한다.
*/
private static ResponseEntity<Envelope<Void>> requestValidationFailed(
String parameterName, String message) {
Map<String, Object> fieldError = Map.of("path", "/" + parameterName, "message", message);
Map<String, Object> details = Map.of("fieldErrors", List.of(fieldError));
return ErrorResponseFactory.envelope(
StudioError.REQUEST_VALIDATION_FAILED,
StudioClientSafeMessages.forError(StudioError.REQUEST_VALIDATION_FAILED),
details);
}
}
@@ -0,0 +1,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");
}
}
+1 -1
View File
@@ -66,7 +66,7 @@ The broadcaster sends only when exactly one projector matches. Zero or duplicate
projection failures, invalid event types, null fields, and over-limit maps are dropped. The wire projection failures, invalid event types, null fields, and over-limit maps are dropped. The wire
envelope contains only `type`, bounded string `fields`, and `occurredAt`. envelope contains only `type`, bounded string `fields`, and `occurredAt`.
No current `sample-portfolio` feature publishes a WebSocket event or contributes a projector. No current production feature publishes a WebSocket event or contributes a projector.
That is a future adoption example, not an existing runtime feature. That is a future adoption example, not an existing runtime feature.
## Evidence and limits ## Evidence and limits
+2 -2
View File
@@ -5,8 +5,8 @@ live in [CLAUDE.md](CLAUDE.md); this document records why the P1 boundary has th
## Opt-in instead of accidental exposure ## Opt-in instead of accidental exposure
The leaf is built and tested but is not part of `app-bootstrap` or `sample-portfolio` production The leaf is built and tested but is not part of `app-bootstrap` production runtime membership.
runtime membership. Even after a future composition adds it, `ca-skeleton.websocket.enabled=false` Even after a future composition adds it, `ca-skeleton.websocket.enabled=false`
keeps its configuration and broadcaster absent. Enabling requires explicit non-wildcard origins, keeps its configuration and broadcaster absent. Enabling requires explicit non-wildcard origins,
so merely adding the artifact cannot expose a wildcard STOMP broker. so merely adding the artifact cannot expose a wildcard STOMP broker.
+1 -1
View File
@@ -42,7 +42,7 @@ Package root: `dev.caskeleton.adapter.outbound.fileserver`. Driven (outbound) ad
## Forbidden ## Forbidden
- Inbound adapters, sibling outbound adapters, persistence, `app-bootstrap`, `sample-portfolio` - Inbound adapters, sibling outbound adapters, persistence, or `app-bootstrap`
(ArchUnit `OUTBOUND_ADAPTERS_*` family rules). (ArchUnit `OUTBOUND_ADAPTERS_*` family rules).
- Leaking filesystem, stream, framework, or provider types across `FilePublicationPort`. - Leaking filesystem, stream, framework, or provider types across `FilePublicationPort`.
- Advertising `FILE_AND_DIRECTORY_SYNC` as physical device/controller/replica/site power-loss - Advertising `FILE_AND_DIRECTORY_SYNC` as physical device/controller/replica/site power-loss
+1 -2
View File
@@ -37,8 +37,7 @@ build choices) live in [README.md](README.md).
- Persistence or web technology (JPA/Hibernate/Spring Data/Spring Web) — ArchUnit - Persistence or web technology (JPA/Hibernate/Spring Data/Spring Web) — ArchUnit
`identifier_adapter_does_not_depend_on_other_adapters_or_bootstrap`; `identifier_adapter_does_not_depend_on_other_adapters_or_bootstrap`;
`.claude/hooks/ca_import_gate.py` G4 가 쓰기 시점에 차단. `.claude/hooks/ca_import_gate.py` G4 가 쓰기 시점에 차단.
- inbound adapters, persistence adapters, other outbound leaves, `app-bootstrap`, - inbound adapters, persistence adapters, other outbound leaves, or `app-bootstrap`.
`sample-portfolio`.
- External IO (HTTP / messaging / cache / DB) — that belongs in `adapter-outbound`. - External IO (HTTP / messaging / cache / DB) — that belongs in `adapter-outbound`.
## Test ## Test
+1 -1
View File
@@ -34,7 +34,7 @@ readiness live in [README.md](README.md) and
## Forbidden ## Forbidden
- Inbound adapters, sibling outbound adapters, persistence, app-bootstrap, or sample-portfolio - Inbound adapters, sibling outbound adapters, persistence, or app-bootstrap
dependencies. dependencies.
- Provider keys, paths, locators, SDK types, Spring types, or control-record types leaking into - Provider keys, paths, locators, SDK types, Spring types, or control-record types leaking into
application-core. application-core.
@@ -68,10 +68,9 @@ Adapters that serve one optional capability carry that capability's switch, unli
module. The `fileserver` package is the current case: every `Jpa*` adapter there is annotated module. The `fileserver` package is the current case: every `Jpa*` adapter there is annotated
`@ConditionalOnProperty(prefix = "app.fileserver-platform", name = "enabled", havingValue = "true")`. `@ConditionalOnProperty(prefix = "app.fileserver-platform", name = "enabled", havingValue = "true")`.
Without the gate a composition root that merely includes this module builds those beans, and each of Without the gate a composition root that merely includes this module builds those beans, although
them needs collaborators only the Fileserver configuration supplies — which is how `sample-portfolio` their collaborators exist only when Fileserver configuration is selected. A store for a capability
came to fail on a `FileStateMachine` it has no use for. A store for a capability nobody enabled nobody enabled should not exist.
should not exist.
## Allowed ## Allowed
@@ -174,7 +173,7 @@ framework-neutral `shared.error.PersistenceFailureException` carrying one of the
Audit metadata (`created_at` / `updated_at` / `created_by` / `updated_by`, D3) is an Audit metadata (`created_at` / `updated_at` / `created_by` / `updated_by`, D3) is an
infrastructure concern that must never reach `domain-core` (D2). It lives only on the infrastructure concern that must never reach `domain-core` (D2). It lives only on the
`audit/AuditableEntity` `@MappedSuperclass`; a domain aggregate persistence entity opts in `audit/AuditableEntity` `@MappedSuperclass`; a domain aggregate persistence entity opts in
by extending it (D6 — e.g. the sample `WorkLogEntity`). The domain aggregate itself carries by extending it (D6). The domain aggregate itself carries
zero audit fields, enforced by ArchUnit `domain_is_pure` (no `jakarta.persistence..`) plus zero audit fields, enforced by ArchUnit `domain_is_pure` (no `jakarta.persistence..`) plus
`domain_entities_do_not_carry_audit_fields` (no `createdAt`/`updatedAt`/`createdBy`/`updatedBy` `domain_entities_do_not_carry_audit_fields` (no `createdAt`/`updatedAt`/`createdBy`/`updatedBy`
fields under `..domain..`). fields under `..domain..`).
@@ -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,23 @@ 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')
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(
@@ -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,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"));
}
}
@@ -0,0 +1,48 @@
package dev.caskeleton.adapter.outbound.persistence.techlog.query;
import dev.caskeleton.application.techlog.studio.model.DisplayTargetView;
import dev.caskeleton.application.techlog.studio.model.DocumentSummaryView;
import dev.caskeleton.application.techlog.studio.model.NextAction;
import dev.caskeleton.application.techlog.studio.model.PublicationStatusView;
import dev.caskeleton.application.techlog.studio.model.RecordKind;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.UUID;
/**
* {@code studio_document} 계약 {@code DocumentSummary}.
*
* <p>목록·대시보드·게시 이력이 모두 매퍼를 쓴다. 화면마다 따로 만들면 같은 문서가 화면마다 다른 {@code nextAction} 이나 {@code
* hasUnpublishedChanges} 보인다.
*/
public final class StudioDocumentRowMapper {
private StudioDocumentRowMapper() {}
public static DocumentSummaryView read(ResultSet rs) throws SQLException {
UUID projectId = rs.getObject("project_id", UUID.class);
String projectSlug = rs.getString("project_slug");
Long publishedVersion = (Long) rs.getObject("published_version");
long version = rs.getLong("version");
String publicationStatus = rs.getString("publication_status");
return new DocumentSummaryView(
rs.getObject("id", UUID.class),
rs.getString("title"),
RecordKind.valueOf(rs.getString("kind")),
projectId == null
? null
: new DisplayTargetView(
projectId,
rs.getString("project_name"),
projectSlug == null ? null : "/projects/" + projectSlug),
rs.getTimestamp("updated_at").toInstant(),
publicationStatus == null
? PublicationStatusView.NEVER_PUBLISHED
: PublicationStatusView.valueOf(publicationStatus),
publishedVersion,
// 계약: "게시 취소 상태에서도 과거 publishedVersion 과 비교한다."
publishedVersion != null && publishedVersion != version,
NextAction.valueOf(rs.getString("next_action")));
}
}
@@ -0,0 +1,130 @@
package dev.caskeleton.adapter.outbound.persistence.techlog.query;
/**
* Studio 문서 union projection의 SQL 정의. 목록·대시보드·단건 조회가 <b>같은</b> 정의를 쓴다.
*
* <p>{@code dependencyRevision} {@code nextAction} 저장하지 않고 계산하는 값이다(spec §6.3, §7.3). 계산식이 SQL
* 곳과 Java 곳에 따로 있으면 목록의 {@code nextAction} 상세의 {@code nextAction} 조용히 갈라진다 사용자에게는 "목록에서는
* 게시하라더니 열어 보니 검증하라"는 모순으로 보인다. 그래서 계산은 여기 SQL 한 벌만 둔다.
*/
public final class StudioDocumentSql {
/**
* 렌더 계약 버전. 렌더 결과의 의미가 바뀌면 올린다 올리는 순간 기존 validation/preview가 전부 stale이 되어 다시 검증·미리보기를 거치게 된다.
*/
public static final String RENDERER_CONTRACT_VERSION = "1";
private StudioDocumentSql() {}
/**
* 유형을 하나의 모양으로 모으고, 위에 의존 상태 해시와 최신 artifact를 붙인 CTE 묶음.
*
* <p>마지막 CTE {@code studio_document} 최종 결과이며 컬럼은 다음과 같다.
*
* <pre>{@code
* id kind title version updated_at topic_id project_id project_name project_slug
* dependency_revision
* validation_version validation_status validation_valid_until validation_revision
* preview_version preview_expires_at preview_revision
* publication_status published_version
* next_action
* }</pre>
*/
public static String documentProjectionCte() {
return """
WITH studio_source AS (
SELECT d.id,
d.document_type AS kind,
d.title,
d.version,
d.updated_at,
d.primary_topic_id AS topic_id,
(SELECT l.project_id FROM project_document_link l
WHERE l.document_id = d.id AND l.relation_type = 'PRIMARY') AS project_id
FROM document d
UNION ALL
SELECT q.id, 'QUESTION', q.question, q.version, q.updated_at, q.primary_topic_id,
(SELECT l.project_id FROM project_question_link l
WHERE l.question_id = q.id AND l.relation_type = 'PRIMARY')
FROM open_question q
UNION ALL
SELECT pd.id, 'PROJECT_DECISION', pd.title, pd.version, pd.updated_at,
pd.primary_topic_id, pd.project_id
FROM project_decision pd
),
studio_dependency AS (
SELECT s.id,
md5(concat_ws('|',
coalesce(t.id::text || ':' || t.version::text, '-'),
coalesce(p.id::text || ':' || p.version::text, '-'),
coalesce((
SELECT md5(string_agg(rel.sig, ',' ORDER BY rel.sig))
FROM (
SELECT r.target_id::text || ':'
|| coalesce(rd.version, rq.version, rp.version, 0)::text AS sig
FROM studio_relation r
LEFT JOIN document rd ON rd.id = r.target_id
LEFT JOIN open_question rq ON rq.id = r.target_id
LEFT JOIN project_decision rp ON rp.id = r.target_id
WHERE r.source_kind = s.kind AND r.source_id = s.id
) rel
), '-'),
'renderer:%s'
)) AS dependency_revision
FROM studio_source s
LEFT JOIN topic t ON t.id = s.topic_id
LEFT JOIN project p ON p.id = s.project_id
),
studio_latest_validation AS (
SELECT DISTINCT ON (v.source_id)
v.source_id, v.validation_id, v.validated_version, v.status,
v.valid_until, v.dependency_revision
FROM studio_validation v
ORDER BY v.source_id, v.validated_at DESC
),
studio_latest_preview AS (
SELECT DISTINCT ON (pv.source_id)
pv.source_id, pv.preview_id, pv.source_version, pv.expires_at,
pv.dependency_revision
FROM studio_preview pv
ORDER BY pv.source_id, pv.created_at DESC
),
studio_document AS (
SELECT s.id, s.kind, s.title, s.version, s.updated_at, s.topic_id, s.project_id,
pr.name AS project_name, pr.slug AS project_slug,
dep.dependency_revision,
val.validated_version AS validation_version,
val.status AS validation_status,
val.valid_until AS validation_valid_until,
val.dependency_revision AS validation_revision,
prev.source_version AS preview_version,
prev.expires_at AS preview_expires_at,
prev.dependency_revision AS preview_revision,
pub.status AS publication_status,
pub.published_version,
CASE
WHEN s.title IS NULL OR btrim(s.title) = '' THEN 'CONTINUE_EDITING'
WHEN val.validated_version IS NULL
OR val.validated_version <> s.version
OR val.dependency_revision IS DISTINCT FROM dep.dependency_revision
OR val.valid_until <= now() THEN 'VALIDATE'
WHEN val.status = 'INVALID' THEN 'FIX_VALIDATION'
WHEN prev.source_version IS NULL
OR prev.source_version <> s.version
OR prev.dependency_revision IS DISTINCT FROM dep.dependency_revision
OR prev.expires_at <= now() THEN 'CREATE_PREVIEW'
WHEN pub.status IS DISTINCT FROM 'PUBLISHED'
OR pub.published_version <> s.version THEN 'PUBLISH'
ELSE 'NONE'
END AS next_action
FROM studio_source s
JOIN studio_dependency dep ON dep.id = s.id
LEFT JOIN project pr ON pr.id = s.project_id
LEFT JOIN studio_latest_validation val ON val.source_id = s.id
LEFT JOIN studio_latest_preview prev ON prev.source_id = s.id
LEFT JOIN publication pub ON pub.source_id = s.id AND pub.source_kind = s.kind
)
"""
.formatted(RENDERER_CONTRACT_VERSION);
}
}
@@ -0,0 +1,244 @@
package dev.caskeleton.adapter.outbound.persistence.techlog.studio.asset;
import dev.caskeleton.application.techlog.studio.model.AssetDetailView;
import dev.caskeleton.application.techlog.studio.model.AssetKindView;
import dev.caskeleton.application.techlog.studio.model.AssetManagementStatusView;
import dev.caskeleton.application.techlog.studio.model.AssetPageView;
import dev.caskeleton.application.techlog.studio.model.AssetUsageView;
import dev.caskeleton.application.techlog.studio.model.AssetView;
import dev.caskeleton.application.techlog.studio.model.RecordKind;
import dev.caskeleton.application.techlog.studio.port.out.AssetRepositoryPort;
import dev.caskeleton.application.techlog.studio.query.ListAssetsQuery;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Timestamp;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Repository;
/**
* {@code asset} 메타데이터 접근.
*
* <p>{@code usageCount} {@code asset_reference} 에서 센다 Asset 행에 캐시해 두면 참조가 바뀔 때마다 곳을 맞춰야 하고,
* 어긋나면 "쓰이고 있는데 삭제 가능"으로 보인다.
*/
@Repository
public class JdbcAssetRepositoryAdapter implements AssetRepositoryPort {
private final JdbcClient jdbcClient;
public JdbcAssetRepositoryAdapter(JdbcClient jdbcClient) {
this.jdbcClient = jdbcClient;
}
@Override
public AssetPageView list(ListAssetsQuery query) {
StringBuilder sql = new StringBuilder(selectColumns() + " WHERE 1 = 1");
Map<String, Object> params = new HashMap<>();
if (query.kind() != null) {
sql.append(" AND a.asset_kind = :kind");
params.put("kind", query.kind().name());
}
if (query.managementStatus() != null) {
sql.append(" AND a.management_status = :status");
params.put("status", query.managementStatus().name());
}
if (query.query() != null && !query.query().isBlank()) {
sql.append(" AND (lower(a.asset_key) LIKE :pattern OR lower(a.original_name) LIKE :pattern)");
params.put("pattern", "%" + query.query().toLowerCase(Locale.ROOT) + "%");
}
if (query.beforeCreatedAt() != null && query.beforeId() != null) {
sql.append(" AND (a.created_at, a.id) < (:before, :beforeId)");
params.put("before", Timestamp.from(query.beforeCreatedAt()));
params.put("beforeId", query.beforeId());
}
sql.append(" ORDER BY a.created_at DESC, a.id DESC 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<AssetView> rows = spec.query(JdbcAssetRepositoryAdapter::mapAsset).list();
boolean hasMore = rows.size() > query.limit();
List<AssetView> page = hasMore ? rows.subList(0, query.limit()) : rows;
String nextCursor = hasMore ? page.getLast().createdAt() + "|" + page.getLast().id() : null;
return new AssetPageView(page, nextCursor);
}
@Override
public Optional<AssetView> find(UUID assetId) {
return jdbcClient
.sql(selectColumns() + " WHERE a.id = :id")
.param("id", assetId)
.query(JdbcAssetRepositoryAdapter::mapAsset)
.optional();
}
@Override
public Optional<AssetDetailView> findDetail(UUID assetId) {
return find(assetId)
.map(
asset -> new AssetDetailView(asset, usagesOf(assetId), hasPublicationHistory(assetId)));
}
@Override
public AssetView create(NewAsset asset, String principal) {
jdbcClient
.sql(
"INSERT INTO asset (id, asset_key, asset_kind, management_status, object_key,"
+ " original_name, display_name, content_type, size_bytes, width, height,"
+ " checksum_sha256, alt_text, decorative, version, created_by, updated_by)"
+ " VALUES (:id, :assetKey, :kind, :status, :objectKey, :originalName,"
+ " :originalName, :contentType, :size, :width, :height, :checksum, :altText,"
// 계약의 Asset.version minimum 1 이다. 컬럼 기본값 0 그대로 두면 생성 직후
// 응답이 계약을 위반하고, 클라이언트가 보내는 expectedVersion 맞출 없다.
+ " :decorative, 1, :principal, :principal)")
.param("id", asset.id())
.param("assetKey", asset.assetKey())
.param("kind", asset.kind().name())
.param("status", asset.managementStatus().name())
.param("objectKey", asset.objectKey())
.param("originalName", asset.originalFilename())
.param("contentType", asset.mediaType())
.param("size", asset.byteSize())
.param("width", asset.width())
.param("height", asset.height())
.param("checksum", asset.checksumSha256())
.param("altText", asset.altText())
.param("decorative", asset.decorative())
.param("principal", principal)
.update();
return find(asset.id()).orElseThrow();
}
@Override
public Optional<AssetView> update(
UUID assetId,
long expectedVersion,
AssetKindView kind,
String altText,
boolean altTextProvided,
Boolean decorative,
AssetManagementStatusView managementStatus,
String principal) {
int updated =
jdbcClient
.sql(
"UPDATE asset SET"
// 보내지 않은 필드는 그대로 둔다 PUT 이지만 계약의 UpdateAssetCommand
// expectedVersion 전부 optional 이라 부분 갱신 의미다.
+ " asset_kind = COALESCE(:kind, asset_kind),"
+ " alt_text = CASE WHEN :altTextProvided THEN :altText ELSE alt_text END,"
+ " decorative = COALESCE(:decorative, decorative),"
+ " management_status = COALESCE(:status, management_status),"
+ " version = version + 1, updated_at = now(), updated_by = :principal"
+ " WHERE id = :id AND version = :expectedVersion")
.param("kind", kind == null ? null : kind.name())
.param("altTextProvided", altTextProvided)
.param("altText", altText)
.param("decorative", decorative)
.param("status", managementStatus == null ? null : managementStatus.name())
.param("principal", principal)
.param("id", assetId)
.param("expectedVersion", expectedVersion)
.update();
return updated == 0 ? Optional.empty() : find(assetId);
}
@Override
public Optional<String> findObjectKey(UUID assetId) {
return jdbcClient
.sql("SELECT object_key FROM asset WHERE id = :id")
.param("id", assetId)
.query(String.class)
.optional();
}
@Override
public void delete(UUID assetId) {
jdbcClient.sql("DELETE FROM asset WHERE id = :id").param("id", assetId).update();
}
private static String selectColumns() {
return "SELECT a.id, a.asset_key, a.asset_kind, a.management_status, a.original_name,"
+ " a.content_type, a.size_bytes, a.width, a.height, a.alt_text, a.decorative,"
+ " a.version, a.created_at, a.updated_at, a.first_published_at,"
+ " (SELECT count(*) FROM asset_reference r WHERE r.asset_id = a.id) AS usage_count"
+ " FROM asset a";
}
private static AssetView mapAsset(ResultSet rs, int rowNum) throws SQLException {
return new AssetView(
rs.getObject("id", UUID.class),
rs.getString("asset_key"),
AssetKindView.valueOf(rs.getString("asset_kind")),
rs.getString("content_type"),
rs.getString("original_name"),
rs.getLong("size_bytes"),
(Integer) rs.getObject("width"),
(Integer) rs.getObject("height"),
rs.getString("alt_text"),
rs.getBoolean("decorative"),
AssetManagementStatusView.valueOf(rs.getString("management_status")),
// 본문에 저장소 경로를 싣지 않는다(설계 05장 §3.1). 안정적인 전송 경로만 노출한다.
"/media/" + rs.getString("id"),
rs.getInt("usage_count"),
rs.getLong("version"),
rs.getTimestamp("created_at").toInstant(),
rs.getTimestamp("updated_at").toInstant());
}
private List<AssetUsageView> usagesOf(UUID assetId) {
return jdbcClient
.sql(
"SELECT r.owner_id, r.owner_type, r.reference_scope,"
+ " COALESCE(d.title, q.question, pd.title) AS title,"
+ " COALESCE(d.document_type, 'QUESTION') AS document_kind"
+ " FROM asset_reference r"
+ " LEFT JOIN document d ON d.id = r.owner_id"
+ " LEFT JOIN open_question q ON q.id = r.owner_id"
+ " LEFT JOIN project_decision pd ON pd.id = r.owner_id"
+ " WHERE r.asset_id = :id")
.param("id", assetId)
.query(
(rs, rowNum) ->
new AssetUsageView(
rs.getObject("owner_id", UUID.class),
kindOf(rs.getString("owner_type"), rs.getString("document_kind")),
rs.getString("title") == null ? "(제목 없음)" : rs.getString("title"),
"PUBLISHED".equals(rs.getString("reference_scope"))))
.list();
}
/** {@code asset_reference.owner_type} 은 {@code RecordKind} 와 이름이 다르다(V7). */
private static RecordKind kindOf(String ownerType, String documentKind) {
return switch (ownerType) {
case "DOCUMENT" -> RecordKind.valueOf(documentKind);
case "DECISION" -> RecordKind.PROJECT_DECISION;
default -> RecordKind.QUESTION;
};
}
/** 한 번이라도 공개된 적이 있으면 hard delete 를 금지한다(계약 {@code AssetDetail} 설명). */
private boolean hasPublicationHistory(UUID assetId) {
return Boolean.TRUE.equals(
jdbcClient
.sql(
"SELECT (first_published_at IS NOT NULL"
+ " OR EXISTS (SELECT 1 FROM asset_reference r"
+ " WHERE r.asset_id = :id AND r.reference_scope = 'PUBLISHED'))"
+ " FROM asset WHERE id = :id")
.param("id", assetId)
.query(Boolean.class)
.optional()
.orElse(Boolean.FALSE));
}
}
@@ -0,0 +1,192 @@
package dev.caskeleton.adapter.outbound.persistence.techlog.studio.publication;
import dev.caskeleton.adapter.outbound.persistence.techlog.query.StudioDocumentRowMapper;
import dev.caskeleton.adapter.outbound.persistence.techlog.query.StudioDocumentSql;
import dev.caskeleton.application.techlog.studio.model.DocumentSummaryView;
import dev.caskeleton.application.techlog.studio.model.PublicationActionView;
import dev.caskeleton.application.techlog.studio.model.PublicationAggregateStatus;
import dev.caskeleton.application.techlog.studio.model.PublicationAggregateView;
import dev.caskeleton.application.techlog.studio.model.PublicationEventTypeView;
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.port.out.PublicationHistoryQueryPort;
import dev.caskeleton.application.techlog.studio.query.ListPublicationsQuery;
import java.sql.Timestamp;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Repository;
/**
* 게시 이력 조회. 이력은 항상 최신순이며 정렬 선택지가 없다 계약에도 정렬 파라미터가 없다.
*
* <p>목록의 {@code document} 요약은 {@link StudioDocumentSql} 같은 정의에서 가져온다. 여기서 따로 만들면 목록 화면과 이력 화면의
* {@code nextAction} 갈라진다.
*/
@Repository
public class JdbcPublicationHistoryQueryAdapter implements PublicationHistoryQueryPort {
private final JdbcClient jdbcClient;
public JdbcPublicationHistoryQueryAdapter(JdbcClient jdbcClient) {
this.jdbcClient = jdbcClient;
}
@Override
public PublicationPageView list(ListPublicationsQuery query) {
StringBuilder sql =
new StringBuilder(
"SELECT e.publication_event_id, e.publication_id, e.source_id, e.event_type,"
+ " e.occurred_at, e.published_version, e.source_published_event_id,"
+ " (s.publication_event_id IS NOT NULL) AS snapshot_available,"
+ " p.status, p.publication_revision, p.latest_event_id, p.public_path,"
+ " p.updated_at AS publication_updated_at, p.published_version AS current_version"
+ " FROM publication_event e"
+ " JOIN publication p ON p.publication_id = e.publication_id"
+ " LEFT JOIN publication_snapshot s"
+ " ON s.publication_event_id = e.publication_event_id"
+ " WHERE 1 = 1");
Map<String, Object> params = new HashMap<>();
if (query.type() != null) {
sql.append(" AND e.event_type = :type");
params.put("type", query.type().name());
}
if (query.beforeOccurredAt() != null && query.beforeEventId() != null) {
sql.append(" AND (e.occurred_at, e.publication_event_id) < (:before, :beforeId)");
params.put("before", Timestamp.from(query.beforeOccurredAt()));
params.put("beforeId", query.beforeEventId());
}
sql.append(" ORDER BY e.occurred_at DESC, e.publication_event_id DESC 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;
Map<UUID, DocumentSummaryView> summaries = summariesFor(page);
List<PublicationListItemView> items = new ArrayList<>(page.size());
for (Row row : page) {
items.add(
new PublicationListItemView(
row.event(),
row.publication(),
summaries.get(row.event().documentId()),
actionsFor(row)));
}
String nextCursor =
hasMore
? page.getLast().event().occurredAt()
+ "|"
+ page.getLast().event().publicationEventId()
: null;
return new PublicationPageView(items, nextCursor);
}
@Override
public Optional<PublicationSnapshotView> findSnapshot(UUID publicationEventId) {
return jdbcClient
.sql(
"SELECT e.publication_event_id, e.publication_id, e.source_id, e.event_type,"
+ " e.occurred_at, e.published_version, e.source_published_event_id,"
+ " true AS snapshot_available,"
+ " s.render_model, s.content_format_version, s.renderer_contract_version"
+ " FROM publication_snapshot s"
+ " JOIN publication_event e"
+ " ON e.publication_event_id = s.publication_event_id"
+ " WHERE s.publication_event_id = :id")
.param("id", publicationEventId)
.query(
(rs, rowNum) ->
new PublicationSnapshotView(
PublicationRowMappers.mapEvent(rs, rowNum),
rs.getString("render_model"),
rs.getString("content_format_version"),
rs.getString("renderer_contract_version")))
.optional();
}
@Override
public Optional<PublicationAggregateView> findById(UUID publicationId) {
return jdbcClient
.sql(
"SELECT publication_id, source_id, status, published_version, publication_revision,"
+ " latest_event_id, public_path, updated_at FROM publication"
+ " WHERE publication_id = :id")
.param("id", publicationId)
.query(JdbcPublicationWriterAdapter::mapAggregate)
.optional();
}
private Map<UUID, DocumentSummaryView> summariesFor(List<Row> rows) {
if (rows.isEmpty()) {
return Map.of();
}
List<UUID> ids = rows.stream().map(row -> row.event().documentId()).distinct().toList();
Map<UUID, DocumentSummaryView> summaries = new HashMap<>();
jdbcClient
.sql(
StudioDocumentSql.documentProjectionCte()
+ " SELECT * FROM studio_document WHERE id IN (:ids)")
.param("ids", ids)
.query((rs, rowNum) -> StudioDocumentRowMapper.read(rs))
.list()
.forEach(summary -> summaries.put(summary.id(), summary));
return summaries;
}
/**
* 계약 {@code PublicationListItem.availableActions}. 지금 상태에서 실제로 있는 것만 담는다 화면이 눌러도 실패할 버튼을
* 그리지 않게 하려는 값이다.
*/
private static List<PublicationActionView> actionsFor(Row row) {
List<PublicationActionView> actions = new ArrayList<>();
if (row.event().snapshotAvailable()) {
actions.add(PublicationActionView.VIEW_SNAPSHOT);
}
if (row.event().sourcePublishedEventId() != null) {
actions.add(PublicationActionView.VIEW_SOURCE_SNAPSHOT);
}
if (row.publication().status() == PublicationAggregateStatus.PUBLISHED
&& row.publication().latestEventId().equals(row.event().publicationEventId())) {
actions.add(PublicationActionView.UNPUBLISH);
}
return actions;
}
private record Row(PublicationEventView event, PublicationAggregateView publication) {}
private static Row readRow(java.sql.ResultSet rs) throws java.sql.SQLException {
PublicationEventView event =
new PublicationEventView(
rs.getObject("publication_event_id", UUID.class),
rs.getObject("publication_id", UUID.class),
rs.getObject("source_id", UUID.class),
PublicationEventTypeView.valueOf(rs.getString("event_type")),
rs.getTimestamp("occurred_at").toInstant(),
rs.getLong("published_version"),
rs.getObject("source_published_event_id", UUID.class),
rs.getBoolean("snapshot_available"));
PublicationAggregateView publication =
new PublicationAggregateView(
rs.getObject("publication_id", UUID.class),
rs.getObject("source_id", UUID.class),
PublicationAggregateStatus.valueOf(rs.getString("status")),
rs.getLong("current_version"),
rs.getLong("publication_revision"),
rs.getObject("latest_event_id", UUID.class),
rs.getString("public_path"),
rs.getTimestamp("publication_updated_at").toInstant());
return new Row(event, publication);
}
}
@@ -0,0 +1,514 @@
package dev.caskeleton.adapter.outbound.persistence.techlog.studio.publication;
import dev.caskeleton.application.techlog.error.StudioError;
import dev.caskeleton.application.techlog.error.StudioException;
import dev.caskeleton.application.techlog.studio.model.AssetManifestEntry;
import dev.caskeleton.application.techlog.studio.model.PublicationAggregateStatus;
import dev.caskeleton.application.techlog.studio.model.PublicationAggregateView;
import dev.caskeleton.application.techlog.studio.model.PublicationEventTypeView;
import dev.caskeleton.application.techlog.studio.model.PublicationEventView;
import dev.caskeleton.application.techlog.studio.model.PublishResultView;
import dev.caskeleton.application.techlog.studio.model.RecordKind;
import dev.caskeleton.application.techlog.studio.port.out.PublicationWriterPort;
import dev.caskeleton.shared.error.MappingException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import java.util.function.Supplier;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import tools.jackson.core.JacksonException;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.node.ArrayNode;
import tools.jackson.databind.node.ObjectNode;
/**
* spec §7.5 게시 트랜잭션 10~19단계.
*
* <p>{@code publication.latest_event_id} {@code publication_event.publication_id} 서로를 가리킨다.
* 게시는 publication INSERT event INSERT publication UPDATE 순서로 트랜잭션 안에서 끝나며, 순환을 허용하는 것이 V7
* {@code DEFERRABLE INITIALLY DEFERRED} . 즉시 검사로 바꾸면 게시가 구조적으로 불가능해진다.
*/
@Repository
public class JdbcPublicationWriterAdapter implements PublicationWriterPort {
/** 공개 projection payload 의 스키마 버전. 모양이 바뀌면 올린다. */
private static final short PAYLOAD_SCHEMA_VERSION = 1;
private final JdbcClient jdbcClient;
private final ObjectMapper objectMapper;
private final Supplier<UUID> idGenerator;
/**
* 생성자가 둘이라 Spring 어느 쪽을 쓸지 스스로 정하지 못한다 표시가 없으면 기본 생성자를 찾다 실패해 컨텍스트가 뜨지 않는다(실제로 부팅 검증에서 그렇게
* 실패했다). 번째 생성자는 테스트가 id 생성기를 주입하기 위한 것이며 프로덕션 배선은 항상 이쪽이다.
*/
@Autowired
public JdbcPublicationWriterAdapter(JdbcClient jdbcClient, ObjectMapper objectMapper) {
this(jdbcClient, objectMapper, UUID::randomUUID);
}
JdbcPublicationWriterAdapter(
JdbcClient jdbcClient, ObjectMapper objectMapper, Supplier<UUID> idGenerator) {
this.jdbcClient = jdbcClient;
this.objectMapper = objectMapper;
this.idGenerator = idGenerator;
}
@Override
public Optional<PublicationAggregateView> lockCurrentPublication(
RecordKind kind, 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_kind = :kind AND source_id = :id FOR UPDATE")
.param("kind", kind.name())
.param("id", documentId)
.query(JdbcPublicationWriterAdapter::mapAggregate)
.optional();
}
@Override
public PublishResultView publish(PublishRequest request) {
requireTransaction("publish");
Optional<PublicationAggregateView> existing =
lockCurrentPublication(request.kind(), request.documentId());
UUID publicationId =
existing.map(PublicationAggregateView::publicationId).orElseGet(idGenerator);
UUID eventId = idGenerator.get();
PublicationEventTypeView eventType =
existing.isEmpty()
? PublicationEventTypeView.PUBLISHED
: PublicationEventTypeView.REPUBLISHED;
if (existing.isEmpty()) {
// 10 이전: aggregate 먼저 만든다. latest_event_id 아직 없는 event 가리키지만
// 지연 검사라 커밋 시점에만 확인된다.
jdbcClient
.sql(
"INSERT INTO publication (publication_id, source_kind, source_id, status,"
+ " published_version, publication_revision, latest_event_id, public_path)"
+ " VALUES (:publicationId, :kind, :id, 'PUBLISHED', :version, 1, :eventId,"
+ " :publicPath)")
.param("publicationId", publicationId)
.param("kind", request.kind().name())
.param("id", request.documentId())
.param("version", request.version())
.param("eventId", eventId)
.param("publicPath", request.publicPath())
.update();
}
// 10. Event
jdbcClient
.sql(
"INSERT INTO publication_event (publication_event_id, publication_id, source_kind,"
+ " source_id, event_type, published_version, occurred_at, idempotency_key,"
+ " created_by)"
+ " VALUES (:eventId, :publicationId, :kind, :id, :type, :version, now(),"
+ " :idempotencyKey, :principal)")
.param("eventId", eventId)
.param("publicationId", publicationId)
.param("kind", request.kind().name())
.param("id", request.documentId())
.param("type", eventType.name())
.param("version", request.version())
.param("idempotencyKey", request.idempotencyKey())
.param("principal", request.principal())
.update();
// 11. Snapshot 게시 시점의 렌더 모델을 그대로 고정한다. 다시 렌더링하지 않는다.
jdbcClient
.sql(
"INSERT INTO publication_snapshot (publication_event_id, render_model,"
+ " content_format_version, renderer_contract_version, asset_manifest)"
+ " VALUES (:eventId, CAST(:renderModel AS jsonb), :contentFormatVersion,"
+ " :rendererContractVersion, CAST(:assetManifest AS jsonb))")
.param("eventId", eventId)
.param("renderModel", request.renderModelJson())
.param("contentFormatVersion", request.contentFormatVersion())
.param("rendererContractVersion", request.rendererContractVersion())
.param("assetManifest", manifestJson(request.assetManifest()))
.update();
upsertProjection(request);
replaceRoute(request);
replaceProjectLink(request);
replacePublishedAssetReferences(request);
markAssetsFirstPublished(request);
// 17. aggregate 갱신
jdbcClient
.sql(
"UPDATE publication SET status = 'PUBLISHED', published_version = :version,"
+ " publication_revision = publication_revision + :bump,"
+ " latest_event_id = :eventId, public_path = :publicPath, updated_at = now()"
+ " WHERE publication_id = :publicationId")
.param("version", request.version())
// 게시는 INSERT 이미 revision 1 넣었다. 여기서 올리면 클라이언트가 받은 값과
// 다음 unpublish 요구하는 값이 어긋난다.
.param("bump", existing.isEmpty() ? 0 : 1)
.param("eventId", eventId)
.param("publicPath", request.publicPath())
.param("publicationId", publicationId)
.update();
// 18. Document publish metadata
markSourcePublished(request);
return result(publicationId, eventId);
}
@Override
public PublishResultView unpublish(UnpublishRequest request) {
requireTransaction("unpublish");
UUID lastPublishedEventId =
jdbcClient
.sql(
"SELECT publication_event_id FROM publication_event"
+ " WHERE publication_id = :publicationId"
+ " AND event_type IN ('PUBLISHED', 'REPUBLISHED')"
+ " ORDER BY occurred_at DESC LIMIT 1")
.param("publicationId", request.publicationId())
.query(UUID.class)
.optional()
.orElseThrow(
() ->
StudioException.of(
StudioError.PUBLICATION_CONFLICT,
"this publication has no published event to withdraw"));
UUID eventId = idGenerator.get();
// UNPUBLISHED Event 자체 snapshot 만들지 않고 마지막 공개 Snapshot 참조한다(V7 주석).
jdbcClient
.sql(
"INSERT INTO publication_event (publication_event_id, publication_id, source_kind,"
+ " source_id, event_type, published_version, source_published_event_id,"
+ " occurred_at, created_by)"
+ " SELECT :eventId, p.publication_id, p.source_kind, p.source_id, 'UNPUBLISHED',"
+ " p.published_version, :sourceEventId, now(), :principal"
+ " FROM publication p WHERE p.publication_id = :publicationId")
.param("eventId", eventId)
.param("sourceEventId", lastPublishedEventId)
.param("principal", request.principal())
.param("publicationId", request.publicationId())
.update();
int updated =
jdbcClient
.sql(
"UPDATE publication SET status = 'UNPUBLISHED',"
+ " publication_revision = publication_revision + 1,"
+ " latest_event_id = :eventId, updated_at = now()"
+ " WHERE publication_id = :publicationId"
+ " AND publication_revision = :expectedRevision")
.param("eventId", eventId)
.param("publicationId", request.publicationId())
.param("expectedRevision", request.expectedRevision())
.update();
if (updated == 0) {
throw StudioException.of(
StudioError.PUBLICATION_CONFLICT,
"the publication revision changed while withdrawing it");
}
// Projection ACTIVE -> WITHDRAWN. route 유지한다 주소가 사라지면 링크가 끊긴다.
jdbcClient
.sql(
"UPDATE public_resource_projection SET publication_state = 'WITHDRAWN',"
+ " updated_at = now() WHERE resource_type = :type AND resource_id = :id")
.param("type", request.kind().name())
.param("id", request.documentId())
.update();
// Working copy 다시 초안으로 돌아간다.
if (request.kind() == RecordKind.CASE || request.kind() == RecordKind.REFERENCE) {
jdbcClient
.sql("UPDATE document SET workflow_status = 'DRAFT' WHERE id = :id")
.param("id", request.documentId())
.update();
}
return result(request.publicationId(), eventId);
}
/** 12. 공개 projection upsert. */
private void upsertProjection(PublishRequest request) {
jdbcClient
.sql(
"INSERT INTO public_resource_projection (resource_type, resource_id, source_version,"
+ " publication_state, visibility, title, summary, state_code, primary_topic_id,"
+ " payload_schema_version, payload, body_plain_text, search_text, content_hash,"
+ " published_at, updated_at, navigation_path)"
+ " VALUES (:type, :id, :version, 'ACTIVE', 'PUBLIC', :title, :summary,"
+ " :stateCode, :topicId, :schemaVersion, CAST(:payload AS jsonb), :bodyPlainText,"
+ " :searchText, :contentHash, now(), now(), :navigationPath)"
+ " ON CONFLICT (resource_type, resource_id) DO UPDATE SET"
+ " source_version = EXCLUDED.source_version,"
+ " publication_state = 'ACTIVE',"
+ " visibility = EXCLUDED.visibility,"
+ " title = EXCLUDED.title,"
+ " summary = EXCLUDED.summary,"
+ " state_code = EXCLUDED.state_code,"
+ " primary_topic_id = EXCLUDED.primary_topic_id,"
+ " payload = EXCLUDED.payload,"
+ " body_plain_text = EXCLUDED.body_plain_text,"
+ " search_text = EXCLUDED.search_text,"
+ " content_hash = EXCLUDED.content_hash,"
+ " updated_at = now(),"
+ " navigation_path = EXCLUDED.navigation_path")
.param("type", request.kind().name())
.param("id", request.documentId())
.param("version", request.version())
.param("title", request.title())
.param("summary", request.summary())
.param("stateCode", request.stateCode())
.param("topicId", request.topicId())
.param("schemaVersion", PAYLOAD_SCHEMA_VERSION)
.param("payload", request.renderModelJson())
.param("bodyPlainText", request.bodyPlainText())
.param(
"searchText",
String.join(
" ",
nullToEmpty(request.title()),
nullToEmpty(request.summary()),
nullToEmpty(request.bodyPlainText())))
.param("contentHash", sha256(request.renderModelJson()))
.param("navigationPath", request.publicPath())
.update();
}
/** 13. canonical route. 이전 slug 의 route 는 alias 로 남긴다 — 지우면 공개된 링크가 끊긴다. */
private void replaceRoute(PublishRequest request) {
jdbcClient
.sql(
"UPDATE public_route SET route_role = 'ALIAS'"
+ " WHERE resource_type = :type AND resource_id = :id AND slug <> :slug")
.param("type", request.kind().name())
.param("id", request.documentId())
.param("slug", slugOf(request.publicPath()))
.update();
jdbcClient
.sql(
"INSERT INTO public_route (resource_type, slug, resource_id, route_role)"
+ " VALUES (:type, :slug, :id, 'CANONICAL')"
+ " ON CONFLICT (resource_type, slug) DO UPDATE SET"
+ " resource_id = EXCLUDED.resource_id, route_role = 'CANONICAL'")
.param("type", request.kind().name())
.param("slug", slugOf(request.publicPath()))
.param("id", request.documentId())
.update();
}
/** 14. 공개 projection 의 프로젝트 링크. Studio 는 PRIMARY 하나만 소유한다. */
private void replaceProjectLink(PublishRequest request) {
jdbcClient
.sql(
"DELETE FROM public_resource_project_link"
+ " WHERE resource_type = :type AND resource_id = :id AND relation_type = 'PRIMARY'")
.param("type", request.kind().name())
.param("id", request.documentId())
.update();
if (request.projectId() == null) {
return;
}
jdbcClient
.sql(
"INSERT INTO public_resource_project_link (resource_type, resource_id, project_id,"
+ " relation_type) VALUES (:type, :id, :projectId, 'PRIMARY')"
+ " ON CONFLICT (resource_type, resource_id, project_id)"
+ " DO UPDATE SET relation_type = 'PRIMARY'")
.param("type", request.kind().name())
.param("id", request.documentId())
.param("projectId", request.projectId())
.update();
}
/** 15. PUBLISHED scope 의 asset_reference 교체. WORKING scope 는 건드리지 않는다. */
private void replacePublishedAssetReferences(PublishRequest request) {
String ownerType = ownerTypeOf(request.kind());
jdbcClient
.sql(
"DELETE FROM asset_reference WHERE owner_type = :ownerType AND owner_id = :id"
+ " AND reference_scope = 'PUBLISHED'")
.param("ownerType", ownerType)
.param("id", request.documentId())
.update();
for (AssetManifestEntry entry : request.assetManifest()) {
jdbcClient
.sql(
"INSERT INTO asset_reference (asset_id, owner_type, owner_id, reference_scope,"
+ " reference_role) VALUES (:assetId, :ownerType, :id, 'PUBLISHED', 'BODY')"
+ " ON CONFLICT DO NOTHING")
.param("assetId", entry.assetId())
.param("ownerType", ownerType)
.param("id", request.documentId())
.update();
}
}
/** 16. 최초 공개 시각. 이미 값이 있으면 덮지 않는다 — "처음"은 한 번뿐이다. */
private void markAssetsFirstPublished(PublishRequest request) {
for (AssetManifestEntry entry : request.assetManifest()) {
jdbcClient
.sql(
"UPDATE asset SET first_published_at = now()"
+ " WHERE id = :assetId AND first_published_at IS NULL")
.param("assetId", entry.assetId())
.update();
}
}
/** 18. source 쪽 게시 메타데이터. */
private void markSourcePublished(PublishRequest request) {
// switch 문이 아니라 식이다 열거 전부를 다루면 default 필요 없고, 유형이 늘면 컴파일러가
// 여기서 막아 준다(문이면 조용히 아무것도 하고 지나간다).
int updated =
switch (request.kind()) {
case CASE, REFERENCE ->
jdbcClient
.sql(
"UPDATE document SET workflow_status = 'PUBLISHED',"
+ " first_published_at = COALESCE(first_published_at, now()),"
+ " last_published_at = now() WHERE id = :id")
.param("id", request.documentId())
.update();
case QUESTION ->
jdbcClient
.sql(
"UPDATE open_question SET"
+ " first_published_at = COALESCE(first_published_at, now()),"
+ " last_published_at = now() WHERE id = :id")
.param("id", request.documentId())
.update();
// project_decision 에는 게시 시각 컬럼이 없다. 게시 사실은 publication 소유하므로
// 여기서 억지로 컬럼을 만들지 않는다.
case PROJECT_DECISION -> 0;
};
if (updated == 0 && request.kind() != RecordKind.PROJECT_DECISION) {
throw StudioException.of(
StudioError.DOCUMENT_NOT_FOUND,
"the source record disappeared while publishing " + request.documentId());
}
}
private PublishResultView result(UUID publicationId, UUID eventId) {
PublicationAggregateView aggregate =
jdbcClient
.sql(
"SELECT publication_id, source_id, status, published_version,"
+ " publication_revision, latest_event_id, public_path, updated_at"
+ " FROM publication WHERE publication_id = :id")
.param("id", publicationId)
.query(JdbcPublicationWriterAdapter::mapAggregate)
.single();
PublicationEventView event =
jdbcClient
.sql(
"SELECT e.publication_event_id, e.publication_id, e.source_id, e.event_type,"
+ " e.occurred_at, e.published_version, e.source_published_event_id,"
+ " (s.publication_event_id IS NOT NULL) AS snapshot_available"
+ " FROM publication_event e"
+ " LEFT JOIN publication_snapshot s"
+ " ON s.publication_event_id = e.publication_event_id"
+ " WHERE e.publication_event_id = :id")
.param("id", eventId)
.query(PublicationRowMappers::mapEvent)
.single();
return new PublishResultView(aggregate, event);
}
/**
* 어댑터는 <b>열린 트랜잭션 안에서만</b> 올바르게 동작한다.
*
* <p>{@code publication.latest_event_id} {@code publication_event.publication_id} 서로를 가리키고,
* 순환은 {@code fk_publication_latest_event} {@code DEFERRABLE INITIALLY DEFERRED} 로만 성립한다. 지연 검사는
* <b>트랜잭션 </b> 일어나므로, autocommit 이면 구문이 트랜잭션이라 INSERT 에서 바로 위반이 된다.
*
* <p> 사실을 주석으로만 남기면 트랜잭션 없이 호출한 코드가 "외래 키 위반"이라는, 원인과 한참 떨어진 오류를 만난다. 통합 테스트를 처음 돌렸을 실제로 그렇게
* 실패했다. 그래서 전제를 여기서 확인하고 무엇이 잘못됐는지 그대로 말한다.
*/
private static void requireTransaction(String operation) {
if (!TransactionSynchronizationManager.isActualTransactionActive()) {
throw new IllegalStateException(
"publication "
+ operation
+ " must run inside an active transaction: publication and publication_event"
+ " reference each other, and that cycle only resolves at commit through"
+ " fk_publication_latest_event's deferred check");
}
}
static PublicationAggregateView mapAggregate(java.sql.ResultSet rs, int rowNum)
throws java.sql.SQLException {
return 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());
}
private String manifestJson(List<AssetManifestEntry> manifest) {
ArrayNode array = objectMapper.createArrayNode();
for (AssetManifestEntry entry : manifest) {
ObjectNode node = array.addObject();
node.put("assetId", entry.assetId() == null ? null : entry.assetId().toString());
node.put("assetKey", entry.assetKey());
node.put("mediaType", entry.mediaType());
node.put("publicPath", entry.publicPath());
node.put("width", entry.width());
node.put("height", entry.height());
node.put("decorative", entry.decorative());
}
try {
return objectMapper.writeValueAsString(array);
} catch (JacksonException e) {
throw new MappingException("failed to serialise a publication asset manifest", e);
}
}
/** {@code asset_reference.owner_type} 은 {@code RecordKind} 와 이름이 다르다(V7). */
private static String ownerTypeOf(RecordKind kind) {
return switch (kind) {
case CASE, REFERENCE -> "DOCUMENT";
case QUESTION -> "QUESTION";
case PROJECT_DECISION -> "DECISION";
};
}
/** {@code public_route.slug} 는 경로가 아니라 마지막 조각이다. */
private static String slugOf(String publicPath) {
if (publicPath == null || publicPath.isBlank()) {
throw StudioException.of(
StudioError.DOCUMENT_VALIDATION_FAILED, "a published record needs a public path");
}
return publicPath.substring(publicPath.lastIndexOf('/') + 1);
}
private static String nullToEmpty(String value) {
return value == null ? "" : value;
}
private static String sha256(String value) {
try {
return HexFormat.of()
.formatHex(
MessageDigest.getInstance("SHA-256")
.digest(nullToEmpty(value).getBytes(StandardCharsets.UTF_8)));
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("SHA-256 must be available on every supported JVM", e);
}
}
}
@@ -0,0 +1,25 @@
package dev.caskeleton.adapter.outbound.persistence.techlog.studio.publication;
import dev.caskeleton.application.techlog.studio.model.PublicationEventTypeView;
import dev.caskeleton.application.techlog.studio.model.PublicationEventView;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.UUID;
/** {@code publication_event} 행 매핑. writer 와 조회 어댑터가 같은 모양을 쓰도록 한 곳에 둔다. */
final class PublicationRowMappers {
private PublicationRowMappers() {}
static PublicationEventView mapEvent(ResultSet rs, int rowNum) throws SQLException {
return new PublicationEventView(
rs.getObject("publication_event_id", UUID.class),
rs.getObject("publication_id", UUID.class),
rs.getObject("source_id", UUID.class),
PublicationEventTypeView.valueOf(rs.getString("event_type")),
rs.getTimestamp("occurred_at").toInstant(),
rs.getLong("published_version"),
rs.getObject("source_published_event_id", UUID.class),
rs.getBoolean("snapshot_available"));
}
}
@@ -0,0 +1,255 @@
package dev.caskeleton.adapter.outbound.persistence.techlog.workingcopy;
import static dev.caskeleton.adapter.outbound.persistence.techlog.workingcopy.StudioSqlSupport.dateFromTimestamp;
import static dev.caskeleton.adapter.outbound.persistence.techlog.workingcopy.StudioSqlSupport.dateToTimestamp;
import static dev.caskeleton.adapter.outbound.persistence.techlog.workingcopy.StudioSqlSupport.instant;
import static dev.caskeleton.adapter.outbound.persistence.techlog.workingcopy.StudioSqlSupport.orEmpty;
import static dev.caskeleton.adapter.outbound.persistence.techlog.workingcopy.StudioSqlSupport.slugFromColumn;
import static dev.caskeleton.adapter.outbound.persistence.techlog.workingcopy.StudioSqlSupport.slugToColumn;
import dev.caskeleton.application.techlog.studio.model.RecordKind;
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 dev.caskeleton.application.techlog.studio.model.WorkingCopyView;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import java.util.function.Supplier;
import org.springframework.jdbc.core.simple.JdbcClient;
/**
* {@code CASE} / {@code REFERENCE} 편집본. {@code document} 루트 + 유형별 detail 테이블이다 (ADR-003, 설계
* 07장).
*/
final class DocumentWorkingCopyStore {
private final JdbcClient jdbcClient;
private final StudioRelationStore relations;
private final StudioJson json;
private final Supplier<UUID> idGenerator;
private final ProjectLinkStore projectLinks;
DocumentWorkingCopyStore(
JdbcClient jdbcClient,
StudioRelationStore relations,
StudioJson json,
Supplier<UUID> idGenerator,
ProjectLinkStore projectLinks) {
this.jdbcClient = jdbcClient;
this.relations = relations;
this.json = json;
this.idGenerator = idGenerator;
this.projectLinks = projectLinks;
}
Optional<WorkingCopyView> find(RecordKind kind, UUID id) {
return jdbcClient
.sql(
"SELECT d.id, d.version, d.updated_at, d.title, d.slug, d.summary, d.primary_topic_id,"
+ " d.body_markdown, d.last_verified_at,"
+ " c.problem_summary, c.conclusion_summary, c.environment, c.reproduction,"
+ " r.scope_summary, r.rules, r.applies_to, r.excluded_scope, r.examples"
+ " FROM document d"
+ " LEFT JOIN case_detail c ON c.document_id = d.id"
+ " LEFT JOIN reference_detail r ON r.document_id = d.id"
+ " WHERE d.id = :id AND d.document_type = :type")
.param("id", id)
.param("type", kind.name())
.query(
(rs, rowNum) -> {
UUID documentId = rs.getObject("id", UUID.class);
WorkingCopyBaseInput base =
new WorkingCopyBaseInput(
kind,
rs.getString("title"),
slugFromColumn(rs.getString("slug")),
orEmpty(rs.getString("summary")),
rs.getObject("primary_topic_id", UUID.class),
projectLinks.findPrimaryProjectForDocument(documentId).orElse(null),
relations.findBySource(kind, documentId));
long version = rs.getLong("version");
var updatedAt = instant(rs, "updated_at");
if (kind == RecordKind.CASE) {
return (WorkingCopyView)
new WorkingCopyView.CaseWorkingCopyView(
documentId,
version,
updatedAt,
base,
orEmpty(rs.getString("problem_summary")),
orEmpty(rs.getString("conclusion_summary")),
orEmpty(rs.getString("environment")),
orEmpty(rs.getString("reproduction")),
dateFromTimestamp(rs, "last_verified_at"),
orEmpty(rs.getString("body_markdown")));
}
return (WorkingCopyView)
new WorkingCopyView.ReferenceWorkingCopyView(
documentId,
version,
updatedAt,
base,
orEmpty(rs.getString("scope_summary")),
json.rulesFromJson(rs.getString("rules")),
json.orderedTextFromJson(rs.getString("applies_to")),
json.orderedTextFromJson(rs.getString("excluded_scope")),
json.orderedTextFromJson(rs.getString("examples")),
dateFromTimestamp(rs, "last_verified_at"));
})
.optional();
}
UUID create(WorkingCopyInputView input, String principal) {
RecordKind kind = input.kind();
UUID id = idGenerator.get();
WorkingCopyBaseInput base = input.base();
jdbcClient
.sql(
"INSERT INTO document (id, document_type, slug, title, summary, body_markdown,"
+ " primary_topic_id, last_verified_at, version, created_by, updated_by)"
+ " VALUES (:id, :type, :slug, :title, :summary, :body, :topicId, :verifiedAt,"
// 계약의 WorkingCopyBase.version minimum 1 이다. 컬럼 기본값 0 그대로 두면
// 생성 직후 응답이 계약을 위반한다.
+ " 1, :principal, :principal)")
.param("id", id)
.param("type", kind.name())
.param("slug", slugToColumn(base.slug()))
.param("title", orEmpty(base.title()))
.param("summary", orEmpty(base.summary()))
.param("body", bodyMarkdownOf(input))
.param("topicId", base.topicId())
.param("verifiedAt", dateToTimestamp(verifiedOnOf(input)))
.param("principal", principal)
.update();
insertDetail(id, input);
relations.replace(kind, id, base.relations());
projectLinks.setPrimaryProjectForDocument(id, base.projectId());
return id;
}
/**
* 낙관적 잠금 저장.
*
* @return 갱신된 행이 없으면(= {@code expectedVersion} 불일치) {@code false}
*/
boolean save(UUID id, long expectedVersion, WorkingCopyInputView input, String principal) {
RecordKind kind = input.kind();
WorkingCopyBaseInput base = input.base();
int updated =
jdbcClient
.sql(
"UPDATE document SET slug = :slug, title = :title, summary = :summary,"
+ " body_markdown = :body, primary_topic_id = :topicId,"
+ " last_verified_at = :verifiedAt, version = version + 1,"
+ " updated_at = now(), updated_by = :principal"
+ " WHERE id = :id AND document_type = :type AND version = :expectedVersion")
.param("slug", slugToColumn(base.slug()))
.param("title", orEmpty(base.title()))
.param("summary", orEmpty(base.summary()))
.param("body", bodyMarkdownOf(input))
.param("topicId", base.topicId())
.param("verifiedAt", dateToTimestamp(verifiedOnOf(input)))
.param("principal", principal)
.param("id", id)
.param("type", kind.name())
.param("expectedVersion", expectedVersion)
.update();
if (updated == 0) {
return false;
}
updateDetail(id, input);
relations.replace(kind, id, base.relations());
projectLinks.setPrimaryProjectForDocument(id, base.projectId());
return true;
}
private void insertDetail(UUID id, WorkingCopyInputView input) {
switch (input) {
case WorkingCopyInputView.CaseInputView caseInput ->
jdbcClient
.sql(
"INSERT INTO case_detail (document_id, document_type, problem_summary,"
+ " conclusion_summary, environment, reproduction)"
+ " VALUES (:id, 'CASE', :problem, :conclusion, :environment, :reproduction)")
.param("id", id)
.param("problem", orEmpty(caseInput.problem()))
.param("conclusion", orEmpty(caseInput.conclusion()))
.param("environment", orEmpty(caseInput.environment()))
.param("reproduction", orEmpty(caseInput.reproduction()))
.update();
case WorkingCopyInputView.ReferenceInputView reference ->
jdbcClient
.sql(
"INSERT INTO reference_detail (document_id, document_type, scope_summary,"
+ " rules, applies_to, excluded_scope, examples)"
+ " VALUES (:id, 'REFERENCE', :purpose, CAST(:rules AS jsonb),"
+ " CAST(:applyWhen AS jsonb), CAST(:exceptions AS jsonb),"
+ " CAST(:examples AS jsonb))")
.param("id", id)
.param("purpose", orEmpty(reference.purpose()))
.param("rules", json.rulesToJson(reference.rules()))
.param("applyWhen", json.orderedTextToJson(reference.applyWhen()))
.param("exceptions", json.orderedTextToJson(reference.exceptions()))
.param("examples", json.orderedTextToJson(reference.examples()))
.update();
default ->
throw new IllegalArgumentException("not a document-backed working copy: " + input.kind());
}
}
private void updateDetail(UUID id, WorkingCopyInputView input) {
switch (input) {
case WorkingCopyInputView.CaseInputView caseInput ->
jdbcClient
.sql(
"UPDATE case_detail SET problem_summary = :problem,"
+ " conclusion_summary = :conclusion, environment = :environment,"
+ " reproduction = :reproduction WHERE document_id = :id")
.param("id", id)
.param("problem", orEmpty(caseInput.problem()))
.param("conclusion", orEmpty(caseInput.conclusion()))
.param("environment", orEmpty(caseInput.environment()))
.param("reproduction", orEmpty(caseInput.reproduction()))
.update();
case WorkingCopyInputView.ReferenceInputView reference ->
jdbcClient
.sql(
"UPDATE reference_detail SET scope_summary = :purpose,"
+ " rules = CAST(:rules AS jsonb), applies_to = CAST(:applyWhen AS jsonb),"
+ " excluded_scope = CAST(:exceptions AS jsonb),"
+ " examples = CAST(:examples AS jsonb) WHERE document_id = :id")
.param("id", id)
.param("purpose", orEmpty(reference.purpose()))
.param("rules", json.rulesToJson(reference.rules()))
.param("applyWhen", json.orderedTextToJson(reference.applyWhen()))
.param("exceptions", json.orderedTextToJson(reference.exceptions()))
.param("examples", json.orderedTextToJson(reference.examples()))
.update();
default ->
throw new IllegalArgumentException("not a document-backed working copy: " + input.kind());
}
}
/** {@code REFERENCE}는 계약에 본문이 없다 — 컬럼이 NOT NULL이므로 빈 문자열을 유지한다. */
private static String bodyMarkdownOf(WorkingCopyInputView input) {
return input instanceof WorkingCopyInputView.CaseInputView caseInput
? orEmpty(caseInput.bodyMarkdown())
: "";
}
/** 계약의 {@code lastVerifiedOn}(CASE) / {@code verifiedOn}(REFERENCE)은 같은 컬럼에 담긴다. */
private static java.time.LocalDate verifiedOnOf(WorkingCopyInputView input) {
return switch (input) {
case WorkingCopyInputView.CaseInputView caseInput -> caseInput.lastVerifiedOn();
case WorkingCopyInputView.ReferenceInputView reference -> reference.verifiedOn();
default -> null;
};
}
List<RelationView> relationsOf(RecordKind kind, UUID id) {
return relations.findBySource(kind, id);
}
}
@@ -0,0 +1,128 @@
package dev.caskeleton.adapter.outbound.persistence.techlog.workingcopy;
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.model.WorkingCopyInputView;
import dev.caskeleton.application.techlog.studio.model.WorkingCopyView;
import dev.caskeleton.application.techlog.studio.port.out.WorkingCopyRepositoryPort;
import java.util.Optional;
import java.util.UUID;
import java.util.function.Supplier;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Repository;
import tools.jackson.databind.ObjectMapper;
/**
* 계약의 통합 {@code WorkingCopy} source aggregate로 dispatch한다.
*
* <p>공통 CRUD repository가 아니다 {@code kind}마다 소유 테이블이 다르고, 구분을 유지하는 것이 ADR-003의 결정이다. 여기서 하는 일은
* "어느 저장소로 보낼지" 뿐이다.
*
* <p>JPA 엔티티가 아니라 {@code JdbcClient} 쓴다. aggregate는 Studio 저장 경로에서만 쓰이고, 낙관적 잠금은 {@code UPDATE
* ... WHERE version = :expectedVersion} 갱신 수로 정확히 같은 의미를 얻는다 여덟 넘는 테이블에 엔티티와 매핑을 세우는 비용에
* 상응하는 이득이 없다. 포트 계약이 같으므로 나중에 JPA 필요해지면 어댑터만 바뀐다.
*/
@Repository
public class JdbcWorkingCopyRepositoryAdapter implements WorkingCopyRepositoryPort {
private final JdbcClient jdbcClient;
private final DocumentWorkingCopyStore documents;
private final QuestionWorkingCopyStore questions;
private final ProjectDecisionWorkingCopyStore decisions;
/**
* 생성자가 둘이라 Spring 어느 쪽을 쓸지 스스로 정하지 못한다 표시가 없으면 기본 생성자를 찾다 실패해 컨텍스트가 뜨지 않는다(실제로 부팅 검증에서 그렇게
* 실패했다). 번째 생성자는 테스트가 id 생성기를 주입하기 위한 것이며 프로덕션 배선은 항상 이쪽이다.
*/
@Autowired
public JdbcWorkingCopyRepositoryAdapter(JdbcClient jdbcClient, ObjectMapper objectMapper) {
this(jdbcClient, objectMapper, UUID::randomUUID);
}
JdbcWorkingCopyRepositoryAdapter(
JdbcClient jdbcClient, ObjectMapper objectMapper, Supplier<UUID> idGenerator) {
this.jdbcClient = jdbcClient;
StudioJson json = new StudioJson(objectMapper);
StudioRelationStore relations = new StudioRelationStore(jdbcClient, idGenerator);
ProjectLinkStore projectLinks = new ProjectLinkStore(jdbcClient);
this.documents =
new DocumentWorkingCopyStore(jdbcClient, relations, json, idGenerator, projectLinks);
this.questions =
new QuestionWorkingCopyStore(jdbcClient, relations, json, idGenerator, projectLinks);
this.decisions = new ProjectDecisionWorkingCopyStore(jdbcClient, relations, json, idGenerator);
}
/**
* 계약상 {@code documentId} source aggregate id 그대로다 어느 테이블에 있는지 먼저 찾아야 한다 (spec §7.2
* StudioDocumentLocator). 테이블을 UNION 으로 번에 본다.
*/
@Override
public Optional<RecordKind> findKind(UUID documentId) {
if (documentId == null) {
return Optional.empty();
}
return jdbcClient
.sql(
"SELECT document_type AS kind FROM document WHERE id = :id"
+ " UNION ALL SELECT 'QUESTION' FROM open_question WHERE id = :id"
+ " UNION ALL SELECT 'PROJECT_DECISION' FROM project_decision WHERE id = :id")
.param("id", documentId)
.query(String.class)
.optional()
.map(RecordKind::valueOf);
}
@Override
public Optional<WorkingCopyView> find(UUID documentId) {
return findKind(documentId).flatMap(kind -> load(kind, documentId));
}
@Override
public WorkingCopyView create(WorkingCopyInputView input, String principal) {
UUID id =
switch (input) {
case WorkingCopyInputView.CaseInputView ignored -> documents.create(input, principal);
case WorkingCopyInputView.ReferenceInputView ignored ->
documents.create(input, principal);
case WorkingCopyInputView.QuestionInputView question ->
questions.create(question, principal);
case WorkingCopyInputView.ProjectDecisionInputView decision ->
decisions.create(decision, principal);
};
return load(input.kind(), id)
.orElseThrow(
() ->
StudioException.of(
StudioError.STUDIO_UNAVAILABLE,
"the working copy "
+ id
+ " could not be read back right after it was created"));
}
@Override
public Optional<WorkingCopyView> save(
UUID documentId, long expectedVersion, WorkingCopyInputView input, String principal) {
boolean saved =
switch (input) {
case WorkingCopyInputView.CaseInputView ignored ->
documents.save(documentId, expectedVersion, input, principal);
case WorkingCopyInputView.ReferenceInputView ignored ->
documents.save(documentId, expectedVersion, input, principal);
case WorkingCopyInputView.QuestionInputView question ->
questions.save(documentId, expectedVersion, question, principal);
case WorkingCopyInputView.ProjectDecisionInputView decision ->
decisions.save(documentId, expectedVersion, decision, principal);
};
return saved ? load(input.kind(), documentId) : Optional.empty();
}
private Optional<WorkingCopyView> load(RecordKind kind, UUID id) {
return switch (kind) {
case CASE, REFERENCE -> documents.find(kind, id);
case QUESTION -> questions.find(id);
case PROJECT_DECISION -> decisions.find(id);
};
}
}
@@ -0,0 +1,180 @@
package dev.caskeleton.adapter.outbound.persistence.techlog.workingcopy;
import static dev.caskeleton.adapter.outbound.persistence.techlog.workingcopy.StudioSqlSupport.dateFromTimestamp;
import static dev.caskeleton.adapter.outbound.persistence.techlog.workingcopy.StudioSqlSupport.dateToTimestamp;
import static dev.caskeleton.adapter.outbound.persistence.techlog.workingcopy.StudioSqlSupport.instant;
import static dev.caskeleton.adapter.outbound.persistence.techlog.workingcopy.StudioSqlSupport.orEmpty;
import static dev.caskeleton.adapter.outbound.persistence.techlog.workingcopy.StudioSqlSupport.slugFromColumn;
import static dev.caskeleton.adapter.outbound.persistence.techlog.workingcopy.StudioSqlSupport.slugToColumn;
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.RecordKind;
import dev.caskeleton.application.techlog.studio.model.WorkingCopyBaseInput;
import dev.caskeleton.application.techlog.studio.model.WorkingCopyInputView;
import dev.caskeleton.application.techlog.studio.model.WorkingCopyView;
import java.util.Optional;
import java.util.UUID;
import java.util.function.Supplier;
import org.springframework.jdbc.core.simple.JdbcClient;
/**
* {@code PROJECT_DECISION} 편집본 ({@code project_decision}).
*
* <p>계약의 {@code ADOPTED} Domain의 {@code ACCEPTED}(ADR-003) UI 용어 때문에 Domain enum을 바꾸지 않고 여기서
* 변환한다. {@code supersede}/{@code reject} secondary management 계약이 소유하므로 저장 경로가 상태를 만들지도,
* 건드리지도 않는다.
*/
final class ProjectDecisionWorkingCopyStore {
private final JdbcClient jdbcClient;
private final StudioRelationStore relations;
private final StudioJson json;
private final Supplier<UUID> idGenerator;
ProjectDecisionWorkingCopyStore(
JdbcClient jdbcClient,
StudioRelationStore relations,
StudioJson json,
Supplier<UUID> idGenerator) {
this.jdbcClient = jdbcClient;
this.relations = relations;
this.json = json;
this.idGenerator = idGenerator;
}
Optional<WorkingCopyView> find(UUID id) {
return jdbcClient
.sql(
"SELECT id, version, updated_at, title, slug, summary, primary_topic_id, project_id,"
+ " decision_status, decided_at, statement, rationale_markdown, consequences"
+ " FROM project_decision WHERE id = :id")
.param("id", id)
.query(
(rs, rowNum) -> {
UUID decisionId = rs.getObject("id", UUID.class);
WorkingCopyBaseInput base =
new WorkingCopyBaseInput(
RecordKind.PROJECT_DECISION,
orEmpty(rs.getString("title")),
slugFromColumn(rs.getString("slug")),
orEmpty(rs.getString("summary")),
rs.getObject("primary_topic_id", UUID.class),
rs.getObject("project_id", UUID.class),
relations.findBySource(RecordKind.PROJECT_DECISION, decisionId));
return (WorkingCopyView)
new WorkingCopyView.ProjectDecisionWorkingCopyView(
decisionId,
rs.getLong("version"),
instant(rs, "updated_at"),
base,
toContractStatus(rs.getString("decision_status")),
dateFromTimestamp(rs, "decided_at"),
orEmpty(rs.getString("statement")),
orEmpty(rs.getString("rationale_markdown")),
json.orderedTextFromJson(rs.getString("consequences")));
})
.optional();
}
UUID create(WorkingCopyInputView.ProjectDecisionInputView input, String principal) {
requireDecidedOnWhenAdopted(input);
UUID id = idGenerator.get();
WorkingCopyBaseInput base = input.base();
jdbcClient
.sql(
"INSERT INTO project_decision (id, project_id, title, slug, summary,"
+ " primary_topic_id, statement, rationale_markdown, consequences,"
+ " decision_status, decided_at, version, created_by, updated_by)"
+ " VALUES (:id, :projectId, :title, :slug, :summary, :topicId, :statement,"
+ " :rationale, CAST(:consequences AS jsonb), :status, :decidedAt, 1,"
+ " :principal, :principal)")
.param("id", id)
.param("projectId", base.projectId())
.param("title", orEmpty(base.title()))
.param("slug", slugToColumn(base.slug()))
.param("summary", orEmpty(base.summary()))
.param("topicId", base.topicId())
.param("statement", orEmpty(input.statement()))
.param("rationale", orEmpty(input.rationale()))
.param("consequences", json.orderedTextToJson(input.consequences()))
.param("status", toDomainStatus(input.decisionStatus()))
.param("decidedAt", dateToTimestamp(input.decidedOn()))
.param("principal", principal)
.update();
relations.replace(RecordKind.PROJECT_DECISION, id, base.relations());
return id;
}
boolean save(
UUID id,
long expectedVersion,
WorkingCopyInputView.ProjectDecisionInputView input,
String principal) {
requireDecidedOnWhenAdopted(input);
WorkingCopyBaseInput base = input.base();
int updated =
jdbcClient
.sql(
"UPDATE project_decision SET project_id = :projectId, title = :title,"
+ " slug = :slug, summary = :summary, primary_topic_id = :topicId,"
+ " statement = :statement, rationale_markdown = :rationale,"
+ " consequences = CAST(:consequences AS jsonb),"
// SUPERSEDED/REJECTED secondary management 계약이 소유한다. Studio 저장이
// 상태를 PROPOSED/ACCEPTED 되돌리면 그쪽 lifecycle 조용히 무효화된다.
+ " decision_status = CASE WHEN decision_status IN ('PROPOSED', 'ACCEPTED')"
+ " THEN :status ELSE decision_status END,"
+ " decided_at = :decidedAt,"
+ " version = version + 1, updated_at = now(), updated_by = :principal"
+ " WHERE id = :id AND version = :expectedVersion")
.param("projectId", base.projectId())
.param("title", orEmpty(base.title()))
.param("slug", slugToColumn(base.slug()))
.param("summary", orEmpty(base.summary()))
.param("topicId", base.topicId())
.param("statement", orEmpty(input.statement()))
.param("rationale", orEmpty(input.rationale()))
.param("consequences", json.orderedTextToJson(input.consequences()))
.param("status", toDomainStatus(input.decisionStatus()))
.param("decidedAt", dateToTimestamp(input.decidedOn()))
.param("principal", principal)
.param("id", id)
.param("expectedVersion", expectedVersion)
.update();
if (updated == 0) {
return false;
}
relations.replace(RecordKind.PROJECT_DECISION, id, base.relations());
return true;
}
/**
* {@code ck_project_decision_status_fields} {@code ACCEPTED} {@code decided_at} 요구한다. 값을 지어내
* 채우면 사용자가 정하지 않은 날짜가 기록되고, 그냥 보내면 DB 제약 위반이 500으로 나간다 무엇이 빠졌는지 알려주고 거절한다.
*/
private static void requireDecidedOnWhenAdopted(
WorkingCopyInputView.ProjectDecisionInputView input) {
if (input.decisionStatus() == DecisionStatusView.ADOPTED && input.decidedOn() == null) {
throw StudioException.of(
StudioError.REQUEST_VALIDATION_FAILED,
"document.decidedOn is required when decisionStatus is ADOPTED");
}
}
private static String toDomainStatus(DecisionStatusView status) {
return status == DecisionStatusView.ADOPTED ? "ACCEPTED" : "PROPOSED";
}
private static DecisionStatusView toContractStatus(String domainStatus) {
return switch (domainStatus) {
case "ACCEPTED" -> DecisionStatusView.ADOPTED;
case "PROPOSED" -> DecisionStatusView.PROPOSED;
// SUPERSEDED / REJECTED 계약의 어디에도 대응하지 않는다. 계약은 null 허용하므로
// 억지로 가장 가까운 값으로 접지 않고 "이 축약 view 로는 표현할 수 없음" null 알린다.
default -> null;
};
}
}

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