Compare commits

...
6 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
222 changed files with 21426 additions and 15551 deletions
+63
View File
@@ -4249,3 +4249,66 @@ env_keys:
validation: positive_int_bounded validation: positive_int_bounded
compatibility_impact: behavior-change compatibility_impact: behavior-change
required_test: async-contract:executor-queue-bounded required_test: async-contract:executor-queue-bounded
# === Tech Log Studio (feature-techlog-studio-backend) ===
- name: APP_STUDIO_CURSOR_SIGNING_KEY
# source: studio-v1.yaml components.parameters.Cursor — "Opaque cursor bound to
# normalized filters and sort". 서명 키가 인스턴스마다 다르면 한 인스턴스가 발급한
# 커서를 다른 인스턴스가 거부한다. 비어 있으면 StudioSettings가 경고하고 개발용 값으로
# 대체한다(커서에 권한이 실리지 않으므로 부팅은 막지 않는다).
type: string
default: null
allowed_values: null
classification: secret
required: false
reload_policy: restart-only
owner_branch: feature-techlog-studio-backend
validation: min_length_16_bytes
compatibility_impact: behavior-change
required_test: techlog-studio-contract:cursor-round-trip
- name: APP_STUDIO_VALIDATION_TTL
# source: 백엔드 설계 §7.3 — "now() < validUntil" 이 Validation 유효 조건의 하나다.
# studio_validation.valid_until 을 채우는 값.
type: duration
default: 1h
allowed_values: null
classification: public-config
required: false
reload_policy: restart-only
owner_branch: feature-techlog-studio-backend
validation: spring_duration_shorthand
compatibility_impact: behavior-change
required_test: techlog-studio-contract:validation-window
- name: APP_STUDIO_PREVIEW_TTL
# source: 백엔드 설계 §7.3 — Preview 가 EXPIRED 로 넘어가는 기준.
# studio_preview.expires_at 을 채우는 값.
type: duration
default: 24h
allowed_values: null
classification: public-config
required: false
reload_policy: restart-only
owner_branch: feature-techlog-studio-backend
validation: spring_duration_shorthand
compatibility_impact: behavior-change
required_test: techlog-studio-contract:preview-expiry
- name: APP_STUDIO_AUTHOR_ROLE
# source: 백엔드 설계 §9 — 권한은 기존 RolePermissionPolicy 를 재사용한다.
# ca-skeleton.authz.role-permissions 의 키로 쓰이는 IdP RAW role 이름. 배포마다 다르다.
# application.yml 이 아니라 프로파일(application-{local,dev,prod}.yml)에 있다 —
# SampleRemovalSmokeContractTest 가 application.yml 의 `role-permissions: {}` 기준선을
# 그대로 유지하도록 요구하기 때문이다.
type: string
default: studio-author
allowed_values: null
classification: public-config
required: false
reload_policy: restart-only
owner_branch: feature-techlog-studio-backend
validation: non_blank
compatibility_impact: behavior-change
required_test: techlog-studio-contract:author-permission
+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())
+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");
}
}
@@ -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;
};
}
}
@@ -0,0 +1,81 @@
package dev.caskeleton.adapter.outbound.persistence.techlog.workingcopy;
import java.util.Optional;
import java.util.UUID;
import org.springframework.jdbc.core.simple.JdbcClient;
/**
* 계약의 단일 {@code projectId} 설계 스키마의 링크 테이블({@code project_document_link} / {@code
* project_question_link}) 옮긴다.
*
* <p>링크 테이블은 문서가 여러 프로젝트에 붙는 것을 허용하지만 Studio 편집기는 프로젝트 하나만 다룬다. Studio가 소유하는 것은 {@code PRIMARY}
* 링크 하나뿐이며, {@code RELATED} 링크는 건드리지 않는다 그건 다른 화면의 데이터이고 Studio 저장이 지워도 되는 것이 아니다.
*/
final class ProjectLinkStore {
private static final String PRIMARY = "PRIMARY";
private final JdbcClient jdbcClient;
ProjectLinkStore(JdbcClient jdbcClient) {
this.jdbcClient = jdbcClient;
}
Optional<UUID> findPrimaryProjectForDocument(UUID documentId) {
return findPrimary("project_document_link", "document_id", documentId);
}
Optional<UUID> findPrimaryProjectForQuestion(UUID questionId) {
return findPrimary("project_question_link", "question_id", questionId);
}
void setPrimaryProjectForDocument(UUID documentId, UUID projectId) {
setPrimary("project_document_link", "document_id", documentId, projectId);
}
void setPrimaryProjectForQuestion(UUID questionId, UUID projectId) {
setPrimary("project_question_link", "question_id", questionId, projectId);
}
private Optional<UUID> findPrimary(String table, String column, UUID id) {
return jdbcClient
.sql(
"SELECT project_id FROM "
+ table
+ " WHERE "
+ column
+ " = :id AND relation_type = :type")
.param("id", id)
.param("type", PRIMARY)
.query(UUID.class)
.optional();
}
private void setPrimary(String table, String column, UUID id, UUID projectId) {
jdbcClient
.sql("DELETE FROM " + table + " WHERE " + column + " = :id AND relation_type = :type")
.param("id", id)
.param("type", PRIMARY)
.update();
if (projectId == null) {
return;
}
jdbcClient
.sql(
"INSERT INTO "
+ table
+ " (project_id, "
+ column
+ ", relation_type)"
+ " VALUES (:projectId, :id, :type)"
// PK (project_id, <column>) 이라 같은 쌍이 RELATED 이미 있으면 INSERT 깨진다.
// Studio 소유하는 것은 PRIMARY 이므로 경우 관계 종류를 올려준다.
+ " ON CONFLICT (project_id, "
+ column
+ ") DO UPDATE SET relation_type = :type")
.param("projectId", projectId)
.param("id", id)
.param("type", PRIMARY)
.update();
}
}
@@ -0,0 +1,296 @@
package dev.caskeleton.adapter.outbound.persistence.techlog.workingcopy;
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.OrderedTextView;
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.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.Set;
import java.util.UUID;
import java.util.function.Supplier;
import org.springframework.jdbc.core.simple.JdbcClient;
/**
* {@code QUESTION} 편집본 ({@code open_question} + {@code question_point}).
*
* <p>계약의 {@code questionStatus} Domain lifecycle의 축약 view다(ADR-003) {@code OPEN} 받았다고 Domain의
* {@code INVESTIGATING}/{@code PAUSED} 덮어쓰지 않는다. 그렇게 하면 조사 이력이 사라진다.
*/
final class QuestionWorkingCopyStore {
/**
* 계약의 {@code QuestionResolution}에는 resolution_type이 없는데 {@code
* ck_question_resolution_consistency} RESOLVED에 값을 요구한다. Studio 편집으로 해결 처리된 질문은 "판단을 내렸다"
* 기록한다 나머지 유형(가정 기각/질문 재정의/무의미해짐) secondary management 계약의 명시적 action이 소유한다.
*/
private static final String DEFAULT_RESOLUTION_TYPE = "DECISION_MADE";
private final JdbcClient jdbcClient;
private final StudioRelationStore relations;
private final StudioJson json;
private final Supplier<UUID> idGenerator;
private final ProjectLinkStore projectLinks;
QuestionWorkingCopyStore(
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(UUID id) {
return jdbcClient
.sql(
"SELECT id, version, updated_at, question, slug, summary, primary_topic_id,"
+ " question_status, next_verification, options, resolution_summary,"
+ " resolution_evidence_target_id, resolution_link_label"
+ " FROM open_question WHERE id = :id")
.param("id", id)
.query(
(rs, rowNum) -> {
UUID questionId = rs.getObject("id", UUID.class);
WorkingCopyBaseInput base =
new WorkingCopyBaseInput(
RecordKind.QUESTION,
rs.getString("question"),
slugFromColumn(rs.getString("slug")),
orEmpty(rs.getString("summary")),
rs.getObject("primary_topic_id", UUID.class),
projectLinks.findPrimaryProjectForQuestion(questionId).orElse(null),
relations.findBySource(RecordKind.QUESTION, questionId));
String domainStatus = rs.getString("question_status");
return (WorkingCopyView)
new WorkingCopyView.QuestionWorkingCopyView(
questionId,
rs.getLong("version"),
instant(rs, "updated_at"),
base,
toContractStatus(domainStatus),
points(questionId, "FACT"),
points(questionId, "ASSUMPTION"),
points(questionId, "UNKNOWN"),
points(questionId, "CONSTRAINT"),
json.optionsFromJson(rs.getString("options")),
orEmpty(rs.getString("next_verification")),
resolutionOf(
domainStatus,
rs.getString("resolution_summary"),
rs.getObject("resolution_evidence_target_id", UUID.class),
rs.getString("resolution_link_label")));
})
.optional();
}
UUID create(WorkingCopyInputView.QuestionInputView input, String principal) {
UUID id = idGenerator.get();
WorkingCopyBaseInput base = input.base();
boolean resolved = input.questionStatus() == QuestionStatusView.RESOLVED;
requireUsableResolution(input, resolved);
jdbcClient
.sql(
"INSERT INTO open_question (id, slug, question, summary, primary_topic_id,"
+ " next_verification, options, question_status, resolution_type,"
+ " resolution_summary, resolution_evidence_target_id, resolution_link_label,"
+ " resolved_at, version, created_by, updated_by)"
+ " VALUES (:id, :slug, :question, :summary, :topicId, :nextValidation,"
+ " CAST(:options AS jsonb), :status, :resolutionType, :resolutionSummary,"
+ " :evidenceTargetId, :linkLabel,"
// 해결 시각은 애플리케이션 시계가 아니라 DB 시계로 찍는다 같은 트랜잭션의
// 다른 타임스탬프(created_at/updated_at) 기준이 같아야 순서가 뒤집히지 않는다.
+ " CASE WHEN :resolved THEN now() ELSE NULL END, 1, :principal, :principal)")
.param("id", id)
.param("slug", slugToColumn(base.slug()))
.param("question", orEmpty(base.title()))
.param("summary", orEmpty(base.summary()))
.param("topicId", base.topicId())
.param("nextValidation", orEmpty(input.nextValidation()))
.param("options", json.optionsToJson(input.options()))
.param("status", resolved ? "RESOLVED" : "OPEN")
.param("resolutionType", resolved ? DEFAULT_RESOLUTION_TYPE : null)
.param("resolutionSummary", resolved ? input.resolution().summary() : null)
.param("evidenceTargetId", resolved ? input.resolution().evidenceTargetId() : null)
.param("linkLabel", resolved ? orEmpty(input.resolution().linkLabel()) : "")
.param("resolved", resolved)
.param("principal", principal)
.update();
replacePoints(id, input);
relations.replace(RecordKind.QUESTION, id, base.relations());
projectLinks.setPrimaryProjectForQuestion(id, base.projectId());
return id;
}
boolean save(
UUID id,
long expectedVersion,
WorkingCopyInputView.QuestionInputView input,
String principal) {
WorkingCopyBaseInput base = input.base();
String storedStatus =
jdbcClient
.sql("SELECT question_status FROM open_question WHERE id = :id")
.param("id", id)
.query(String.class)
.optional()
.orElse("OPEN");
boolean resolveNow =
input.questionStatus() == QuestionStatusView.RESOLVED && !"RESOLVED".equals(storedStatus);
requireUsableResolution(input, input.questionStatus() == QuestionStatusView.RESOLVED);
// ADR-003: 축약 상태가 Domain lifecycle 덮어쓰지 않는다. OPEN 계열 안에서의 변화는 무시하고,
// 이미 RESOLVED 질문을 OPEN 으로 되돌리는 것도 저장이 일이 아니다 reopen secondary
// management 계약의 명시적 action 이다.
String nextStatus = resolveNow ? "RESOLVED" : storedStatus;
boolean resolvedAfter = "RESOLVED".equals(nextStatus);
int updated =
jdbcClient
.sql(
"UPDATE open_question SET slug = :slug, question = :question, summary = :summary,"
+ " primary_topic_id = :topicId, next_verification = :nextValidation,"
+ " options = CAST(:options AS jsonb), question_status = :status,"
+ " resolution_type = CASE WHEN :resolved THEN"
+ " COALESCE(resolution_type, :resolutionType) ELSE resolution_type END,"
+ " resolution_summary = CASE WHEN :resolved THEN :resolutionSummary"
+ " ELSE resolution_summary END,"
+ " resolution_evidence_target_id = CASE WHEN :resolved THEN :evidenceTargetId"
+ " ELSE resolution_evidence_target_id END,"
+ " resolution_link_label = CASE WHEN :resolved THEN :linkLabel"
+ " ELSE resolution_link_label END,"
+ " resolved_at = CASE WHEN :resolved THEN COALESCE(resolved_at, now())"
+ " ELSE resolved_at END,"
+ " version = version + 1, updated_at = now(), updated_by = :principal"
+ " WHERE id = :id AND version = :expectedVersion")
.param("slug", slugToColumn(base.slug()))
.param("question", orEmpty(base.title()))
.param("summary", orEmpty(base.summary()))
.param("topicId", base.topicId())
.param("nextValidation", orEmpty(input.nextValidation()))
.param("options", json.optionsToJson(input.options()))
.param("status", nextStatus)
.param("resolved", resolvedAfter)
.param("resolutionType", DEFAULT_RESOLUTION_TYPE)
.param("resolutionSummary", resolvedAfter ? input.resolution().summary() : null)
.param("evidenceTargetId", resolvedAfter ? input.resolution().evidenceTargetId() : null)
.param("linkLabel", resolvedAfter ? orEmpty(input.resolution().linkLabel()) : "")
.param("principal", principal)
.param("id", id)
.param("expectedVersion", expectedVersion)
.update();
if (updated == 0) {
return false;
}
replacePoints(id, input);
relations.replace(RecordKind.QUESTION, id, base.relations());
projectLinks.setPrimaryProjectForQuestion(id, base.projectId());
return true;
}
/**
* {@code RESOLVED} {@code ck_question_resolution_consistency} 요약을 요구한다. 요약 없이 해결 처리된 질문은 "
* 끝났는지 모르는 종료"라 저장을 거부한다 — 여기서 거부하지 않으면 DB 제약 위반이 500으로 나가 클라이언트가 무엇이 잘못됐는지 알 수 없다.
*/
private static void requireUsableResolution(
WorkingCopyInputView.QuestionInputView input, boolean resolved) {
if (!resolved) {
return;
}
QuestionResolutionView resolution = input.resolution();
if (resolution == null || resolution.summary() == null || resolution.summary().isBlank()) {
throw StudioException.of(
StudioError.REQUEST_VALIDATION_FAILED,
"document.resolution.summary is required when questionStatus is RESOLVED");
}
}
private static QuestionStatusView toContractStatus(String domainStatus) {
return "RESOLVED".equals(domainStatus) ? QuestionStatusView.RESOLVED : QuestionStatusView.OPEN;
}
private static QuestionResolutionView resolutionOf(
String domainStatus, String summary, UUID evidenceTargetId, String linkLabel) {
if (!"RESOLVED".equals(domainStatus)) {
return null;
}
return new QuestionResolutionView(orEmpty(summary), evidenceTargetId, orEmpty(linkLabel));
}
private List<OrderedTextView> points(UUID questionId, String pointKind) {
return jdbcClient
.sql(
"SELECT id, content, display_order FROM question_point"
+ " WHERE question_id = :id AND point_kind = :kind ORDER BY display_order")
.param("id", questionId)
.param("kind", pointKind)
.query(
(rs, rowNum) ->
new OrderedTextView(
rs.getObject("id", UUID.class),
rs.getString("content"),
rs.getInt("display_order")))
.list();
}
private void replacePoints(UUID questionId, WorkingCopyInputView.QuestionInputView input) {
// 질문이 지금 소유한 point id. 클라이언트가 보낸 id 여기 있는 것만 유지한다 남의 질문 것을
// 그대로 쓰면 PK 충돌하고, 매번 새로 부여하면 편집기의 식별자가 저장마다 바뀐다.
Set<UUID> owned =
Set.copyOf(
jdbcClient
.sql("SELECT id FROM question_point WHERE question_id = :id")
.param("id", questionId)
.query(UUID.class)
.list());
jdbcClient
.sql("DELETE FROM question_point WHERE question_id = :id")
.param("id", questionId)
.update();
insertPoints(questionId, "FACT", input.facts(), owned);
insertPoints(questionId, "ASSUMPTION", input.assumptions(), owned);
insertPoints(questionId, "UNKNOWN", input.unknowns(), owned);
insertPoints(questionId, "CONSTRAINT", input.constraints(), owned);
}
private void insertPoints(
UUID questionId, String pointKind, List<OrderedTextView> items, Set<UUID> owned) {
int order = 0;
for (OrderedTextView item : items) {
// content CHECK (length(trim(content)) > 0) . 줄은 편집 흔한 상태이므로 거부하는 대신
// 저장하지 않는다 계약도 OrderedText.text minLength 1 두어 항목을 보내지 말라고 한다.
if (item.text() == null || item.text().isBlank()) {
continue;
}
jdbcClient
.sql(
"INSERT INTO question_point (id, question_id, point_kind, content, display_order)"
+ " VALUES (:id, :questionId, :kind, :content, :order)")
.param(
"id",
(item.id() != null && owned.contains(item.id())) ? item.id() : idGenerator.get())
.param("questionId", questionId)
.param("kind", pointKind)
.param("content", item.text())
.param("order", order++)
.update();
}
}
}
@@ -0,0 +1,131 @@
package dev.caskeleton.adapter.outbound.persistence.techlog.workingcopy;
import dev.caskeleton.application.techlog.studio.model.OrderedTextView;
import dev.caskeleton.application.techlog.studio.model.QuestionOptionView;
import dev.caskeleton.application.techlog.studio.model.ReferenceRuleView;
import dev.caskeleton.shared.error.MappingException;
import java.util.List;
import java.util.UUID;
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;
/**
* 설계 스키마가 jsonb 배열로 정한 순서 있는 항목들을 읽고 쓴다.
*
* <p>Jackson의 자동 POJO 바인딩을 쓰지 않고 필드를 직접 읽고 쓴다. 값들은 <b>DB에 영속되는 형태</b> application record의 필드 이름이
* 바뀌면 이미 저장된 행을 읽지 못하게 된다 결합을 만들지 않으려고 컬럼 안의 key 이름을 여기서 명시적으로 고정한다.
*/
final class StudioJson {
private final ObjectMapper mapper;
StudioJson(ObjectMapper mapper) {
this.mapper = mapper;
}
String orderedTextToJson(List<OrderedTextView> items) {
ArrayNode array = mapper.createArrayNode();
for (OrderedTextView item : items) {
ObjectNode node = array.addObject();
node.put("id", item.id() == null ? null : item.id().toString());
node.put("text", item.text());
node.put("order", item.order());
}
return write(array);
}
List<OrderedTextView> orderedTextFromJson(String json) {
return read(json)
.valueStream()
.map(
node ->
new OrderedTextView(
uuid(node, "id"), text(node, "text"), node.path("order").asInt(0)))
.sorted(java.util.Comparator.comparingInt(OrderedTextView::order))
.toList();
}
String rulesToJson(List<ReferenceRuleView> items) {
ArrayNode array = mapper.createArrayNode();
for (ReferenceRuleView item : items) {
ObjectNode node = array.addObject();
node.put("id", item.id() == null ? null : item.id().toString());
node.put("title", item.title());
node.put("body", item.body());
node.put("order", item.order());
}
return write(array);
}
List<ReferenceRuleView> rulesFromJson(String json) {
return read(json)
.valueStream()
.map(
node ->
new ReferenceRuleView(
uuid(node, "id"),
text(node, "title"),
text(node, "body"),
node.path("order").asInt(0)))
.sorted(java.util.Comparator.comparingInt(ReferenceRuleView::order))
.toList();
}
String optionsToJson(List<QuestionOptionView> items) {
ArrayNode array = mapper.createArrayNode();
for (QuestionOptionView item : items) {
ObjectNode node = array.addObject();
node.put("id", item.id() == null ? null : item.id().toString());
node.put("title", item.title());
node.put("description", item.description());
node.put("order", item.order());
}
return write(array);
}
List<QuestionOptionView> optionsFromJson(String json) {
return read(json)
.valueStream()
.map(
node ->
new QuestionOptionView(
uuid(node, "id"),
text(node, "title"),
text(node, "description"),
node.path("order").asInt(0)))
.sorted(java.util.Comparator.comparingInt(QuestionOptionView::order))
.toList();
}
private String write(ArrayNode array) {
try {
return mapper.writeValueAsString(array);
} catch (JacksonException e) {
throw new MappingException("failed to serialise a Studio jsonb column", e);
}
}
private JsonNode read(String json) {
if (json == null || json.isBlank()) {
return mapper.createArrayNode();
}
try {
JsonNode node = mapper.readTree(json);
return node.isArray() ? node : mapper.createArrayNode();
} catch (JacksonException e) {
throw new MappingException("failed to read a Studio jsonb column", e);
}
}
private static UUID uuid(JsonNode node, String field) {
String value = node.path(field).asString(null);
return (value == null || value.isBlank()) ? null : UUID.fromString(value);
}
private static String text(JsonNode node, String field) {
return node.path(field).asString("");
}
}
@@ -0,0 +1,89 @@
package dev.caskeleton.adapter.outbound.persistence.techlog.workingcopy;
import dev.caskeleton.application.techlog.studio.model.RecordKind;
import dev.caskeleton.application.techlog.studio.model.RelationView;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import java.util.function.Supplier;
import org.springframework.jdbc.core.simple.JdbcClient;
/**
* 유형이 공유하는 편집용 관계({@code studio_relation}) 읽고 통째로 교체한다.
*
* <p>부분 갱신이 아니라 교체인 이유: 계약의 {@code relations} 배열 전체가 편집 대상이고, 클라이언트가 보낸 배열이 최종 상태다. 지운 줄을 알아내려고
* diff를 뜨면 순서 재배열과 삭제를 구분하지 못한다.
*/
final class StudioRelationStore {
private final JdbcClient jdbcClient;
private final Supplier<UUID> idGenerator;
StudioRelationStore(JdbcClient jdbcClient, Supplier<UUID> idGenerator) {
this.jdbcClient = jdbcClient;
this.idGenerator = idGenerator;
}
List<RelationView> findBySource(RecordKind kind, UUID sourceId) {
return jdbcClient
.sql(
"SELECT id, target_id, reason, display_order FROM studio_relation "
+ "WHERE source_kind = :kind AND source_id = :sourceId ORDER BY display_order")
.param("kind", kind.name())
.param("sourceId", sourceId)
.query(
(rs, rowNum) ->
new RelationView(
rs.getObject("id", UUID.class),
rs.getObject("target_id", UUID.class),
rs.getString("reason"),
rs.getInt("display_order")))
.list();
}
void replace(RecordKind kind, UUID sourceId, List<RelationView> relations) {
// source 지금 소유한 관계 id 집합. 클라이언트가 보낸 id 여기 있는 것만 유지한다.
Set<UUID> owned =
Set.copyOf(
jdbcClient
.sql(
"SELECT id FROM studio_relation "
+ "WHERE source_kind = :kind AND source_id = :sourceId")
.param("kind", kind.name())
.param("sourceId", sourceId)
.query(UUID.class)
.list());
jdbcClient
.sql("DELETE FROM studio_relation WHERE source_kind = :kind AND source_id = :sourceId")
.param("kind", kind.name())
.param("sourceId", sourceId)
.update();
for (RelationView relation : relations) {
// source 원래 갖고 있던 id 그대로 둔다 편집기가 줄을 식별하는 키라 저장마다 바뀌면
// 화면의 줄이 통째로 갈아엎어진 것처럼 보인다. 밖의 id(남의 문서 것이거나 클라이언트가 지어낸
// ) 신뢰하지 않고 새로 부여한다 그대로 쓰면 다른 문서의 관계 행과 PK 충돌한다.
UUID id =
(relation.id() != null && owned.contains(relation.id()))
? relation.id()
: idGenerator.get();
jdbcClient
.sql(
"INSERT INTO studio_relation "
+ "(id, source_kind, source_id, target_id, reason, display_order) "
+ "VALUES (:id, :kind, :sourceId, :targetId, :reason, :order)")
.param("id", id)
.param("kind", kind.name())
.param("sourceId", sourceId)
.param("targetId", relation.targetId())
.param("reason", relation.reason() == null ? "" : relation.reason())
.param("order", relation.order())
.update();
}
}
void deleteBySource(RecordKind kind, UUID sourceId) {
replace(kind, sourceId, List.of());
}
}
@@ -0,0 +1,49 @@
package dev.caskeleton.adapter.outbound.persistence.techlog.workingcopy;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Timestamp;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneOffset;
/** 계약 타입과 컬럼 타입 사이의 되풀이되는 변환. */
final class StudioSqlSupport {
private StudioSqlSupport() {}
/**
* 계약의 slug("아직 정하지 않음") NULL로 옮긴다. 문자열을 그대로 넣으면 {@code ck_document_slug_non_blank} 계열 제약에
* 걸리고, 무엇보다 여러 초안이 같은 slug를 갖는 순간 slug UNIQUE 제약이 번째 초안을 거부한다.
*/
static String slugToColumn(String slug) {
return (slug == null || slug.isBlank()) ? null : slug;
}
/** 반대 방향. 계약은 slug가 null이 아니라 빈 문자열이어야 한다고 정한다. */
static String slugFromColumn(String slug) {
return slug == null ? "" : slug;
}
static String orEmpty(String value) {
return value == null ? "" : value;
}
/**
* 계약의 {@code format: date} {@code timestamptz} 컬럼에 담는다. 자정 UTC로 고정한다 저장 시각의 로컬 타임존을 쓰면 같은 날짜가
* 서버 위치에 따라 다른 순간이 되고, 다시 읽을 하루가 밀린다.
*/
static Timestamp dateToTimestamp(LocalDate date) {
return date == null ? null : Timestamp.from(date.atStartOfDay(ZoneOffset.UTC).toInstant());
}
static LocalDate dateFromTimestamp(ResultSet rs, String column) throws SQLException {
Timestamp value = rs.getTimestamp(column);
return value == null ? null : value.toInstant().atZone(ZoneOffset.UTC).toLocalDate();
}
static Instant instant(ResultSet rs, String column) throws SQLException {
Timestamp value = rs.getTimestamp(column);
return value == null ? null : value.toInstant();
}
}
@@ -0,0 +1,779 @@
-- Tech Log 코어 스키마.
-- 원본: tech-log-design-package/database/V1__init.sql
-- (branch feature/response-envelope-adr-006, HEAD b20d7a2 — 이 파일은 그 이후 바뀌지 않았다)
--
-- 원본 DDL과의 차이:
-- 1. `CREATE SCHEMA IF NOT EXISTS tech_log;` / `SET search_path TO tech_log, public;` 제거.
-- 이 저장소의 기존 마이그레이션(V1/V3/V4/V5/V6)과 JPA 설정(@EntityScan, @EnableJpaRepositories,
-- PostgreSqlPersistenceConfig의 FlywayConfigurationCustomizer)은 모두 기본 public 스키마를
-- 전제한다. tech_log 전용 스키마로 옮기면 Task 9 이후의 JPA 엔티티가 이 테이블들을 찾지
-- 못한다. 그래서 테이블은 이 저장소의 다른 모든 테이블과 마찬가지로 public 스키마에 만든다.
-- 2. `studio_idempotency` 테이블과 전용 인덱스(`idx_studio_idempotency_expiry`)를 제외한다.
-- 기존 `idempotency_record`를 재사용한다 (spec D5).
-- 3. `release` / `site_config` / `profile_page` / `home_focus_config` /
-- `topic_featured_document` / `project_topic` 테이블과 전용 인덱스(`uq_topic_start_here`)를
-- 제외한다. 이번 범위 밖이다 (spec §2.2). site_config/profile_page/home_focus_config를
-- 시딩하던 마지막 INSERT 구문도 대상 테이블이 없으므로 함께 제외했다.
-- 4. 그 밖의 테이블·컬럼·CHECK·UNIQUE·인덱스·주석·순서는 원본을 그대로 보존한다. 순환 FK
-- `publication.latest_event_id` -> `publication_event.publication_id`의
-- `DEFERRABLE INITIALLY DEFERRED`도 그대로 유지한다 — 즉시 검사로 바꾸면 첫 게시가
-- 구조적으로 불가능해진다.
-- Tech Log initial PostgreSQL schema
-- Target: PostgreSQL 16+
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE EXTENSION IF NOT EXISTS pgcrypto;
-- ---------------------------------------------------------------------------
-- Taxonomy and assets
-- ---------------------------------------------------------------------------
CREATE TABLE topic (
id uuid PRIMARY KEY,
name varchar(80) NOT NULL,
normalized_name varchar(80) NOT NULL,
slug varchar(100) NOT NULL,
description varchar(600),
scope text,
status varchar(20) NOT NULL DEFAULT 'ACTIVE'
CHECK (status IN ('ACTIVE', 'ARCHIVED')),
version bigint NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
created_by varchar(255) NOT NULL,
updated_at timestamptz NOT NULL DEFAULT now(),
updated_by varchar(255) NOT NULL,
CONSTRAINT uq_topic_normalized_name UNIQUE (normalized_name),
CONSTRAINT uq_topic_slug UNIQUE (slug)
);
CREATE TABLE tag (
id uuid PRIMARY KEY,
name varchar(40) NOT NULL,
normalized_name varchar(40) NOT NULL,
slug varchar(60) NOT NULL,
version bigint NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
created_by varchar(255) NOT NULL,
updated_at timestamptz NOT NULL DEFAULT now(),
updated_by varchar(255) NOT NULL,
CONSTRAINT uq_tag_normalized_name UNIQUE (normalized_name),
CONSTRAINT uq_tag_slug UNIQUE (slug)
);
-- asset_key는 Public content가 참조하는 안정적인 key다.
-- object_key(스토리지 경로)와 분리되며 immutable이다.
--
-- asset_key -> Asset lookup -> current approved delivery path
--
-- 콘텐츠 원문에 object storage URL을 직접 영속하지 않는다.
-- 공개 이력이 있는 asset_key의 재사용 금지는 application rule로 강제한다.
-- (DB는 현재 행의 유일성만 보장한다.)
CREATE TABLE asset (
id uuid PRIMARY KEY,
asset_key varchar(200) NOT NULL,
asset_kind varchar(20) NOT NULL
CHECK (asset_kind IN ('IMAGE', 'DIAGRAM', 'ATTACHMENT')),
management_status varchar(20) NOT NULL
CHECK (management_status IN ('READY', 'ARCHIVED', 'REJECTED', 'QUARANTINED')),
object_key varchar(500) NOT NULL,
original_name varchar(255) NOT NULL,
display_name varchar(255),
content_type varchar(150) NOT NULL,
size_bytes bigint NOT NULL CHECK (size_bytes >= 0),
width integer CHECK (width IS NULL OR width > 0),
height integer CHECK (height IS NULL OR height > 0),
checksum_sha256 char(64) NOT NULL,
alt_text varchar(300),
decorative boolean NOT NULL DEFAULT false,
first_published_at timestamptz,
version bigint NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
created_by varchar(255) NOT NULL,
updated_at timestamptz NOT NULL DEFAULT now(),
updated_by varchar(255) NOT NULL,
CONSTRAINT uq_asset_key UNIQUE (asset_key),
CONSTRAINT uq_asset_object_key UNIQUE (object_key)
);
-- alt/decorative는 DB CHECK로 강제하지 않는다.
--
-- 업로드 시점에는 alt를 아직 정하지 않을 수 있어야 하고(Asset Picker에서 이후 수정),
-- 최종 판단은 syntax parser가 아니라 Publication Validation이 한다.
--
-- Asset decorative=false + 사용 위치 alt 비어 있음 -> PublishValidationFailed
-- Asset decorative=true -> alt="" 허용
--
-- 즉 판단 대상은 asset.alt_text 자체가 아니라 "해당 사용 위치의 alt"다.
-- 같은 Asset이 문서마다 다른 alt로 쓰일 수 있으므로 행 단위 CHECK로 표현할 수 없다.
-- ---------------------------------------------------------------------------
-- Knowledge documents
-- ---------------------------------------------------------------------------
CREATE TABLE document (
id uuid PRIMARY KEY,
document_type varchar(20) NOT NULL
CHECK (document_type IN ('CASE', 'REFERENCE')),
slug varchar(180),
title varchar(180) NOT NULL,
body_markdown text NOT NULL DEFAULT '',
content_format varchar(20) NOT NULL DEFAULT 'MARKDOWN'
CHECK (content_format IN ('MARKDOWN')),
content_format_version smallint NOT NULL DEFAULT 1
CHECK (content_format_version > 0),
workflow_status varchar(20) NOT NULL DEFAULT 'DRAFT'
CHECK (workflow_status IN ('DRAFT', 'IN_REVIEW', 'PUBLISHED', 'ARCHIVED')),
target_visibility varchar(20) NOT NULL DEFAULT 'PRIVATE'
CHECK (target_visibility IN ('PRIVATE', 'UNLISTED', 'PUBLIC')),
primary_topic_id uuid REFERENCES topic(id),
cover_asset_id uuid REFERENCES asset(id),
last_verified_at timestamptz,
first_published_at timestamptz,
last_published_at timestamptz,
version bigint NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
created_by varchar(255) NOT NULL,
updated_at timestamptz NOT NULL DEFAULT now(),
updated_by varchar(255) NOT NULL,
CONSTRAINT uq_document_id_type UNIQUE (id, document_type),
CONSTRAINT uq_document_type_slug UNIQUE (document_type, slug),
CONSTRAINT ck_document_slug_non_blank CHECK (slug IS NULL OR length(trim(slug)) > 0),
CONSTRAINT ck_document_publish_time_order CHECK (
first_published_at IS NULL
OR last_published_at IS NULL
OR first_published_at <= last_published_at
)
);
CREATE TABLE case_detail (
document_id uuid PRIMARY KEY,
document_type varchar(20) NOT NULL DEFAULT 'CASE'
CHECK (document_type = 'CASE'),
problem_summary varchar(600) NOT NULL DEFAULT '',
conclusion_summary varchar(600) NOT NULL DEFAULT '',
environment_items jsonb NOT NULL DEFAULT '[]'::jsonb
CHECK (jsonb_typeof(environment_items) = 'array'),
CONSTRAINT fk_case_detail_document
FOREIGN KEY (document_id, document_type)
REFERENCES document(id, document_type)
ON DELETE CASCADE
);
CREATE TABLE reference_detail (
document_id uuid PRIMARY KEY,
document_type varchar(20) NOT NULL DEFAULT 'REFERENCE'
CHECK (document_type = 'REFERENCE'),
scope_summary varchar(600) NOT NULL DEFAULT '',
applies_to jsonb NOT NULL DEFAULT '[]'::jsonb
CHECK (jsonb_typeof(applies_to) = 'array'),
excluded_scope jsonb NOT NULL DEFAULT '[]'::jsonb
CHECK (jsonb_typeof(excluded_scope) = 'array'),
freshness_status varchar(20) NOT NULL DEFAULT 'CURRENT'
CHECK (freshness_status IN ('CURRENT', 'REVIEW_DUE', 'HISTORICAL')),
CONSTRAINT fk_reference_detail_document
FOREIGN KEY (document_id, document_type)
REFERENCES document(id, document_type)
ON DELETE CASCADE
);
CREATE TABLE document_tag (
document_id uuid NOT NULL REFERENCES document(id) ON DELETE CASCADE,
tag_id uuid NOT NULL REFERENCES tag(id),
display_order integer NOT NULL CHECK (display_order >= 0),
PRIMARY KEY (document_id, tag_id),
CONSTRAINT uq_document_tag_order UNIQUE (document_id, display_order)
);
CREATE TABLE document_relation (
source_document_id uuid NOT NULL REFERENCES document(id) ON DELETE CASCADE,
target_document_id uuid NOT NULL REFERENCES document(id),
relation_type varchar(30) NOT NULL
CHECK (relation_type IN ('RELATED', 'DERIVED_FROM', 'SUPERSEDES')),
display_order integer NOT NULL DEFAULT 0 CHECK (display_order >= 0),
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (source_document_id, target_document_id, relation_type),
CONSTRAINT ck_document_relation_not_self CHECK (source_document_id <> target_document_id)
);
-- ---------------------------------------------------------------------------
-- Open questions
-- ---------------------------------------------------------------------------
CREATE TABLE open_question (
id uuid PRIMARY KEY,
slug varchar(180),
question varchar(300) NOT NULL,
summary varchar(600),
context_markdown text NOT NULL DEFAULT '',
importance_markdown text NOT NULL DEFAULT '',
next_verification text,
question_status varchar(20) NOT NULL DEFAULT 'OPEN'
CHECK (question_status IN ('OPEN', 'INVESTIGATING', 'PAUSED', 'RESOLVED', 'ARCHIVED')),
target_visibility varchar(20) NOT NULL DEFAULT 'PRIVATE'
CHECK (target_visibility IN ('PRIVATE', 'UNLISTED', 'PUBLIC')),
primary_topic_id uuid REFERENCES topic(id),
resolution_type varchar(30)
CHECK (resolution_type IS NULL OR resolution_type IN (
'DECISION_MADE',
'ASSUMPTION_REJECTED',
'QUESTION_REFRAMED',
'NO_LONGER_RELEVANT'
)),
resolution_summary text,
opened_at timestamptz NOT NULL DEFAULT now(),
resolved_at timestamptz,
first_published_at timestamptz,
last_published_at timestamptz,
version bigint NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
created_by varchar(255) NOT NULL,
updated_at timestamptz NOT NULL DEFAULT now(),
updated_by varchar(255) NOT NULL,
CONSTRAINT uq_open_question_slug UNIQUE (slug),
CONSTRAINT ck_question_resolution_consistency CHECK (
(question_status = 'RESOLVED'
AND resolution_type IS NOT NULL
AND resolution_summary IS NOT NULL
AND resolved_at IS NOT NULL)
OR
(question_status <> 'RESOLVED')
)
);
CREATE TABLE question_point (
id uuid PRIMARY KEY,
question_id uuid NOT NULL REFERENCES open_question(id) ON DELETE CASCADE,
point_kind varchar(20) NOT NULL
CHECK (point_kind IN ('FACT', 'ASSUMPTION', 'UNKNOWN', 'CONSTRAINT')),
content text NOT NULL CHECK (length(trim(content)) > 0),
display_order integer NOT NULL CHECK (display_order >= 0),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT uq_question_point_order UNIQUE (question_id, point_kind, display_order)
);
CREATE TABLE question_update (
id uuid PRIMARY KEY,
question_id uuid NOT NULL REFERENCES open_question(id) ON DELETE CASCADE,
update_type varchar(30) NOT NULL
CHECK (update_type IN (
'OBSERVATION',
'EVIDENCE',
'SCOPE_CHANGE',
'BLOCKER',
'NEXT_STEP',
'RESOLUTION',
'RESOLUTION_REOPENED'
)),
title varchar(180) NOT NULL,
body_markdown text NOT NULL DEFAULT '',
update_visibility varchar(20) NOT NULL DEFAULT 'PRIVATE'
CHECK (update_visibility IN ('PRIVATE', 'PUBLIC')),
sequence_no integer NOT NULL CHECK (sequence_no > 0),
occurred_at timestamptz NOT NULL,
version bigint NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
created_by varchar(255) NOT NULL,
updated_at timestamptz NOT NULL DEFAULT now(),
updated_by varchar(255) NOT NULL,
CONSTRAINT uq_question_update_sequence UNIQUE (question_id, sequence_no)
);
CREATE TABLE question_tag (
question_id uuid NOT NULL REFERENCES open_question(id) ON DELETE CASCADE,
tag_id uuid NOT NULL REFERENCES tag(id),
display_order integer NOT NULL CHECK (display_order >= 0),
PRIMARY KEY (question_id, tag_id),
CONSTRAINT uq_question_tag_order UNIQUE (question_id, display_order)
);
CREATE TABLE question_document_link (
question_id uuid NOT NULL REFERENCES open_question(id) ON DELETE CASCADE,
document_id uuid NOT NULL REFERENCES document(id),
relation_type varchar(30) NOT NULL
CHECK (relation_type IN ('RESULT_CASE', 'DERIVED_REFERENCE', 'RELATED')),
display_order integer NOT NULL DEFAULT 0 CHECK (display_order >= 0),
PRIMARY KEY (question_id, document_id, relation_type)
);
CREATE UNIQUE INDEX uq_question_result_case
ON question_document_link(question_id)
WHERE relation_type = 'RESULT_CASE';
-- ---------------------------------------------------------------------------
-- Projects, decisions, activities
-- ---------------------------------------------------------------------------
CREATE TABLE project (
id uuid PRIMARY KEY,
slug varchar(180),
name varchar(180) NOT NULL,
one_line_purpose varchar(600) NOT NULL DEFAULT '',
purpose_markdown text NOT NULL DEFAULT '',
boundary_markdown text NOT NULL DEFAULT '',
system_overview_markdown text NOT NULL DEFAULT '',
phase varchar(30) NOT NULL DEFAULT 'RESEARCH'
CHECK (phase IN (
'RESEARCH',
'DESIGN',
'IMPLEMENTATION',
'VERIFICATION',
'MAINTENANCE',
'PAUSED',
'COMPLETED'
)),
current_objective text,
next_step text,
technology_labels jsonb NOT NULL DEFAULT '[]'::jsonb
CHECK (jsonb_typeof(technology_labels) = 'array'),
workflow_status varchar(20) NOT NULL DEFAULT 'DRAFT'
CHECK (workflow_status IN ('DRAFT', 'PUBLISHED', 'ARCHIVED')),
target_visibility varchar(20) NOT NULL DEFAULT 'PRIVATE'
CHECK (target_visibility IN ('PRIVATE', 'UNLISTED', 'PUBLIC')),
featured_order integer,
first_published_at timestamptz,
last_published_at timestamptz,
version bigint NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
created_by varchar(255) NOT NULL,
updated_at timestamptz NOT NULL DEFAULT now(),
updated_by varchar(255) NOT NULL,
CONSTRAINT uq_project_slug UNIQUE (slug),
CONSTRAINT ck_project_featured_order CHECK (featured_order IS NULL OR featured_order >= 0)
);
CREATE TABLE project_document_link (
project_id uuid NOT NULL REFERENCES project(id) ON DELETE CASCADE,
document_id uuid NOT NULL REFERENCES document(id),
relation_type varchar(20) NOT NULL
CHECK (relation_type IN ('PRIMARY', 'RELATED')),
featured_order integer,
PRIMARY KEY (project_id, document_id),
CONSTRAINT ck_project_document_featured_order CHECK (featured_order IS NULL OR featured_order >= 0)
);
CREATE UNIQUE INDEX uq_document_primary_project
ON project_document_link(document_id)
WHERE relation_type = 'PRIMARY';
CREATE TABLE project_question_link (
project_id uuid NOT NULL REFERENCES project(id) ON DELETE CASCADE,
question_id uuid NOT NULL REFERENCES open_question(id),
relation_type varchar(20) NOT NULL
CHECK (relation_type IN ('PRIMARY', 'RELATED')),
featured_order integer,
PRIMARY KEY (project_id, question_id),
CONSTRAINT ck_project_question_featured_order CHECK (featured_order IS NULL OR featured_order >= 0)
);
CREATE UNIQUE INDEX uq_question_primary_project
ON project_question_link(question_id)
WHERE relation_type = 'PRIMARY';
CREATE TABLE project_decision (
id uuid PRIMARY KEY,
project_id uuid NOT NULL REFERENCES project(id) ON DELETE CASCADE,
statement varchar(1000) NOT NULL,
rationale_markdown text NOT NULL DEFAULT '',
consequences jsonb NOT NULL DEFAULT '[]'::jsonb
CHECK (jsonb_typeof(consequences) = 'array'),
alternatives_markdown text NOT NULL DEFAULT '',
decision_status varchar(20) NOT NULL DEFAULT 'PROPOSED'
CHECK (decision_status IN ('PROPOSED', 'ACCEPTED', 'SUPERSEDED', 'REJECTED')),
target_visibility varchar(20) NOT NULL DEFAULT 'PRIVATE'
CHECK (target_visibility IN ('PRIVATE', 'PUBLIC')),
source_question_id uuid REFERENCES open_question(id),
source_case_id uuid REFERENCES document(id),
superseded_by_id uuid,
is_featured boolean NOT NULL DEFAULT false,
decided_at timestamptz,
version bigint NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
created_by varchar(255) NOT NULL,
updated_at timestamptz NOT NULL DEFAULT now(),
updated_by varchar(255) NOT NULL,
CONSTRAINT fk_project_decision_superseded_by
FOREIGN KEY (superseded_by_id)
REFERENCES project_decision(id),
CONSTRAINT ck_project_decision_not_self_supersede
CHECK (superseded_by_id IS NULL OR superseded_by_id <> id),
CONSTRAINT ck_project_decision_status_fields CHECK (
decision_status NOT IN ('ACCEPTED', 'SUPERSEDED')
OR decided_at IS NOT NULL
),
CONSTRAINT ck_project_decision_supersede_target CHECK (
decision_status <> 'SUPERSEDED'
OR superseded_by_id IS NOT NULL
)
);
CREATE UNIQUE INDEX uq_project_featured_decision
ON project_decision(project_id)
WHERE is_featured = true;
CREATE TABLE project_activity (
id uuid PRIMARY KEY,
project_id uuid NOT NULL REFERENCES project(id) ON DELETE CASCADE,
activity_type varchar(40) NOT NULL
CHECK (activity_type IN (
'PHASE_CHANGED',
'QUESTION_OPENED',
'QUESTION_RESOLVED',
'DECISION_ACCEPTED',
'CASE_PUBLISHED',
'REFERENCE_PUBLISHED',
'MILESTONE_REACHED',
'PROJECT_PAUSED',
'PROJECT_RESUMED'
)),
title varchar(180) NOT NULL,
summary varchar(600),
visibility varchar(20) NOT NULL DEFAULT 'PRIVATE'
CHECK (visibility IN ('PRIVATE', 'PUBLIC')),
origin varchar(20) NOT NULL
CHECK (origin IN ('AUTO', 'MANUAL')),
related_resource_type varchar(30),
related_resource_id uuid,
occurred_at timestamptz NOT NULL,
operation_key varchar(180),
version bigint NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
created_by varchar(255) NOT NULL,
updated_at timestamptz NOT NULL DEFAULT now(),
updated_by varchar(255) NOT NULL,
CONSTRAINT uq_project_activity_operation_key UNIQUE (project_id, operation_key)
);
-- ---------------------------------------------------------------------------
-- Publication and public read model
-- ---------------------------------------------------------------------------
CREATE TABLE public_resource_projection (
resource_type varchar(30) NOT NULL
CHECK (resource_type IN (
'CASE',
'REFERENCE',
'QUESTION',
'PROJECT',
'PROJECT_DECISION',
'PROJECT_ACTIVITY',
'RELEASE',
'PROFILE'
)),
resource_id uuid NOT NULL,
source_version bigint NOT NULL CHECK (source_version >= 0),
publication_state varchar(20) NOT NULL
CHECK (publication_state IN ('ACTIVE', 'WITHDRAWN')),
visibility varchar(20) NOT NULL
CHECK (visibility IN ('PUBLIC', 'UNLISTED')),
title varchar(300) NOT NULL,
summary varchar(600),
state_code varchar(30),
primary_topic_id uuid REFERENCES topic(id),
payload_schema_version smallint NOT NULL CHECK (payload_schema_version > 0),
payload jsonb NOT NULL CHECK (jsonb_typeof(payload) = 'object'),
body_plain_text text NOT NULL DEFAULT '',
search_text text NOT NULL DEFAULT '',
content_hash char(64) NOT NULL,
published_at timestamptz NOT NULL,
updated_at timestamptz NOT NULL,
last_verified_at timestamptz,
latest_index_at timestamptz,
navigation_path varchar(500) NOT NULL,
PRIMARY KEY (resource_type, resource_id)
);
CREATE TABLE public_route (
resource_type varchar(30) NOT NULL,
slug varchar(180) NOT NULL,
resource_id uuid NOT NULL,
route_role varchar(20) NOT NULL
CHECK (route_role IN ('CANONICAL', 'ALIAS')),
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (resource_type, slug),
CONSTRAINT fk_public_route_projection
FOREIGN KEY (resource_type, resource_id)
REFERENCES public_resource_projection(resource_type, resource_id)
ON DELETE CASCADE
);
CREATE UNIQUE INDEX uq_public_route_canonical
ON public_route(resource_type, resource_id)
WHERE route_role = 'CANONICAL';
CREATE TABLE public_resource_tag (
resource_type varchar(30) NOT NULL,
resource_id uuid NOT NULL,
tag_id uuid NOT NULL REFERENCES tag(id),
display_order integer NOT NULL DEFAULT 0 CHECK (display_order >= 0),
PRIMARY KEY (resource_type, resource_id, tag_id),
CONSTRAINT fk_public_resource_tag_projection
FOREIGN KEY (resource_type, resource_id)
REFERENCES public_resource_projection(resource_type, resource_id)
ON DELETE CASCADE,
CONSTRAINT uq_public_resource_tag_order
UNIQUE (resource_type, resource_id, display_order)
);
CREATE TABLE public_resource_project_link (
resource_type varchar(30) NOT NULL,
resource_id uuid NOT NULL,
project_id uuid NOT NULL REFERENCES project(id),
relation_type varchar(20) NOT NULL
CHECK (relation_type IN ('PRIMARY', 'RELATED')),
featured_order integer,
PRIMARY KEY (resource_type, resource_id, project_id),
CONSTRAINT fk_public_resource_project_projection
FOREIGN KEY (resource_type, resource_id)
REFERENCES public_resource_projection(resource_type, resource_id)
ON DELETE CASCADE,
CONSTRAINT ck_public_project_featured_order CHECK (
featured_order IS NULL OR featured_order >= 0
)
);
CREATE UNIQUE INDEX uq_public_primary_project
ON public_resource_project_link(resource_type, resource_id)
WHERE relation_type = 'PRIMARY';
CREATE TABLE asset_reference (
asset_id uuid NOT NULL REFERENCES asset(id),
owner_type varchar(30) NOT NULL
CHECK (owner_type IN (
'DOCUMENT',
'QUESTION',
'QUESTION_UPDATE',
'PROJECT',
'DECISION',
'RELEASE',
'PROFILE',
'SITE'
)),
owner_id uuid NOT NULL,
reference_scope varchar(20) NOT NULL
CHECK (reference_scope IN ('WORKING', 'PUBLISHED')),
reference_role varchar(20) NOT NULL
CHECK (reference_role IN ('BODY', 'COVER', 'AVATAR', 'ATTACHMENT')),
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (asset_id, owner_type, owner_id, reference_scope, reference_role)
);
-- preview_token 테이블은 제거되었다.
-- Capability Token 기반 익명 Preview는 인증된 Preview Artifact(studio_preview)로
-- 대체되었다. contracts/openapi/preview-v1.deprecated.md 참고.
-- ---------------------------------------------------------------------------
-- Studio workflow artifacts
--
-- WorkingCopy는 API projection이므로 범용 working_copy 테이블을 만들지 않는다.
-- 아래 테이블은 편집 대상 자체가 아니라 "편집 흐름이 만들어내는 산출물"을 저장한다.
--
-- source_kind + source_id는 Studio API의 documentId를 가리킨다.
-- documentId는 source aggregate id를 그대로 사용하므로 별도 surrogate id가 없다.
-- ---------------------------------------------------------------------------
-- Validation은 일급 artifact다. 실행하고 버리는 결과가 아니라 특정 version을
-- 검증한 사실을 validation_id로 참조할 수 있어야 한다.
CREATE TABLE studio_validation (
validation_id uuid PRIMARY KEY,
source_kind varchar(30) NOT NULL
CHECK (source_kind IN ('CASE', 'REFERENCE', 'QUESTION', 'PROJECT_DECISION')),
source_id uuid NOT NULL,
validated_version bigint NOT NULL CHECK (validated_version >= 0),
status varchar(20) NOT NULL
CHECK (status IN ('INVALID', 'WARNINGS', 'VALID')),
issues jsonb NOT NULL DEFAULT '[]'::jsonb
CHECK (jsonb_typeof(issues) = 'array'),
-- 검증에 사용한 외부 의존 상태(Topic/Project publishability, relation target,
-- Asset READY/QUARANTINED, slug/route ownership, catalog revision,
-- renderer/content-format version)를 정규화한 hash.
-- Publish 시 다시 계산해 값이 다르면 VALIDATION_STALE로 거절한다.
dependency_revision varchar(200) NOT NULL,
validated_at timestamptz NOT NULL DEFAULT now(),
valid_until timestamptz NOT NULL,
created_by varchar(255) NOT NULL,
CONSTRAINT ck_studio_validation_window CHECK (valid_until > validated_at)
);
-- Preview는 저장된 version + validation + dependency revision을 묶어 만든
-- PublicRenderModel snapshot이다. 인증된 Studio API로만 조회한다.
-- CURRENT/STALE/EXPIRED 상태는 저장하지 않고 조회 시점에 서버가 계산한다.
CREATE TABLE studio_preview (
preview_id uuid PRIMARY KEY,
source_kind varchar(30) NOT NULL
CHECK (source_kind IN ('CASE', 'REFERENCE', 'QUESTION', 'PROJECT_DECISION')),
source_id uuid NOT NULL,
source_version bigint NOT NULL CHECK (source_version >= 0),
validation_id uuid NOT NULL REFERENCES studio_validation(validation_id),
dependency_revision varchar(200) NOT NULL,
render_model jsonb NOT NULL CHECK (jsonb_typeof(render_model) = 'object'),
created_at timestamptz NOT NULL DEFAULT now(),
expires_at timestamptz NOT NULL,
created_by varchar(255) NOT NULL,
CONSTRAINT ck_studio_preview_expiry CHECK (expires_at > created_at)
);
-- ---------------------------------------------------------------------------
-- Publication aggregate, immutable history, immutable snapshot
--
-- 세 개념을 분리한다.
--
-- publication 현재 게시 상태
-- publication_event 게시/재게시/게시 취소 불변 이력
-- publication_snapshot PUBLISHED/REPUBLISHED 시점의 불변 PublicRenderModel
--
-- public_resource_projection은 여전히 "현재 공개 상태"를 담당한다.
-- 과거 Snapshot을 현재 source나 현재 projection에서 재계산하지 않는다.
-- ---------------------------------------------------------------------------
CREATE TABLE publication (
publication_id uuid PRIMARY KEY,
source_kind varchar(30) NOT NULL
CHECK (source_kind IN ('CASE', 'REFERENCE', 'QUESTION', 'PROJECT_DECISION')),
source_id uuid NOT NULL,
status varchar(20) NOT NULL
CHECK (status IN ('PUBLISHED', 'UNPUBLISHED')),
published_version bigint NOT NULL CHECK (published_version >= 0),
-- Publication 자체의 optimistic concurrency 토큰.
-- unpublish는 expectedPublicationRevision으로 이 값을 검증한다.
publication_revision bigint NOT NULL DEFAULT 1 CHECK (publication_revision >= 1),
latest_event_id uuid NOT NULL,
public_path varchar(500) NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT uq_publication_source UNIQUE (source_kind, source_id)
);
CREATE TABLE publication_event (
publication_event_id uuid PRIMARY KEY,
publication_id uuid NOT NULL REFERENCES publication(publication_id),
source_kind varchar(30) NOT NULL
CHECK (source_kind IN ('CASE', 'REFERENCE', 'QUESTION', 'PROJECT_DECISION')),
source_id uuid NOT NULL,
event_type varchar(20) NOT NULL
CHECK (event_type IN ('PUBLISHED', 'REPUBLISHED', 'UNPUBLISHED')),
published_version bigint NOT NULL CHECK (published_version >= 0),
-- UNPUBLISHED Event는 자체 snapshot을 만들지 않고 마지막 공개 Snapshot을 참조한다.
source_published_event_id uuid REFERENCES publication_event(publication_event_id),
occurred_at timestamptz NOT NULL DEFAULT now(),
-- Publish 재시도가 중복 Event를 만들지 않도록 최초 요청의 idempotency key를 남긴다.
idempotency_key varchar(200),
created_by varchar(255) NOT NULL,
CONSTRAINT ck_publication_event_source_ref CHECK (
(event_type = 'UNPUBLISHED' AND source_published_event_id IS NOT NULL)
OR (event_type <> 'UNPUBLISHED' AND source_published_event_id IS NULL)
)
);
-- Event row는 생성 후 수정하지 않는다. UPDATE/DELETE 차단은 권한과 application
-- rule로 강제하며, 필요하면 운영에서 REVOKE UPDATE, DELETE로 보강한다.
-- 첫 게시는 publication(latest_event_id) -> publication_event -> publication UPDATE
-- 순서로 한 transaction 안에서 처리된다. 순환 참조를 허용하기 위해 지연 검사한다.
ALTER TABLE publication
ADD CONSTRAINT fk_publication_latest_event
FOREIGN KEY (latest_event_id)
REFERENCES publication_event(publication_event_id)
DEFERRABLE INITIALLY DEFERRED;
CREATE TABLE publication_snapshot (
publication_event_id uuid PRIMARY KEY
REFERENCES publication_event(publication_event_id),
render_model jsonb NOT NULL CHECK (jsonb_typeof(render_model) = 'object'),
content_format_version varchar(50) NOT NULL,
renderer_contract_version varchar(50) NOT NULL,
-- 게시 시점에 사용된 Asset의 assetKey/delivery path/치수를 고정한다.
-- 이후 Asset이 교체되어도 과거 Snapshot의 표현은 변하지 않는다.
asset_manifest jsonb NOT NULL DEFAULT '[]'::jsonb
CHECK (jsonb_typeof(asset_manifest) = 'array'),
created_at timestamptz NOT NULL DEFAULT now()
);
-- ---------------------------------------------------------------------------
-- Indexes
-- ---------------------------------------------------------------------------
CREATE INDEX idx_document_management
ON document(document_type, workflow_status, updated_at DESC);
CREATE INDEX idx_document_topic
ON document(primary_topic_id, document_type, updated_at DESC);
CREATE INDEX idx_question_status
ON open_question(question_status, updated_at DESC);
CREATE INDEX idx_question_topic
ON open_question(primary_topic_id, question_status, updated_at DESC);
CREATE INDEX idx_question_update_timeline
ON question_update(question_id, occurred_at ASC, sequence_no ASC);
CREATE INDEX idx_project_phase
ON project(phase, updated_at DESC);
CREATE INDEX idx_project_decision
ON project_decision(project_id, decision_status, decided_at DESC);
CREATE INDEX idx_project_activity
ON project_activity(project_id, visibility, occurred_at DESC);
CREATE INDEX idx_asset_status
ON asset(management_status, created_at DESC);
CREATE INDEX idx_asset_checksum
ON asset(checksum_sha256);
CREATE INDEX idx_asset_reference_owner
ON asset_reference(owner_type, owner_id, reference_scope);
CREATE INDEX idx_asset_reference_asset
ON asset_reference(asset_id, reference_scope);
CREATE INDEX idx_public_latest
ON public_resource_projection(latest_index_at DESC, resource_type, resource_id)
WHERE publication_state = 'ACTIVE'
AND visibility = 'PUBLIC'
AND latest_index_at IS NOT NULL;
CREATE INDEX idx_public_topic
ON public_resource_projection(primary_topic_id, resource_type, published_at DESC)
WHERE publication_state = 'ACTIVE'
AND visibility = 'PUBLIC';
CREATE INDEX idx_public_type
ON public_resource_projection(resource_type, visibility, published_at DESC)
WHERE publication_state = 'ACTIVE';
CREATE INDEX idx_public_projection_search_trgm
ON public_resource_projection
USING gin (search_text gin_trgm_ops)
WHERE publication_state = 'ACTIVE'
AND visibility = 'PUBLIC';
CREATE INDEX idx_public_project_link_lookup
ON public_resource_project_link(project_id, relation_type, resource_type);
-- Studio workflow artifacts -------------------------------------------------
-- 특정 version에 대한 최신 Validation 조회 (nextAction 계산의 핵심 경로)
CREATE INDEX idx_studio_validation_source
ON studio_validation(source_kind, source_id, validated_version, validated_at DESC);
-- 특정 version에 대한 최신 Preview 조회
CREATE INDEX idx_studio_preview_source
ON studio_preview(source_kind, source_id, source_version, created_at DESC);
-- 만료 Preview 정리 배치
CREATE INDEX idx_studio_preview_expiry
ON studio_preview(expires_at);
-- Publication history -------------------------------------------------------
-- 한 문서의 게시 이력 (occurredAt DESC, publicationEventId DESC 정렬 계약과 일치)
CREATE INDEX idx_publication_event_publication
ON publication_event(publication_id, occurred_at DESC, publication_event_id DESC);
-- 전체 게시 기록 화면과 source 기준 조회
CREATE INDEX idx_publication_event_source
ON publication_event(source_kind, source_id, occurred_at DESC);
@@ -0,0 +1,92 @@
-- Studio WorkingCopy 계약(studio-v1.yaml)이 요구하는 편집 필드를 네 source aggregate에 채운다.
--
-- 배경: V7(= 설계 패키지 database/V1__init.sql)의 물리 스키마는 유형마다 다른 모델을 갖는데,
-- Studio 계약은 네 유형을 공통 base(WorkingCopyInputBase) + 유형별 확장이라는 하나의 편집
-- 흐름으로 다룬다. 그 공통 base와 유형별 필드 중 V7에 대응 컬럼이 없는 것만 여기서 채운다.
-- 계약에 없는 V7 컬럼(environment_items, alternatives_markdown, freshness_status 등)은
-- 건드리지 않는다 — 설계 패키지의 정본 스키마이고 public-v1 구현이 쓸 수 있다.
--
-- ADR-003과 충돌하지 않는다. ADR-003이 금지한 것은 네 Aggregate를 하나의 범용
-- `working_copy` 테이블 + 통짜 JSON으로 뭉개는 것이다. 여기서는 각 Aggregate가 자기
-- 테이블을 그대로 소유한 채 필요한 컬럼만 얻는다.
-- ── 공통 base ───────────────────────────────────────────────────────────────
-- 계약 WorkingCopyInputBase.summary (maxLength 300). open_question.summary(varchar 600)과
-- project_decision(아래)에는 각각 대응 컬럼이 있거나 새로 만든다.
ALTER TABLE document ADD COLUMN summary varchar(300) NOT NULL DEFAULT '';
-- ── CASE ────────────────────────────────────────────────────────────────────
-- 계약의 problem/conclusion은 maxLength 100000인 본문이다. V7의 *_summary는 varchar(600)이라
-- 그대로 쓰면 잘린다 — text로 넓힌다(폭을 늘리는 변경이라 기존 행에 무손실).
ALTER TABLE case_detail ALTER COLUMN problem_summary TYPE text;
ALTER TABLE case_detail ALTER COLUMN conclusion_summary TYPE text;
-- environment_items(jsonb 배열)는 계약의 environment(단일 문자열)와 다른 모양이다.
-- 계약 쪽을 담을 컬럼을 따로 둔다. 기존 컬럼은 그대로 남긴다.
ALTER TABLE case_detail ADD COLUMN environment text NOT NULL DEFAULT '';
ALTER TABLE case_detail ADD COLUMN reproduction text NOT NULL DEFAULT '';
-- ── REFERENCE ───────────────────────────────────────────────────────────────
ALTER TABLE reference_detail ALTER COLUMN scope_summary TYPE text;
-- rules[] = ReferenceRule{id,title,body,order}, examples[] = OrderedText{id,text,order}.
-- applyWhen[]/exceptions[]는 기존 applies_to/excluded_scope를 그대로 쓴다.
ALTER TABLE reference_detail ADD COLUMN rules jsonb NOT NULL DEFAULT '[]'::jsonb
CHECK (jsonb_typeof(rules) = 'array');
ALTER TABLE reference_detail ADD COLUMN examples jsonb NOT NULL DEFAULT '[]'::jsonb
CHECK (jsonb_typeof(examples) = 'array');
-- ── QUESTION ────────────────────────────────────────────────────────────────
-- options[] = QuestionOption{id,title,description,order}.
-- facts/assumptions/unknowns/constraints는 기존 question_point(point_kind)로 간다.
ALTER TABLE open_question ADD COLUMN options jsonb NOT NULL DEFAULT '[]'::jsonb
CHECK (jsonb_typeof(options) = 'array');
-- QuestionResolution{summary, evidenceTargetId, linkLabel} 중 summary만 V7에 있다.
ALTER TABLE open_question ADD COLUMN resolution_evidence_target_id uuid;
ALTER TABLE open_question ADD COLUMN resolution_link_label varchar(120) NOT NULL DEFAULT '';
-- ── PROJECT_DECISION ────────────────────────────────────────────────────────
-- 계약은 PROJECT_DECISION도 다른 셋과 같은 공통 base(title/slug/summary/topicId)로 편집한다.
-- V7의 project_decision에는 statement/rationale/consequences만 있다.
ALTER TABLE project_decision ADD COLUMN title varchar(120) NOT NULL DEFAULT '';
ALTER TABLE project_decision ADD COLUMN slug varchar(180);
ALTER TABLE project_decision ADD COLUMN summary varchar(300) NOT NULL DEFAULT '';
ALTER TABLE project_decision ADD COLUMN primary_topic_id uuid REFERENCES topic(id);
-- 계약 statement는 maxLength 100000이다. varchar(1000)이면 잘린다.
ALTER TABLE project_decision ALTER COLUMN statement TYPE text;
-- 계약은 PROJECT_DECISION 의 projectId 를 "게시 시점에 non-null, 저장 시점에는 강제하지 않음"으로
-- 정한다(studio-v1.yaml WorkingCopyInputBase.projectId). V7 의 NOT NULL 은 그 초안 저장을
-- 구조적으로 불가능하게 만든다 — 게시 필수 여부는 검증이 판단하도록 컬럼 제약을 푼다.
ALTER TABLE project_decision ALTER COLUMN project_id DROP NOT NULL;
ALTER TABLE project_decision ADD CONSTRAINT uq_project_decision_slug UNIQUE (slug);
ALTER TABLE project_decision ADD CONSTRAINT ck_project_decision_slug_non_blank
CHECK (slug IS NULL OR length(trim(slug)) > 0);
-- ── relations ───────────────────────────────────────────────────────────────
-- 계약의 relations[]는 네 유형 공통 base에 있고 항목이 {id, targetId, reason, order}다.
-- V7의 document_relation은 (source, target, relation_type) 복합 PK라 항목 자체의 id도
-- reason도 없고 document끼리만 성립한다 — QUESTION/PROJECT_DECISION의 relations를 담을 수
-- 없다. 그래서 Studio 편집용 관계는 전용 테이블에 둔다. document_relation은 설계의 공개
-- 렌더링용 유형 관계로 그대로 남는다.
--
-- (source_kind, source_id) 다형 참조는 이 스키마가 studio_validation/studio_preview에서
-- 이미 쓰는 방식과 같다. 다형 참조라 FK를 걸 수 없으므로 부모 삭제 시 정리는 application이
-- 책임진다.
--
-- target_id가 nullable인 것은 계약(RelationInput.targetId: [string,"null"])을 따른 것이다 —
-- 아직 대상을 고르지 않은 관계 줄도 저장할 수 있어야 한다. 게시 가능 여부는 검증이 판단한다.
CREATE TABLE studio_relation (
id uuid PRIMARY KEY,
source_kind varchar(30) NOT NULL
CHECK (source_kind IN ('CASE', 'REFERENCE', 'QUESTION', 'PROJECT_DECISION')),
source_id uuid NOT NULL,
target_id uuid,
reason text NOT NULL DEFAULT '',
display_order integer NOT NULL CHECK (display_order >= 0),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT uq_studio_relation_order UNIQUE (source_kind, source_id, display_order),
CONSTRAINT ck_studio_relation_not_self CHECK (target_id IS NULL OR target_id <> source_id)
);
CREATE INDEX idx_studio_relation_source ON studio_relation (source_kind, source_id);
CREATE INDEX idx_studio_relation_target ON studio_relation (target_id)
WHERE target_id IS NOT NULL;
@@ -44,7 +44,7 @@ class PostgreSqlMigrationIntegrationTest {
.migrate(); .migrate();
assertThat(appliedVersions(postgres, "flyway_schema_history")) assertThat(appliedVersions(postgres, "flyway_schema_history"))
.containsExactly("1", "3", "4", "5", "6"); .containsExactly("1", "3", "4", "5", "6", "7");
Flyway coreStream = Flyway coreStream =
Flyway.configure() Flyway.configure()
@@ -0,0 +1,157 @@
package dev.caskeleton.adapter.outbound.persistence.techlog;
import static org.assertj.core.api.Assertions.assertThat;
import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
import java.sql.Connection;
import java.sql.ResultSet;
import java.util.ArrayList;
import java.util.List;
import javax.sql.DataSource;
import org.flywaydb.core.Flyway;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.testcontainers.DockerClientFactory;
import org.testcontainers.postgresql.PostgreSQLContainer;
/**
* V7이 실제 PostgreSQL에 적용되는지, 그리고 순환 FK가 deferrable로 선언됐는지 본다. H2로는 검증할 없다 deferrable 제약이 벤더 의미이기
* 때문이다.
*
* <p> 모듈(persistence-jpa)에는 {@code @SpringBootConfiguration} 없다 Boot 메인 클래스는 app-bootstrap 모듈에
* 있고, 모듈의 postgresqlIntegrationTest 소스셋 classpath에는 포함되지 않는다. 그래서 {@code @SpringBootTest} 컨텍스트를
* 띄울 없고, 패키지의 형제인 {@code readiness.PostgreSqlMigrationIntegrationTest} 같은 방식 Testcontainers
* 위에서 순수 Flyway API를 직접 구동 쓴다. 컨테이너는 매번 완전히 상태로 시작하므로, 기존 V1/V3/V4/V5/V6 다음에 V7이 얹히는 전체 체인이
* 클린 DB에 처음부터 적용되는 경로를 그대로 검증한다.
*/
class TechLogSchemaMigrationTest {
private static final String IMAGE =
System.getProperty("jpa.evidence.postgresql.image", "postgres:16-alpine");
private static PostgreSQLContainer postgres;
private static HikariDataSource dataSource;
@BeforeAll
static void migrateFreshDatabase() {
if (!DockerClientFactory.instance().isDockerAvailable()) {
throw new IllegalStateException(
"Docker is required for the Tech Log schema migration test; skipping is forbidden");
}
postgres = new PostgreSQLContainer(IMAGE).withReuse(false);
postgres.start();
HikariConfig config = new HikariConfig();
config.setJdbcUrl(postgres.getJdbcUrl());
config.setUsername(postgres.getUsername());
config.setPassword(postgres.getPassword());
config.setMaximumPoolSize(5);
config.setMinimumIdle(1);
dataSource = new HikariDataSource(config);
// classpath:db/migration/postgresql only the same location
// PostgreSqlPersistenceConfig's FlywayConfigurationCustomizer pins the application to. Using
// the full "classpath:db/migration" tree here would also pick up the unrelated jpa/* streams
// (each starting their own V1) and collide.
Flyway.configure()
.dataSource(dataSource)
.locations("classpath:db/migration/postgresql")
.table("flyway_schema_history")
.baselineOnMigrate(false)
.outOfOrder(false)
.load()
.migrate();
}
@AfterAll
static void stopPostgreSql() {
if (dataSource != null) {
dataSource.close();
}
if (postgres != null) {
postgres.stop();
}
}
@Test
void createsEveryTechLogTable() throws Exception {
List<String> expected =
List.of(
"topic",
"tag",
"document",
"case_detail",
"reference_detail",
"document_tag",
"document_relation",
"open_question",
"question_point",
"question_update",
"question_tag",
"question_document_link",
"project",
"project_decision",
"project_document_link",
"project_question_link",
"project_activity",
"asset",
"asset_reference",
"studio_validation",
"studio_preview",
"publication",
"publication_event",
"publication_snapshot",
"public_resource_projection",
"public_route",
"public_resource_tag",
"public_resource_project_link");
List<String> actual = new ArrayList<>();
try (Connection connection = dataSource().getConnection();
ResultSet rs =
connection
.createStatement()
.executeQuery(
"SELECT table_name FROM information_schema.tables WHERE table_schema = 'public'")) {
while (rs.next()) {
actual.add(rs.getString(1));
}
}
assertThat(actual).containsAll(expected);
}
@Test
void doesNotCreateAStudioIdempotencyTable() throws Exception {
try (Connection connection = dataSource().getConnection();
ResultSet rs =
connection
.createStatement()
.executeQuery(
"SELECT count(*) FROM information_schema.tables "
+ "WHERE table_schema = 'public' AND table_name = 'studio_idempotency'")) {
rs.next();
assertThat(rs.getInt(1)).isZero();
}
}
@Test
void publicationLatestEventForeignKeyIsDeferrable() throws Exception {
try (Connection connection = dataSource().getConnection();
ResultSet rs =
connection
.createStatement()
.executeQuery(
"SELECT condeferrable, condeferred FROM pg_constraint "
+ "WHERE conname = 'fk_publication_latest_event'")) {
assertThat(rs.next()).as("fk_publication_latest_event 제약이 있어야 한다").isTrue();
assertThat(rs.getBoolean(1)).as("deferrable").isTrue();
assertThat(rs.getBoolean(2)).as("initially deferred").isTrue();
}
}
private static DataSource dataSource() {
return dataSource;
}
}
@@ -0,0 +1,121 @@
package dev.caskeleton.adapter.outbound.persistence.techlog.query;
import static org.assertj.core.api.Assertions.assertThat;
import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
import dev.caskeleton.application.techlog.studio.query.CatalogEntryType;
import dev.caskeleton.application.techlog.studio.query.CatalogPageView;
import org.flywaydb.core.Flyway;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.testcontainers.DockerClientFactory;
import org.testcontainers.postgresql.PostgreSQLContainer;
/**
* 모듈(persistence-jpa)에는 {@code @SpringBootConfiguration} 없다 Boot 메인 클래스는 app-bootstrap 모듈에 있고,
* 모듈의 postgresqlIntegrationTest 소스셋 classpath에는 포함되지 않는다. 그래서 브리프의 {@code @SpringBootTest}로는
* 컨텍스트를 띄울 없다({@code TechLogSchemaMigrationTest} 같은 문제를 겪었다). 테스트도 같은 형제 패턴 Testcontainers
* 위에서 순수 Flyway로 V7까지 적용한 , {@code JdbcClient} 어댑터를 직접 조립 쓴다. Spring 컨테이너가 없어도 {@code
* JdbcCatalogQueryAdapter} 생성자 인자로 {@code JdbcClient} 하나만 받으므로 문제가 없다.
*/
class JdbcCatalogQueryAdapterTest {
private static final String IMAGE =
System.getProperty("jpa.evidence.postgresql.image", "postgres:16-alpine");
private static PostgreSQLContainer postgres;
private static HikariDataSource dataSource;
private static JdbcClient jdbcClient;
private static JdbcCatalogQueryAdapter adapter;
@BeforeAll
static void migrateFreshDatabase() {
if (!DockerClientFactory.instance().isDockerAvailable()) {
throw new IllegalStateException(
"Docker is required for the catalog query adapter test; skipping is forbidden");
}
postgres = new PostgreSQLContainer(IMAGE).withReuse(false);
postgres.start();
HikariConfig config = new HikariConfig();
config.setJdbcUrl(postgres.getJdbcUrl());
config.setUsername(postgres.getUsername());
config.setPassword(postgres.getPassword());
config.setMaximumPoolSize(5);
config.setMinimumIdle(1);
dataSource = new HikariDataSource(config);
Flyway.configure()
.dataSource(dataSource)
.locations("classpath:db/migration/postgresql")
.table("flyway_schema_history")
.baselineOnMigrate(false)
.outOfOrder(false)
.load()
.migrate();
jdbcClient = JdbcClient.create(dataSource);
adapter = new JdbcCatalogQueryAdapter(jdbcClient);
}
@AfterAll
static void stopPostgreSql() {
if (dataSource != null) {
dataSource.close();
}
if (postgres != null) {
postgres.stop();
}
}
@Test
void findsTopicsByPrefix() {
jdbcClient
.sql(
"INSERT INTO topic (id, name, normalized_name, slug, created_by, updated_by) "
+ "VALUES (gen_random_uuid(), 'Kafka', 'kafka', 'kafka', 'test', 'test')")
.update();
CatalogPageView page = adapter.search(CatalogEntryType.TOPIC, "kaf", null, 20);
assertThat(page.items()).hasSize(1);
assertThat(page.items().get(0).label()).isEqualTo("Kafka");
assertThat(page.items().get(0).dependencyRevision()).isNotBlank();
}
@Test
void returnsAnEmptyPageWhenNothingMatches() {
CatalogPageView page = adapter.search(CatalogEntryType.TOPIC, "zzzz-none", null, 20);
assertThat(page.items()).isEmpty();
assertThat(page.nextCursor()).isNull();
}
/**
* Review Important 1: {@code searchProjects} (JdbcCatalogQueryAdapter) had never run against a
* real database {@code project} has a different column shape than {@code topic} (slug/name/
* workflow_status vs. name/normalized_name/slug/status), so a column typo or bad bind would only
* have surfaced in production. {@code project}'s NOT-NULL-without-default columns are {@code id},
* {@code name}, {@code created_by}, {@code updated_by} (V7__techlog_core.sql CREATE TABLE
* project) everything else has a DEFAULT or is nullable, so the minimal INSERT below is valid.
*/
@Test
void findsProjectsByPrefix() {
jdbcClient
.sql(
"INSERT INTO project (id, name, created_by, updated_by) "
+ "VALUES (gen_random_uuid(), 'Payments Platform', 'test', 'test')")
.update();
CatalogPageView page = adapter.search(CatalogEntryType.PROJECT, "pay", null, 20);
assertThat(page.items()).hasSize(1);
assertThat(page.items().get(0).id()).isNotNull();
assertThat(page.items().get(0).label()).isEqualTo("Payments Platform");
assertThat(page.items().get(0).kind()).isEqualTo("PROJECT");
assertThat(page.items().get(0).dependencyRevision()).isNotBlank();
}
}
@@ -0,0 +1,716 @@
package dev.caskeleton.adapter.outbound.persistence.techlog.studio;
import static org.assertj.core.api.Assertions.assertThat;
import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
import dev.caskeleton.adapter.outbound.persistence.techlog.artifact.JdbcPreviewArtifactAdapter;
import dev.caskeleton.adapter.outbound.persistence.techlog.artifact.JdbcValidationArtifactAdapter;
import dev.caskeleton.adapter.outbound.persistence.techlog.query.JdbcDependencyRevisionAdapter;
import dev.caskeleton.adapter.outbound.persistence.techlog.query.JdbcStudioDashboardQueryAdapter;
import dev.caskeleton.adapter.outbound.persistence.techlog.query.JdbcStudioDependencyResolverAdapter;
import dev.caskeleton.adapter.outbound.persistence.techlog.query.JdbcStudioDocumentQueryAdapter;
import dev.caskeleton.adapter.outbound.persistence.techlog.studio.asset.JdbcAssetRepositoryAdapter;
import dev.caskeleton.adapter.outbound.persistence.techlog.studio.publication.JdbcPublicationHistoryQueryAdapter;
import dev.caskeleton.adapter.outbound.persistence.techlog.studio.publication.JdbcPublicationWriterAdapter;
import dev.caskeleton.adapter.outbound.persistence.techlog.workingcopy.JdbcWorkingCopyRepositoryAdapter;
import dev.caskeleton.application.techlog.studio.model.AssetKindView;
import dev.caskeleton.application.techlog.studio.model.AssetManagementStatusView;
import dev.caskeleton.application.techlog.studio.model.AssetView;
import dev.caskeleton.application.techlog.studio.model.DecisionStatusView;
import dev.caskeleton.application.techlog.studio.model.DocumentSort;
import dev.caskeleton.application.techlog.studio.model.NextAction;
import dev.caskeleton.application.techlog.studio.model.OrderedTextView;
import dev.caskeleton.application.techlog.studio.model.PublicPreviewView;
import dev.caskeleton.application.techlog.studio.model.PublicationAggregateStatus;
import dev.caskeleton.application.techlog.studio.model.PublishResultView;
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.ValidationReportView;
import dev.caskeleton.application.techlog.studio.model.ValidationStatus;
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 dev.caskeleton.application.techlog.studio.port.out.AssetRepositoryPort;
import dev.caskeleton.application.techlog.studio.port.out.PublicationWriterPort;
import dev.caskeleton.application.techlog.studio.port.out.StudioDependencyResolverPort;
import dev.caskeleton.application.techlog.studio.query.ListAssetsQuery;
import dev.caskeleton.application.techlog.studio.query.ListDocumentsQuery;
import dev.caskeleton.application.techlog.studio.query.ListPublicationsQuery;
import java.time.Instant;
import java.time.LocalDate;
import java.time.temporal.ChronoUnit;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import org.flywaydb.core.Flyway;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.testcontainers.DockerClientFactory;
import org.testcontainers.postgresql.PostgreSQLContainer;
import tools.jackson.databind.ObjectMapper;
/**
* Studio 영속 경로 전체를 실제 PostgreSQL 위에서 돌린다.
*
* <p> 테스트가 필요한 이유는 분명하다 저장소의 표준 {@code check} Testcontainers 통합 테스트를 돌리지 않으므로, 여기 있는 SQL
* 테스트 없이는 <b> 번도 실행되지 않은 </b> 통과한다. 컴파일과 단위 테스트는 컬럼 이름 오타도, jsonb 캐스팅 누락도, 순환 FK 지연 검사도 검증하지
* 못한다.
*
* <p>{@code JdbcCatalogQueryAdapterTest} 같은 형제 패턴을 쓴다 모듈에는 {@code @SpringBootConfiguration}
* 없으므로 Testcontainers + 순수 Flyway + 직접 조립이다.
*/
class StudioPersistenceIntegrationTest {
private static final String IMAGE =
System.getProperty("jpa.evidence.postgresql.image", "postgres:16-alpine");
private static PostgreSQLContainer postgres;
private static HikariDataSource dataSource;
private static JdbcClient jdbcClient;
private static JdbcWorkingCopyRepositoryAdapter workingCopies;
private static JdbcStudioDocumentQueryAdapter documents;
private static JdbcStudioDependencyResolverAdapter dependencies;
private static JdbcDependencyRevisionAdapter dependencyRevisions;
private static JdbcValidationArtifactAdapter validations;
private static JdbcPreviewArtifactAdapter previews;
private static JdbcPublicationWriterAdapter publicationWriter;
private static JdbcPublicationHistoryQueryAdapter publicationHistory;
private static JdbcStudioDashboardQueryAdapter dashboard;
private static JdbcAssetRepositoryAdapter assets;
private static org.springframework.transaction.support.TransactionTemplate transactions;
private static UUID topicId;
private static UUID projectId;
@BeforeAll
static void migrateFreshDatabase() {
if (!DockerClientFactory.instance().isDockerAvailable()) {
throw new IllegalStateException(
"Docker is required for the Studio persistence integration test; skipping is forbidden");
}
postgres = new PostgreSQLContainer(IMAGE).withReuse(false);
postgres.start();
HikariConfig config = new HikariConfig();
config.setJdbcUrl(postgres.getJdbcUrl());
config.setUsername(postgres.getUsername());
config.setPassword(postgres.getPassword());
config.setMaximumPoolSize(5);
config.setMinimumIdle(1);
dataSource = new HikariDataSource(config);
Flyway.configure()
.dataSource(dataSource)
.locations("classpath:db/migration/postgresql")
.baselineOnMigrate(false)
.outOfOrder(false)
.load()
.migrate();
jdbcClient = JdbcClient.create(dataSource);
ObjectMapper objectMapper = new ObjectMapper();
workingCopies = new JdbcWorkingCopyRepositoryAdapter(jdbcClient, objectMapper);
documents = new JdbcStudioDocumentQueryAdapter(jdbcClient);
dependencies = new JdbcStudioDependencyResolverAdapter(jdbcClient);
dependencyRevisions = new JdbcDependencyRevisionAdapter(jdbcClient);
validations = new JdbcValidationArtifactAdapter(jdbcClient, objectMapper);
previews = new JdbcPreviewArtifactAdapter(jdbcClient);
publicationWriter = new JdbcPublicationWriterAdapter(jdbcClient, objectMapper);
publicationHistory = new JdbcPublicationHistoryQueryAdapter(jdbcClient);
dashboard = new JdbcStudioDashboardQueryAdapter(jdbcClient);
assets = new JdbcAssetRepositoryAdapter(jdbcClient);
// 게시는 프로덕션에서 TransactionPort.inWrite 안에서 돈다. 순환 FK 지연 검사가 성립하려면
// 트랜잭션이 반드시 있어야 하므로 테스트도 같은 조건에서 호출한다.
transactions =
new org.springframework.transaction.support.TransactionTemplate(
new org.springframework.jdbc.support.JdbcTransactionManager(dataSource));
topicId = UUID.randomUUID();
jdbcClient
.sql(
"INSERT INTO topic (id, name, normalized_name, slug, created_by, updated_by)"
+ " VALUES (:id, 'Kafka', 'kafka', 'kafka', 'test', 'test')")
.param("id", topicId)
.update();
projectId = UUID.randomUUID();
jdbcClient
.sql(
"INSERT INTO project (id, slug, name, created_by, updated_by)"
+ " VALUES (:id, 'tech-log', 'Tech Log', 'test', 'test')")
.param("id", projectId)
.update();
}
@AfterAll
static void stopPostgreSql() {
if (dataSource != null) {
dataSource.close();
}
if (postgres != null) {
postgres.stop();
}
}
private static WorkingCopyBaseInput base(RecordKind kind, String title, String slug) {
return new WorkingCopyBaseInput(kind, title, slug, "요약", topicId, projectId, List.of());
}
@Test
void v8AddsEveryColumnTheStudioContractNeeds() {
List<String> caseColumns = columnsOf("case_detail");
assertThat(caseColumns).contains("environment", "reproduction");
assertThat(columnsOf("reference_detail")).contains("rules", "examples");
assertThat(columnsOf("open_question"))
.contains("options", "resolution_evidence_target_id", "resolution_link_label");
assertThat(columnsOf("project_decision"))
.contains("title", "slug", "summary", "primary_topic_id");
assertThat(columnsOf("document")).contains("summary");
assertThat(columnsOf("studio_relation")).contains("source_kind", "source_id", "target_id");
// 계약이 허용한 "프로젝트 없는 결정 초안" 저장이 가능해야 한다.
assertThat(isNullable("project_decision", "project_id")).isTrue();
// 계약의 본문 길이는 100000 이라 varchar(600)/varchar(1000) 로는 담을 없다.
assertThat(typeOf("case_detail", "problem_summary")).isEqualTo("text");
assertThat(typeOf("project_decision", "statement")).isEqualTo("text");
}
@Test
void aCaseWorkingCopyRoundTripsThroughEveryColumnItTouches() {
WorkingCopyView created =
workingCopies.create(
new WorkingCopyInputView.CaseInputView(
base(RecordKind.CASE, "장애 사례", "outage-case"),
"문제",
"결론",
"환경",
"재현",
LocalDate.of(2026, 8, 1),
"본문 :::evidence key=\"x\""),
"tester");
assertThat(created.version()).as("계약의 version 은 minimum 1 이다").isEqualTo(1L);
WorkingCopyView loaded = workingCopies.find(created.id()).orElseThrow();
assertThat(loaded).isInstanceOf(WorkingCopyView.CaseWorkingCopyView.class);
WorkingCopyView.CaseWorkingCopyView value = (WorkingCopyView.CaseWorkingCopyView) loaded;
assertThat(value.problem()).isEqualTo("문제");
assertThat(value.reproduction()).isEqualTo("재현");
assertThat(value.lastVerifiedOn()).isEqualTo(LocalDate.of(2026, 8, 1));
assertThat(value.base().topicId()).isEqualTo(topicId);
assertThat(value.base().projectId()).as("프로젝트 링크 테이블 왕복").isEqualTo(projectId);
assertThat(workingCopies.findKind(created.id())).contains(RecordKind.CASE);
}
@Test
void savingWithTheWrongVersionChangesNothing() {
WorkingCopyView created =
workingCopies.create(
new WorkingCopyInputView.CaseInputView(
base(RecordKind.CASE, "낙관적 잠금", "optimistic-lock"), "", "", "", "", null, ""),
"tester");
Optional<WorkingCopyView> conflict =
workingCopies.save(
created.id(),
created.version() + 99,
new WorkingCopyInputView.CaseInputView(
base(RecordKind.CASE, "덮어쓰기 시도", "optimistic-lock"), "", "", "", "", null, ""),
"tester");
assertThat(conflict).isEmpty();
assertThat(workingCopies.find(created.id()).orElseThrow().base().title()).isEqualTo("낙관적 잠금");
WorkingCopyView saved =
workingCopies
.save(
created.id(),
created.version(),
new WorkingCopyInputView.CaseInputView(
base(RecordKind.CASE, "정상 저장", "optimistic-lock"), "", "", "", "", null, ""),
"tester")
.orElseThrow();
assertThat(saved.version()).isEqualTo(created.version() + 1);
assertThat(saved.base().title()).isEqualTo("정상 저장");
}
@Test
void theOtherThreeKindsRoundTripThroughTheirOwnTables() {
WorkingCopyView reference =
workingCopies.create(
new WorkingCopyInputView.ReferenceInputView(
base(RecordKind.REFERENCE, "기준 문서", "reference-doc"),
"목적",
List.of(new ReferenceRuleView(UUID.randomUUID(), "규칙", "본문", 0)),
List.of(new OrderedTextView(UUID.randomUUID(), "적용", 0)),
List.of(),
List.of(new OrderedTextView(UUID.randomUUID(), "예시", 0)),
LocalDate.of(2026, 7, 1)),
"tester");
WorkingCopyView.ReferenceWorkingCopyView loadedReference =
(WorkingCopyView.ReferenceWorkingCopyView) workingCopies.find(reference.id()).orElseThrow();
assertThat(loadedReference.rules())
.singleElement()
.extracting(ReferenceRuleView::title)
.isEqualTo("규칙");
assertThat(loadedReference.examples())
.singleElement()
.extracting(OrderedTextView::text)
.isEqualTo("예시");
WorkingCopyView question =
workingCopies.create(
new WorkingCopyInputView.QuestionInputView(
base(RecordKind.QUESTION, "미해결 질문", "open-question"),
QuestionStatusView.OPEN,
List.of(new OrderedTextView(UUID.randomUUID(), "사실", 0)),
List.of(),
List.of(new OrderedTextView(UUID.randomUUID(), "모르는 것", 0)),
List.of(),
List.of(),
"다음 검증",
null),
"tester");
WorkingCopyView.QuestionWorkingCopyView loadedQuestion =
(WorkingCopyView.QuestionWorkingCopyView) workingCopies.find(question.id()).orElseThrow();
assertThat(loadedQuestion.facts())
.singleElement()
.extracting(OrderedTextView::text)
.isEqualTo("사실");
assertThat(loadedQuestion.unknowns()).hasSize(1);
assertThat(loadedQuestion.nextValidation()).isEqualTo("다음 검증");
assertThat(loadedQuestion.base().projectId()).isEqualTo(projectId);
WorkingCopyView decision =
workingCopies.create(
new WorkingCopyInputView.ProjectDecisionInputView(
base(RecordKind.PROJECT_DECISION, "결정", "a-decision"),
DecisionStatusView.ADOPTED,
LocalDate.of(2026, 6, 1),
"결정문",
"근거",
List.of(new OrderedTextView(UUID.randomUUID(), "결과", 0))),
"tester");
WorkingCopyView.ProjectDecisionWorkingCopyView loadedDecision =
(WorkingCopyView.ProjectDecisionWorkingCopyView)
workingCopies.find(decision.id()).orElseThrow();
// 계약의 ADOPTED Domain ACCEPTED (ADR-003).
assertThat(loadedDecision.decisionStatus()).isEqualTo(DecisionStatusView.ADOPTED);
assertThat(storedDecisionStatus(decision.id())).isEqualTo("ACCEPTED");
assertThat(loadedDecision.consequences()).hasSize(1);
}
@Test
void relationsSurviveAReplaceAndKeepTheirIdentity() {
WorkingCopyView target =
workingCopies.create(
new WorkingCopyInputView.CaseInputView(
base(RecordKind.CASE, "관계 대상", "relation-target"), "", "", "", "", null, ""),
"tester");
RelationView relation = new RelationView(null, target.id(), "왜 관련 있는지", 0);
WorkingCopyView source =
workingCopies.create(
new WorkingCopyInputView.CaseInputView(
new WorkingCopyBaseInput(
RecordKind.CASE,
"관계 원본",
"relation-source",
"요약",
topicId,
projectId,
List.of(relation)),
"",
"",
"",
"",
null,
""),
"tester");
List<RelationView> stored = workingCopies.find(source.id()).orElseThrow().base().relations();
assertThat(stored).singleElement().extracting(RelationView::reason).isEqualTo("왜 관련 있는지");
UUID relationId = stored.getFirst().id();
assertThat(relationId).isNotNull();
WorkingCopyView saved =
workingCopies
.save(
source.id(),
source.version(),
new WorkingCopyInputView.CaseInputView(
new WorkingCopyBaseInput(
RecordKind.CASE,
"관계 원본",
"relation-source",
"요약",
topicId,
projectId,
List.of(new RelationView(relationId, target.id(), "이유 수정", 0))),
"",
"",
"",
"",
null,
""),
"tester")
.orElseThrow();
List<RelationView> afterSave = saved.base().relations();
assertThat(afterSave).singleElement().extracting(RelationView::reason).isEqualTo("이유 수정");
assertThat(afterSave.getFirst().id()).as("이 문서가 소유하던 관계 id 는 저장해도 유지된다").isEqualTo(relationId);
}
@Test
void theUnionQueryComputesDependencyRevisionAndNextAction() {
WorkingCopyView document =
workingCopies.create(
new WorkingCopyInputView.CaseInputView(
base(RecordKind.CASE, "목록 대상", "list-target"), "", "", "", "", null, ""),
"tester");
var page =
documents.list(
new ListDocumentsQuery(
"목록", RecordKind.CASE, null, null, projectId, DocumentSort.UPDATED_DESC, null, 20));
assertThat(page.items()).extracting(item -> item.id()).contains(document.id());
assertThat(page.items().getFirst().nextAction())
.as("검증한 적이 없으면 다음 행동은 VALIDATE 다")
.isEqualTo(NextAction.VALIDATE);
String revision = dependencyRevisions.revisionFor(RecordKind.CASE, document.id());
assertThat(revision).isNotBlank();
assertThat(dependencyRevisions.revisionFor(RecordKind.CASE, document.id()))
.as("같은 상태면 같은 값이어야 한다")
.isEqualTo(revision);
// 제목이 비면 검증할 의미가 없으므로 CONTINUE_EDITING 이다.
workingCopies.save(
document.id(),
document.version(),
new WorkingCopyInputView.CaseInputView(
base(RecordKind.CASE, "", "list-target"), "", "", "", "", null, ""),
"tester");
var blankTitlePage =
documents.list(
new ListDocumentsQuery(
null,
RecordKind.CASE,
null,
NextAction.CONTINUE_EDITING,
projectId,
DocumentSort.UPDATED_DESC,
null,
20));
assertThat(blankTitlePage.items()).extracting(item -> item.id()).contains(document.id());
}
@Test
void dependencyResolutionFindsTopicProjectRelationsAndSlugOwners() {
WorkingCopyView first =
workingCopies.create(
new WorkingCopyInputView.CaseInputView(
base(RecordKind.CASE, "슬러그 주인", "shared-slug"), "", "", "", "", null, ""),
"tester");
WorkingCopyView second =
workingCopies.create(
new WorkingCopyInputView.CaseInputView(
base(RecordKind.CASE, "슬러그 경쟁", ""), "", "", "", "", null, ""),
"tester");
StudioDependencyResolverPort.Resolved resolved =
dependencies.resolve(workingCopies.find(first.id()).orElseThrow(), Set.of());
assertThat(resolved.topic()).isNotNull();
assertThat(resolved.topic().publicPath()).isEqualTo("/topics/kafka");
assertThat(resolved.project().publicPath()).isEqualTo("/projects/tech-log");
assertThat(resolved.publicPath()).isEqualTo("/cases/shared-slug");
assertThat(resolved.topicMissing()).isFalse();
assertThat(resolved.slugOwnerId()).as("자기 자신은 slug 충돌이 아니다").isNull();
// slug NULL 저장되므로 UNIQUE 제약을 여러 초안이 함께 지날 있다.
assertThat(workingCopies.find(second.id()).orElseThrow().base().slug()).isEmpty();
}
@Test
void validationAndPreviewArtifactsPersistAndReadBack() {
WorkingCopyView document =
workingCopies.create(
new WorkingCopyInputView.CaseInputView(
base(RecordKind.CASE, "artifact", "artifact-case"), "문제", "결론", "", "", null, ""),
"tester");
Instant now = Instant.now().truncatedTo(ChronoUnit.MILLIS);
ValidationReportView report =
new ValidationReportView(
UUID.randomUUID(),
document.id(),
document.version(),
ValidationStatus.WARNINGS,
List.of(
new dev.caskeleton.application.techlog.studio.model.ValidationIssueView(
"BODY_EMPTY",
dev.caskeleton.application.techlog.studio.model.ValidationSeverity.WARNING,
"/bodyMarkdown",
"본문이 비어 있다")),
now,
now.plusSeconds(3600),
"rev-1");
validations.save(RecordKind.CASE, report, "tester");
ValidationReportView loaded = validations.findById(report.validationId()).orElseThrow();
assertThat(loaded.status()).isEqualTo(ValidationStatus.WARNINGS);
assertThat(loaded.issues())
.singleElement()
.extracting(issue -> issue.code())
.isEqualTo("BODY_EMPTY");
assertThat(validations.latestFor(RecordKind.CASE, document.id())).isPresent();
PublicPreviewView preview =
new PublicPreviewView(
UUID.randomUUID(),
document.id(),
document.version(),
report.validationId(),
"rev-1",
now,
now.plusSeconds(3600),
"{\"kind\":\"CASE\"}");
previews.save(RecordKind.CASE, preview, "tester");
// jsonb 컬럼은 PostgreSQL 정규화해 되돌려준다(공백· 순서가 입력과 같지 않다). 계약 DTO
// 다시 파싱해 쓰는 값이라 의미는 보존되지만, 바이트 동일성을 기대하면 된다.
String storedRenderModel =
previews.findById(preview.previewId()).orElseThrow().renderModelJson();
assertThat(new ObjectMapper().readTree(storedRenderModel).path("kind").asString(""))
.isEqualTo("CASE");
}
@Test
void publishWritesTheWholeAggregateAndUnpublishWithdrawsIt() {
WorkingCopyView document =
workingCopies.create(
new WorkingCopyInputView.CaseInputView(
base(RecordKind.CASE, "게시 대상", "publish-target"), "문제", "결론", "", "", null, "본문"),
"tester");
PublishResultView published =
transactions.execute(
status ->
publicationWriter.publish(
new PublicationWriterPort.PublishRequest(
RecordKind.CASE,
document.id(),
document.version(),
"게시 대상",
"요약",
topicId,
projectId,
"/cases/publish-target",
null,
"{\"kind\":\"CASE\"}",
"본문 평문",
List.of(),
"1",
"1",
"idem-1",
"tester")));
assertThat(published.publication().status()).isEqualTo(PublicationAggregateStatus.PUBLISHED);
assertThat(published.publication().publicationRevision())
.as("첫 게시의 revision 은 1 이다 — INSERT 가 넣은 값을 UPDATE 가 또 올리면 안 된다")
.isEqualTo(1L);
assertThat(published.event().snapshotAvailable()).isTrue();
// 순환 FK(publication.latest_event_id <-> publication_event) 지연 검사로 통과해야 한다.
assertThat(published.publication().latestEventId())
.isEqualTo(published.event().publicationEventId());
assertThat(count("public_resource_projection", "resource_id", document.id())).isEqualTo(1);
assertThat(count("public_route", "resource_id", document.id())).isEqualTo(1);
assertThat(
jdbcClient
.sql("SELECT workflow_status FROM document WHERE id = :id")
.param("id", document.id())
.query(String.class)
.single())
.isEqualTo("PUBLISHED");
// 재게시는 REPUBLISHED 이벤트와 revision 증가를 만든다.
PublishResultView republished =
transactions.execute(
status ->
publicationWriter.publish(
new PublicationWriterPort.PublishRequest(
RecordKind.CASE,
document.id(),
document.version(),
"게시 대상",
"요약",
topicId,
projectId,
"/cases/publish-target",
null,
"{\"kind\":\"CASE\"}",
"본문 평문",
List.of(),
"1",
"1",
"idem-2",
"tester")));
assertThat(republished.event().type())
.isEqualTo(
dev.caskeleton.application.techlog.studio.model.PublicationEventTypeView.REPUBLISHED);
assertThat(republished.publication().publicationRevision()).isEqualTo(2L);
var page = publicationHistory.list(new ListPublicationsQuery(null, null, null, 20));
assertThat(page.items()).isNotEmpty();
assertThat(page.items().getFirst().document()).isNotNull();
assertThat(publicationHistory.findSnapshot(published.event().publicationEventId())).isPresent();
PublishResultView withdrawn =
transactions.execute(
status ->
publicationWriter.unpublish(
new PublicationWriterPort.UnpublishRequest(
published.publication().publicationId(),
RecordKind.CASE,
document.id(),
republished.publication().publicationRevision(),
"tester")));
assertThat(withdrawn.publication().status()).isEqualTo(PublicationAggregateStatus.UNPUBLISHED);
assertThat(withdrawn.event().sourcePublishedEventId())
.as("UNPUBLISHED 이벤트는 마지막 공개 Snapshot 을 반드시 참조한다")
.isNotNull();
assertThat(
jdbcClient
.sql(
"SELECT publication_state FROM public_resource_projection"
+ " WHERE resource_id = :id")
.param("id", document.id())
.query(String.class)
.single())
.isEqualTo("WITHDRAWN");
assertThat(count("public_route", "resource_id", document.id()))
.as("게시 취소는 주소를 지우지 않는다 — 지우면 공개된 링크가 끊긴다")
.isEqualTo(1);
}
@Test
void dashboardCountsUseTheSameProjectionAsTheList() {
var totals = dashboard.totals();
assertThat(totals.documents()).isPositive();
assertThat(dashboard.topByNextAction(List.of(NextAction.VALIDATE), 5)).isNotNull();
}
@Test
void assetsRoundTripAndReportTheirUsage() {
UUID assetId = UUID.randomUUID();
AssetView created =
assets.create(
new AssetRepositoryPort.NewAsset(
assetId,
"diagram-key",
AssetKindView.DIAGRAM,
"image/png",
"techlog/assets/" + assetId,
"diagram.png",
1024L,
800,
600,
"a".repeat(64),
"설명",
false,
AssetManagementStatusView.READY),
"tester");
assertThat(created.version()).as("계약의 Asset.version 은 minimum 1 이다").isEqualTo(1L);
assertThat(created.publicPath()).isEqualTo("/media/" + assetId);
assertThat(created.usageCount()).isZero();
var page =
assets.list(new ListAssetsQuery("diagram", AssetKindView.DIAGRAM, null, null, null, 20));
assertThat(page.items()).extracting(AssetView::id).contains(assetId);
AssetView updated =
assets
.update(
assetId,
created.version(),
null,
null,
true,
Boolean.TRUE,
AssetManagementStatusView.ARCHIVED,
"tester")
.orElseThrow();
assertThat(updated.altText()).as("altTextProvided=true 는 null 로 지우는 것을 뜻한다").isNull();
assertThat(updated.decorative()).isTrue();
assertThat(updated.managementStatus()).isEqualTo(AssetManagementStatusView.ARCHIVED);
assertThat(updated.version()).isEqualTo(2L);
assertThat(assets.update(assetId, 99L, null, null, false, null, null, "tester")).isEmpty();
assertThat(assets.findObjectKey(assetId)).contains("techlog/assets/" + assetId);
var detail = assets.findDetail(assetId).orElseThrow();
assertThat(detail.hasPublicationHistory()).isFalse();
assertThat(detail.usages()).isEmpty();
assets.delete(assetId);
assertThat(assets.find(assetId)).isEmpty();
}
private static List<String> columnsOf(String table) {
return jdbcClient
.sql("SELECT column_name FROM information_schema.columns WHERE table_name = :table")
.param("table", table)
.query(String.class)
.list();
}
private static boolean isNullable(String table, String column) {
return "YES"
.equals(
jdbcClient
.sql(
"SELECT is_nullable FROM information_schema.columns"
+ " WHERE table_name = :table AND column_name = :column")
.param("table", table)
.param("column", column)
.query(String.class)
.single());
}
private static String typeOf(String table, String column) {
return jdbcClient
.sql(
"SELECT data_type FROM information_schema.columns"
+ " WHERE table_name = :table AND column_name = :column")
.param("table", table)
.param("column", column)
.query(String.class)
.single();
}
private static String storedDecisionStatus(UUID id) {
return jdbcClient
.sql("SELECT decision_status FROM project_decision WHERE id = :id")
.param("id", id)
.query(String.class)
.single();
}
private static int count(String table, String column, UUID id) {
return jdbcClient
.sql("SELECT count(*) FROM " + table + " WHERE " + column + " = :id")
.param("id", id)
.query(Integer.class)
.single();
}
}
@@ -31,8 +31,8 @@ import org.springframework.transaction.support.TransactionTemplate;
* USING}, and only an execution proves that the substitution kept the three outcomes intact. * USING}, and only an execution proves that the substitution kept the three outcomes intact.
* *
* <p>In-memory and process-local, so this stays an ordinary unit test: no container, no network, * <p>In-memory and process-local, so this stays an ordinary unit test: no container, no network,
* nothing to skip when Docker is absent. Real-PostgreSQL fidelity remains the job of the * nothing to skip when Docker is absent. Real-PostgreSQL fidelity remains the job of the {@code
* {@code postgresqlIntegrationTest} source set. * postgresqlIntegrationTest} source set.
*/ */
class H2ClaimSqlTest { class H2ClaimSqlTest {
@@ -142,8 +142,9 @@ class H2ClaimSqlTest {
List<OutboxEventEntity> claimed = claimEligible(now, 10); List<OutboxEventEntity> claimed = claimEligible(now, 10);
assertThat(claimed).extracting(OutboxEventEntity::getEventId).containsExactly("evt-old", assertThat(claimed)
"evt-other"); .extracting(OutboxEventEntity::getEventId)
.containsExactly("evt-old", "evt-other");
} }
@Test @Test
+43 -1
View File
@@ -15,6 +15,12 @@ sourceSets {
functionalTest { functionalTest {
java.srcDir 'src/functionalTest/java' java.srcDir 'src/functionalTest/java'
resources.srcDir 'src/functionalTest/resources' resources.srcDir 'src/functionalTest/resources'
// feature-techlog-studio-backend Task 10 StudioContractDriftTest reuses
// RepositoryContractResources (test-sourceSet-owned, see
// dev.caskeleton.bootstrap.contract.support) for fail-closed repo-root resolution instead
// of a hand-rolled relative Path.of(..), matching the sibling contract tests' convention.
compileClasspath += sourceSets.test.output
runtimeClasspath += sourceSets.test.output
} }
conditionalTransportTest { conditionalTransportTest {
java.srcDir 'src/conditionalTransportTest/java' java.srcDir 'src/conditionalTransportTest/java'
@@ -121,6 +127,33 @@ dependencies {
functionalTestImplementation 'org.junit.jupiter:junit-jupiter' functionalTestImplementation 'org.junit.jupiter:junit-jupiter'
functionalTestImplementation 'org.assertj:assertj-core' functionalTestImplementation 'org.assertj:assertj-core'
functionalTestRuntimeOnly 'org.junit.platform:junit-platform-launcher' functionalTestRuntimeOnly 'org.junit.platform:junit-platform-launcher'
// feature-techlog-studio-backend Task 10 StudioContractDriftTest boots a minimal Studio web
// slice (real StudioSessionController/StudioCatalogController, no persistence/messaging/cache)
// to diff springdoc's published /api/v1/studio/** surface against studio-v1.yaml. Only the two
// modules the slice actually needs deliberately not the full app-bootstrap runtime graph, so
// no DataSource/Flyway/Redis auto-configuration is even on this classpath to exclude.
functionalTestImplementation project(':adapter:inbound:web')
functionalTestImplementation project(':application-core')
// test-only: @SpringBootTest/MockMvc/@AutoConfigureMockMvc mirrors the root build.gradle
// subprojects{} pair every non-core module already gets on its ordinary `test` sourceSet.
functionalTestImplementation 'org.springframework.boot:spring-boot-starter-test'
functionalTestImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test'
// test-only: classic Jackson (2.x) ObjectMapper/JsonNode to read /v3/api-docs and convert the
// SnakeYaml-parsed contract into a comparable tree. adapter-inbound-web/application-core pull
// this in only as a project-dependency `implementation` (hidden from a consumer's
// compileClasspath by Gradle's api/implementation split), so it must be declared directly here
// mirrors the existing `testImplementation 'org.springframework.boot:spring-boot-starter-json'`
// pattern below for app-bootstrap's own `test` sourceSet.
functionalTestImplementation 'com.fasterxml.jackson.core:jackson-databind'
// test-only: org.springframework.boot.security.autoconfigure.SecurityAutoConfiguration (excluded
// below) is part of spring-boot-security, only pulled in transitively by spring-boot-starter-security
// same api/implementation-hiding reason as jackson-databind above. app-bootstrap declares this
// on `implementation` for its own main/test sourceSets, which functionalTest does not extend.
functionalTestImplementation 'org.springframework.boot:spring-boot-starter-security'
// test-only: parses config/openapi/studio-v1.yaml with the same library StudioErrorRegistryTest
// already uses for docs/registries/error-codes.yaml avoids adding jackson-dataformat-yaml
// (present only on runtimeClasspath repo-wide, transitively via springdoc, not compileClasspath).
functionalTestImplementation 'org.yaml:snakeyaml'
// Explicit qualification-only composition. These projects remain absent from main // Explicit qualification-only composition. These projects remain absent from main
// api/implementation/compileOnly/runtimeOnly and therefore from both shipped runtime graphs. // api/implementation/compileOnly/runtimeOnly and therefore from both shipped runtime graphs.
conditionalTransportTestImplementation project(':adapter:inbound:graphql') conditionalTransportTestImplementation project(':adapter:inbound:graphql')
@@ -166,13 +199,22 @@ sampleOffQualification.configure {
tasks.register('functionalTest', Test) { tasks.register('functionalTest', Test) {
group = 'verification' group = 'verification'
description = 'Runs isolated Gradle TestKit contracts for repository build behavior.' description = 'Runs isolated Gradle TestKit contracts for repository build behavior, plus the ' +
'feature-techlog-studio-backend Studio contract drift gate.'
testClassesDirs = sourceSets.functionalTest.output.classesDirs testClassesDirs = sourceSets.functionalTest.output.classesDirs
classpath = sourceSets.functionalTest.runtimeClasspath classpath = sourceSets.functionalTest.runtimeClasspath
useJUnitPlatform() useJUnitPlatform()
failOnNoDiscoveredTests = true failOnNoDiscoveredTests = true
shouldRunAfter tasks.named('test') shouldRunAfter tasks.named('test')
jvmArgs '-Duser.timezone=UTC' jvmArgs '-Duser.timezone=UTC'
// feature-techlog-studio-backend Task 10 StudioContractDriftTest boots a real (minimal)
// Spring Boot context that needs Logback, on the same classpath as gradleTestKit() (whose own
// SLF4J provider org.gradle.internal.logging.slf4j.OutputEventListenerBackedLoggerContext
// wins classpath scanning over the real one). Spring Boot's LogbackLoggingSystem then finds
// Logback's jar present but the bound ILoggerFactory is Gradle's fake context, and fails fast
// with IllegalStateException before the context even starts. LoggingSystem=none skips Boot's
// logging bootstrap entirely this task doesn't assert on log output, so there is nothing lost.
systemProperty 'org.springframework.boot.logging.LoggingSystem', 'none'
} }
def conditionalTransportCompositionQualification = registerStrictQualificationTest( def conditionalTransportCompositionQualification = registerStrictQualificationTest(
+113 -108
View File
@@ -2,26 +2,26 @@
# 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.
aopalliance:aopalliance:1.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath aopalliance:aopalliance:1.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=compileClasspath,sampleOffTestCompileClasspath,testCompileClasspath biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=compileClasspath,functionalTestCompileClasspath,sampleOffTestCompileClasspath,testCompileClasspath
ch.qos.logback:logback-classic:1.5.21=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath ch.qos.logback:logback-classic:1.5.21=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
ch.qos.logback:logback-core:1.5.21=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath ch.qos.logback:logback-core:1.5.21=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.approvaltests:approvaltests-util:31.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath com.approvaltests:approvaltests-util:31.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.approvaltests:approvaltests:31.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath com.approvaltests:approvaltests:31.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.ethlo.time:itu:1.14.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath com.ethlo.time:itu:1.14.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
com.fasterxml.jackson.core:jackson-annotations:2.20=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath com.fasterxml.jackson.core:jackson-annotations:2.20=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.fasterxml.jackson.core:jackson-core:2.20.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath com.fasterxml.jackson.core:jackson-core:2.20.1=compileClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.fasterxml.jackson.core:jackson-databind:2.20.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath com.fasterxml.jackson.core:jackson-databind:2.20.1=compileClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.20.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.20.1=functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.20.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.20.1=functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
com.fasterxml.jackson:jackson-bom:2.20.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath com.fasterxml.jackson:jackson-bom:2.20.1=compileClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.fasterxml:classmate:1.7.1=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath com.fasterxml:classmate:1.7.1=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,conditionalTransportTestAnnotationProcessor,functionalTestAnnotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,conditionalTransportTestAnnotationProcessor,functionalTestAnnotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor
com.github.docker-java:docker-java-api:3.7.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath com.github.docker-java:docker-java-api:3.7.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.github.docker-java:docker-java-transport-zerodep:3.7.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath com.github.docker-java:docker-java-transport-zerodep:3.7.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.github.docker-java:docker-java-transport:3.7.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath com.github.docker-java:docker-java-transport:3.7.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,conditionalTransportTestAnnotationProcessor,functionalTestAnnotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,conditionalTransportTestAnnotationProcessor,functionalTestAnnotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor
com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs
com.github.spotbugs:spotbugs-annotations:4.8.6=compileClasspath,sampleOffTestCompileClasspath,testCompileClasspath com.github.spotbugs:spotbugs-annotations:4.8.6=compileClasspath,functionalTestCompileClasspath,sampleOffTestCompileClasspath,testCompileClasspath
com.github.spotbugs:spotbugs:4.10.2=spotbugs com.github.spotbugs:spotbugs:4.10.2=spotbugs
com.github.stephenc.jcip:jcip-annotations:1.0-1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath com.github.stephenc.jcip:jcip-annotations:1.0-1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath
com.google.android:annotations:4.1.1.4=conditionalTransportTestRuntimeClasspath com.google.android:annotations:4.1.1.4=conditionalTransportTestRuntimeClasspath
@@ -29,10 +29,10 @@ com.google.api.grpc:proto-google-common-protos:2.41.0=conditionalTransportTestRu
com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,conditionalTransportTestAnnotationProcessor,functionalTestAnnotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,conditionalTransportTestAnnotationProcessor,functionalTestAnnotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor
com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,conditionalTransportTestAnnotationProcessor,functionalTestAnnotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,conditionalTransportTestAnnotationProcessor,functionalTestAnnotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor
com.google.auto:auto-common:1.2.2=annotationProcessor,conditionalTransportTestAnnotationProcessor,functionalTestAnnotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor com.google.auto:auto-common:1.2.2=annotationProcessor,conditionalTransportTestAnnotationProcessor,functionalTestAnnotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor
com.google.code.findbugs:jsr305:3.0.2=checkstyle,compileClasspath,conditionalTransportTestRuntimeClasspath,sampleOffTestCompileClasspath,spotbugs,testCompileClasspath com.google.code.findbugs:jsr305:3.0.2=checkstyle,compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,sampleOffTestCompileClasspath,spotbugs,testCompileClasspath
com.google.code.gson:gson:2.13.2=conditionalTransportTestRuntimeClasspath,spotbugs com.google.code.gson:gson:2.13.2=conditionalTransportTestRuntimeClasspath,spotbugs
com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,conditionalTransportTestAnnotationProcessor,functionalTestAnnotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,conditionalTransportTestAnnotationProcessor,functionalTestAnnotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor
com.google.errorprone:error_prone_annotations:2.38.0=compileClasspath,sampleOffTestCompileClasspath,testCompileClasspath com.google.errorprone:error_prone_annotations:2.38.0=compileClasspath,functionalTestCompileClasspath,sampleOffTestCompileClasspath,testCompileClasspath
com.google.errorprone:error_prone_annotations:2.41.0=conditionalTransportTestRuntimeClasspath,spotbugs com.google.errorprone:error_prone_annotations:2.41.0=conditionalTransportTestRuntimeClasspath,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,conditionalTransportTestAnnotationProcessor,functionalTestAnnotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,conditionalTransportTestAnnotationProcessor,functionalTestAnnotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor
@@ -54,11 +54,11 @@ com.graphql-java:graphql-java:25.0=conditionalTransportTestRuntimeClasspath
com.graphql-java:java-dataloader:6.0.0=conditionalTransportTestRuntimeClasspath com.graphql-java:java-dataloader:6.0.0=conditionalTransportTestRuntimeClasspath
com.h2database:h2:2.4.240=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath com.h2database:h2:2.4.240=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
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=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath com.jayway.jsonpath:json-path:2.9.0=functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.networknt:json-schema-validator:3.0.2=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath com.networknt:json-schema-validator:3.0.2=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
com.nimbusds:content-type:2.3=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath com.nimbusds:content-type:2.3=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.nimbusds:lang-tag:1.7=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath com.nimbusds:lang-tag:1.7=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.nimbusds:nimbus-jose-jwt:10.4=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath com.nimbusds:nimbus-jose-jwt:10.4=compileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.nimbusds:oauth2-oidc-sdk:11.26.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath com.nimbusds:oauth2-oidc-sdk:11.26.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.puppycrawl.tools:checkstyle:13.5.0=checkstyle com.puppycrawl.tools:checkstyle:13.5.0=checkstyle
com.squareup.okhttp3:okhttp-jvm:5.2.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath com.squareup.okhttp3:okhttp-jvm:5.2.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
@@ -71,14 +71,14 @@ com.tngtech.archunit:archunit-junit5-engine-api:1.3.0=sampleOffTestRuntimeClassp
com.tngtech.archunit:archunit-junit5-engine:1.3.0=sampleOffTestRuntimeClasspath,testRuntimeClasspath com.tngtech.archunit:archunit-junit5-engine:1.3.0=sampleOffTestRuntimeClasspath,testRuntimeClasspath
com.tngtech.archunit:archunit-junit5:1.3.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath com.tngtech.archunit:archunit-junit5:1.3.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.tngtech.archunit:archunit:1.3.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath com.tngtech.archunit:archunit:1.3.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.vaadin.external.google:android-json:0.0.20131108.vaadin1=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath com.vaadin.external.google:android-json:0.0.20131108.vaadin1=functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.zaxxer:HikariCP:7.0.2=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath com.zaxxer:HikariCP:7.0.2=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
commons-beanutils:commons-beanutils:1.11.0=checkstyle commons-beanutils:commons-beanutils:1.11.0=checkstyle
commons-codec:commons-codec:1.19.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath commons-codec:commons-codec:1.19.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
commons-collections:commons-collections:3.2.2=checkstyle commons-collections:commons-collections:3.2.2=checkstyle
commons-io:commons-io:2.20.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath commons-io:commons-io:2.20.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
commons-io:commons-io:2.21.0=spotbugs commons-io:commons-io:2.21.0=spotbugs
commons-logging:commons-logging:1.3.5=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath commons-logging:commons-logging:1.3.5=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
info.picocli:picocli:4.7.7=checkstyle info.picocli:picocli:4.7.7=checkstyle
io.github.cdimascio:dotenv-java:3.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath io.github.cdimascio:dotenv-java:3.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,conditionalTransportTestAnnotationProcessor,functionalTestAnnotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,conditionalTransportTestAnnotationProcessor,functionalTestAnnotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor
@@ -101,10 +101,10 @@ io.grpc:grpc-stub:1.68.1=conditionalTransportTestRuntimeClasspath
io.grpc:grpc-util:1.68.1=conditionalTransportTestRuntimeClasspath io.grpc:grpc-util:1.68.1=conditionalTransportTestRuntimeClasspath
io.lettuce:lettuce-core:6.8.1.RELEASE=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath io.lettuce:lettuce-core:6.8.1.RELEASE=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
io.micrometer:context-propagation:1.2.0=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath io.micrometer:context-propagation:1.2.0=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
io.micrometer:micrometer-commons:1.16.0=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath io.micrometer:micrometer-commons:1.16.0=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
io.micrometer:micrometer-core:1.16.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath io.micrometer:micrometer-core:1.16.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
io.micrometer:micrometer-jakarta9:1.16.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath io.micrometer:micrometer-jakarta9:1.16.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
io.micrometer:micrometer-observation:1.16.0=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath io.micrometer:micrometer-observation:1.16.0=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
io.micrometer:micrometer-registry-prometheus:1.16.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath io.micrometer:micrometer-registry-prometheus:1.16.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
io.micrometer:micrometer-tracing-bridge-otel:1.6.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath io.micrometer:micrometer-tracing-bridge-otel:1.6.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
io.micrometer:micrometer-tracing:1.6.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath io.micrometer:micrometer-tracing:1.6.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
@@ -147,39 +147,39 @@ io.opentelemetry:opentelemetry-sdk:1.55.0=compileClasspath,productionRuntimeClas
io.perfmark:perfmark-api:0.27.0=conditionalTransportTestRuntimeClasspath io.perfmark:perfmark-api:0.27.0=conditionalTransportTestRuntimeClasspath
io.projectreactor.netty:reactor-netty-core:1.3.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath io.projectreactor.netty:reactor-netty-core:1.3.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
io.projectreactor.netty:reactor-netty-http:1.3.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath io.projectreactor.netty:reactor-netty-http:1.3.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
io.projectreactor:reactor-core:3.8.0=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath io.projectreactor:reactor-core:3.8.0=conditionalTransportTestRuntimeClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
io.prometheus:prometheus-metrics-config:1.4.3=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath io.prometheus:prometheus-metrics-config:1.4.3=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
io.prometheus:prometheus-metrics-core:1.4.3=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath io.prometheus:prometheus-metrics-core:1.4.3=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
io.prometheus:prometheus-metrics-exposition-formats:1.4.3=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath io.prometheus:prometheus-metrics-exposition-formats:1.4.3=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
io.prometheus:prometheus-metrics-exposition-textformats:1.4.3=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath io.prometheus:prometheus-metrics-exposition-textformats:1.4.3=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
io.prometheus:prometheus-metrics-model:1.4.3=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath io.prometheus:prometheus-metrics-model:1.4.3=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
io.prometheus:prometheus-metrics-tracer-common:1.4.3=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath io.prometheus:prometheus-metrics-tracer-common:1.4.3=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
io.swagger.core.v3:swagger-annotations-jakarta:2.2.38=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath io.swagger.core.v3:swagger-annotations-jakarta:2.2.38=functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
io.swagger.core.v3:swagger-core-jakarta:2.2.38=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath io.swagger.core.v3:swagger-core-jakarta:2.2.38=functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
io.swagger.core.v3:swagger-models-jakarta:2.2.38=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath io.swagger.core.v3:swagger-models-jakarta:2.2.38=functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
jakarta.activation:jakarta.activation-api:2.1.4=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath jakarta.activation:jakarta.activation-api:2.1.4=functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
jakarta.annotation:jakarta.annotation-api:3.0.0=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath jakarta.annotation:jakarta.annotation-api:3.0.0=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
jakarta.inject:jakarta.inject-api:2.0.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath jakarta.inject:jakarta.inject-api:2.0.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
jakarta.persistence:jakarta.persistence-api:3.2.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath jakarta.persistence:jakarta.persistence-api:3.2.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
jakarta.transaction:jakarta.transaction-api:2.0.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath jakarta.transaction:jakarta.transaction-api:2.0.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
jakarta.validation:jakarta.validation-api:3.1.1=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath jakarta.validation:jakarta.validation-api:3.1.1=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
jakarta.websocket:jakarta.websocket-api:2.2.0=sampleOffTestCompileClasspath,testCompileClasspath jakarta.websocket:jakarta.websocket-api:2.2.0=sampleOffTestCompileClasspath,testCompileClasspath
jakarta.ws.rs:jakarta.ws.rs-api:4.0.0=sampleOffTestCompileClasspath,testCompileClasspath jakarta.ws.rs:jakarta.ws.rs-api:4.0.0=sampleOffTestCompileClasspath,testCompileClasspath
jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
javax.inject:javax.inject:1=annotationProcessor,conditionalTransportTestAnnotationProcessor,functionalTestAnnotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor javax.inject:javax.inject:1=annotationProcessor,conditionalTransportTestAnnotationProcessor,functionalTestAnnotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor
jaxen:jaxen:2.0.0=spotbugs jaxen:jaxen:2.0.0=spotbugs
me.paulschwarz:spring-dotenv:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath me.paulschwarz:spring-dotenv:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
net.bytebuddy:byte-buddy-agent:1.17.8=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath net.bytebuddy:byte-buddy-agent:1.17.8=functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
net.bytebuddy:byte-buddy:1.17.8=conditionalTransportTestCompileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath net.bytebuddy:byte-buddy:1.17.8=conditionalTransportTestCompileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
net.java.dev.jna:jna:5.18.1=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath net.java.dev.jna:jna:5.18.1=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
net.logstash.logback:logstash-logback-encoder:8.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath net.logstash.logback:logstash-logback-encoder:8.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
net.minidev:accessors-smart:2.6.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath net.minidev:accessors-smart:2.6.0=compileClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
net.minidev:json-smart:2.6.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath net.minidev:json-smart:2.6.0=compileClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs
org.antlr:antlr4-runtime:4.13.2=checkstyle,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.antlr:antlr4-runtime:4.13.2=checkstyle,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.apache.bcel:bcel:6.12.0=spotbugs org.apache.bcel:bcel:6.12.0=spotbugs
org.apache.commons:commons-compress:1.28.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.apache.commons:commons-compress:1.28.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.apache.commons:commons-lang3:3.20.0=checkstyle,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath org.apache.commons:commons-lang3:3.20.0=checkstyle,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath
org.apache.commons:commons-text:1.15.0=spotbugs org.apache.commons:commons-text:1.15.0=spotbugs
org.apache.commons:commons-text:1.3=checkstyle org.apache.commons:commons-text:1.3=checkstyle
org.apache.httpcomponents.client5:httpclient5:5.5.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath org.apache.httpcomponents.client5:httpclient5:5.5.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
@@ -188,21 +188,21 @@ org.apache.httpcomponents.core5:httpcore5:5.3.6=productionRuntimeClasspath,runti
org.apache.httpcomponents:httpclient:4.5.13=checkstyle org.apache.httpcomponents:httpclient:4.5.13=checkstyle
org.apache.httpcomponents:httpcore:4.4.16=checkstyle org.apache.httpcomponents:httpcore:4.4.16=checkstyle
org.apache.kafka:kafka-clients:4.1.1=sampleOffTestCompileClasspath,testCompileClasspath org.apache.kafka:kafka-clients:4.1.1=sampleOffTestCompileClasspath,testCompileClasspath
org.apache.logging.log4j:log4j-api:2.25.2=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath org.apache.logging.log4j:log4j-api:2.25.2=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath
org.apache.logging.log4j:log4j-core:2.25.2=spotbugs org.apache.logging.log4j:log4j-core:2.25.2=spotbugs
org.apache.logging.log4j:log4j-to-slf4j:2.25.2=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.apache.logging.log4j:log4j-to-slf4j:2.25.2=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.apache.maven.doxia:doxia-core:1.12.0=checkstyle org.apache.maven.doxia:doxia-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=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.apache.tomcat.embed:tomcat-embed-core:11.0.14=conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.apache.tomcat.embed:tomcat-embed-el:11.0.14=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.apache.tomcat.embed:tomcat-embed-el:11.0.14=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.apache.xbean:xbean-reflect:3.7=checkstyle org.apache.xbean:xbean-reflect:3.7=checkstyle
org.apiguardian:apiguardian-api:1.1.2=conditionalTransportTestCompileClasspath,functionalTestCompileClasspath,sampleOffTestCompileClasspath,testCompileClasspath org.apiguardian:apiguardian-api:1.1.2=conditionalTransportTestCompileClasspath,functionalTestCompileClasspath,sampleOffTestCompileClasspath,testCompileClasspath
org.aspectj:aspectjweaver:1.9.25=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.aspectj:aspectjweaver:1.9.25=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.assertj:assertj-core:3.27.6=conditionalTransportTestCompileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.assertj:assertj-core:3.27.6=conditionalTransportTestCompileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.awaitility:awaitility:4.3.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.awaitility:awaitility:4.3.0=functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.checkerframework:checker-qual:3.42.0=conditionalTransportTestRuntimeClasspath org.checkerframework:checker-qual:3.42.0=conditionalTransportTestRuntimeClasspath
org.checkerframework:checker-qual:3.49.5=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath org.checkerframework:checker-qual:3.49.5=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.codehaus.mojo:animal-sniffer-annotations:1.24=conditionalTransportTestRuntimeClasspath org.codehaus.mojo:animal-sniffer-annotations:1.24=conditionalTransportTestRuntimeClasspath
@@ -210,6 +210,10 @@ 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=functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.commonmark:commonmark-ext-gfm-strikethrough:0.21.0=functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.commonmark:commonmark-ext-gfm-tables:0.21.0=functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.commonmark:commonmark:0.21.0=functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.dom4j:dom4j:2.2.0=spotbugs org.dom4j:dom4j:2.2.0=spotbugs
org.eclipse.angus:angus-activation:2.0.3=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath org.eclipse.angus:angus-activation:2.0.3=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.eclipse.jetty.compression:jetty-compression-common:12.1.4=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath org.eclipse.jetty.compression:jetty-compression-common:12.1.4=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
@@ -224,17 +228,17 @@ org.flywaydb:flyway-database-postgresql:11.14.1=productionRuntimeClasspath,runti
org.glassfish.jaxb:jaxb-core:4.0.6=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath org.glassfish.jaxb:jaxb-core:4.0.6=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.glassfish.jaxb:jaxb-runtime:4.0.6=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath org.glassfish.jaxb:jaxb-runtime:4.0.6=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.glassfish.jaxb:txw2:4.0.6=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath org.glassfish.jaxb:txw2:4.0.6=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.hamcrest:hamcrest:3.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.hamcrest:hamcrest:3.0=functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.hdrhistogram:HdrHistogram:2.2.2=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath org.hdrhistogram:HdrHistogram:2.2.2=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.hibernate.models:hibernate-models:1.0.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath org.hibernate.models:hibernate-models:1.0.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.hibernate.orm:hibernate-core:7.1.8.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.hibernate.orm:hibernate-core:7.1.8.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.hibernate.validator:hibernate-validator:9.0.1.Final=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.hibernate.validator:hibernate-validator:9.0.1.Final=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.javassist:javassist:3.28.0-GA=checkstyle org.javassist:javassist:3.28.0-GA=checkstyle
org.jboss.logging:jboss-logging:3.6.1.Final=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.jboss.logging:jboss-logging:3.6.1.Final=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.jetbrains.kotlin:kotlin-stdlib:2.2.21=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath org.jetbrains.kotlin:kotlin-stdlib:2.2.21=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.jetbrains:annotations:13.0=productionRuntimeClasspath,runtimeClasspath org.jetbrains:annotations:13.0=productionRuntimeClasspath,runtimeClasspath
org.jetbrains:annotations:17.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.jetbrains:annotations:17.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,conditionalTransportTestAnnotationProcessor,conditionalTransportTestCompileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestAnnotationProcessor,functionalTestCompileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestAnnotationProcessor,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,conditionalTransportTestAnnotationProcessor,conditionalTransportTestCompileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestAnnotationProcessor,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestAnnotationProcessor,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter-api:6.0.1=conditionalTransportTestCompileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.junit.jupiter:junit-jupiter-api:6.0.1=conditionalTransportTestCompileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter-engine:6.0.1=conditionalTransportTestRuntimeClasspath,functionalTestRuntimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath org.junit.jupiter:junit-jupiter-engine:6.0.1=conditionalTransportTestRuntimeClasspath,functionalTestRuntimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter-params:6.0.1=conditionalTransportTestCompileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.junit.jupiter:junit-jupiter-params:6.0.1=conditionalTransportTestCompileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
@@ -246,35 +250,36 @@ org.junit.platform:junit-platform-testkit:6.0.1=sampleOffTestCompileClasspath,sa
org.junit:junit-bom:6.0.1=conditionalTransportTestCompileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.junit:junit-bom:6.0.1=conditionalTransportTestCompileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.junit:junit-bom:6.1.0=spotbugs org.junit:junit-bom:6.1.0=spotbugs
org.latencyutils:LatencyUtils:2.0.3=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath org.latencyutils:LatencyUtils:2.0.3=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.mockito:mockito-core:5.20.0=mockitoAgent,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.mockito:mockito-core:5.20.0=functionalTestCompileClasspath,functionalTestRuntimeClasspath,mockitoAgent,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.mockito:mockito-junit-jupiter:5.20.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.mockito:mockito-junit-jupiter:5.20.0=functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.objenesis:objenesis:3.3=sampleOffTestRuntimeClasspath,testRuntimeClasspath org.nibor.autolink:autolink:0.10.0=functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.openapitools:jackson-databind-nullable:0.2.6=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath org.objenesis:objenesis:3.3=functionalTestRuntimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.openapitools:jackson-databind-nullable:0.2.6=functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.opentest4j:opentest4j:1.3.0=conditionalTransportTestCompileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.opentest4j:opentest4j:1.3.0=conditionalTransportTestCompileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.osgi:org.osgi.annotation.bundle:2.0.0=compileClasspath,sampleOffTestCompileClasspath,testCompileClasspath org.osgi:org.osgi.annotation.bundle:2.0.0=compileClasspath,functionalTestCompileClasspath,sampleOffTestCompileClasspath,testCompileClasspath
org.osgi:org.osgi.annotation.versioning:1.1.2=compileClasspath,sampleOffTestCompileClasspath,testCompileClasspath org.osgi:org.osgi.annotation.versioning:1.1.2=compileClasspath,functionalTestCompileClasspath,sampleOffTestCompileClasspath,testCompileClasspath
org.osgi:org.osgi.resource:1.0.0=compileClasspath,sampleOffTestCompileClasspath,testCompileClasspath org.osgi:org.osgi.resource:1.0.0=compileClasspath,functionalTestCompileClasspath,sampleOffTestCompileClasspath,testCompileClasspath
org.osgi:org.osgi.service.serviceloader:1.0.0=compileClasspath,sampleOffTestCompileClasspath,testCompileClasspath org.osgi:org.osgi.service.serviceloader:1.0.0=compileClasspath,functionalTestCompileClasspath,sampleOffTestCompileClasspath,testCompileClasspath
org.ow2.asm:asm-analysis:9.10.1=spotbugs org.ow2.asm:asm-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=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.ow2.asm:asm:9.7.1=compileClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.pcollections:pcollections:4.0.1=annotationProcessor,conditionalTransportTestAnnotationProcessor,functionalTestAnnotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor org.pcollections:pcollections:4.0.1=annotationProcessor,conditionalTransportTestAnnotationProcessor,functionalTestAnnotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor
org.postgresql:postgresql:42.7.8=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath org.postgresql:postgresql:42.7.8=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.reactivestreams:reactive-streams:1.0.4=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.reactivestreams:reactive-streams:1.0.4=conditionalTransportTestRuntimeClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.reflections:reflections:0.10.2=checkstyle org.reflections:reflections:0.10.2=checkstyle
org.rnorth.duct-tape:duct-tape:1.0.8=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.rnorth.duct-tape:duct-tape:1.0.8=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.skyscreamer:jsonassert:1.5.3=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.skyscreamer:jsonassert:1.5.3=functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.slf4j:jul-to-slf4j:2.0.17=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.slf4j:jul-to-slf4j:2.0.17=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.slf4j:slf4j-api:2.0.17=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath org.slf4j:slf4j-api:2.0.17=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath
org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j
org.springdoc:springdoc-openapi-starter-common:3.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath org.springdoc:springdoc-openapi-starter-common:3.0.0=functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.springdoc:springdoc-openapi-starter-webmvc-api:3.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath org.springdoc:springdoc-openapi-starter-webmvc-api:3.0.0=functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-actuator-autoconfigure:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-actuator-autoconfigure:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-actuator:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-actuator:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-configuration-processor:4.0.0=annotationProcessor org.springframework.boot:spring-boot-configuration-processor:4.0.0=annotationProcessor
org.springframework.boot:spring-boot-data-commons:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-data-commons:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-data-jpa-test:4.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-data-jpa-test:4.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
@@ -283,9 +288,9 @@ org.springframework.boot:spring-boot-flyway:4.0.0=compileClasspath,productionRun
org.springframework.boot:spring-boot-graphql:4.0.0=conditionalTransportTestRuntimeClasspath org.springframework.boot:spring-boot-graphql:4.0.0=conditionalTransportTestRuntimeClasspath
org.springframework.boot:spring-boot-health:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-health:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-hibernate:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-hibernate:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-http-client:4.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-http-client:4.0.0=functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-http-converter:4.0.0=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-http-converter:4.0.0=conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-jackson:4.0.0=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-jackson:4.0.0=conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-jdbc-test:4.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-jdbc-test:4.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-jdbc:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-jdbc:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-jpa-test:4.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-jpa-test:4.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
@@ -293,73 +298,73 @@ org.springframework.boot:spring-boot-jpa:4.0.0=productionRuntimeClasspath,runtim
org.springframework.boot:spring-boot-micrometer-metrics:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-micrometer-metrics:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-micrometer-observation:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-micrometer-observation:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-persistence:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-persistence:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-restclient:4.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-restclient:4.0.0=functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-resttestclient:4.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-resttestclient:4.0.0=functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-security-oauth2-resource-server:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-security-oauth2-resource-server:4.0.0=functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-security:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-security:4.0.0=compileClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-servlet:4.0.0=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-servlet:4.0.0=conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-sql:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-sql:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-actuator:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-actuator:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-data-jpa:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-data-jpa:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-flyway:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-flyway:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-graphql:4.0.0=conditionalTransportTestRuntimeClasspath org.springframework.boot:spring-boot-starter-graphql:4.0.0=conditionalTransportTestRuntimeClasspath
org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-jackson:4.0.0=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-jackson:4.0.0=conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-jdbc:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-jdbc:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-json:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-json:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-logging:4.0.0=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-logging:4.0.0=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-micrometer-metrics:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-micrometer-metrics:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-oauth2-resource-server:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-oauth2-resource-server:4.0.0=functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-security:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-security:4.0.0=compileClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-test:4.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-test:4.0.0=functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-tomcat:4.0.0=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-tomcat:4.0.0=conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-validation:4.0.0=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-validation:4.0.0=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-web:4.0.0=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-web:4.0.0=conditionalTransportTestRuntimeClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-webmvc-test:4.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-webmvc-test:4.0.0=functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-webmvc:4.0.0=conditionalTransportTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-webmvc:4.0.0=conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-websocket:4.0.0=conditionalTransportTestRuntimeClasspath org.springframework.boot:spring-boot-starter-websocket:4.0.0=conditionalTransportTestRuntimeClasspath
org.springframework.boot:spring-boot-starter:4.0.0=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter:4.0.0=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-test:4.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-test:4.0.0=functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-tomcat:4.0.0=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-tomcat:4.0.0=conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-transaction:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-transaction:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-validation:4.0.0=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-validation:4.0.0=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-web-server:4.0.0=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-web-server:4.0.0=conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-webmvc-test:4.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-webmvc-test:4.0.0=functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-webmvc:4.0.0=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-webmvc:4.0.0=conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-websocket:4.0.0=conditionalTransportTestRuntimeClasspath org.springframework.boot:spring-boot-websocket:4.0.0=conditionalTransportTestRuntimeClasspath
org.springframework.boot:spring-boot:4.0.0=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot:4.0.0=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.cloud:spring-cloud-context:4.1.4=sampleOffTestCompileClasspath,testCompileClasspath org.springframework.cloud:spring-cloud-context:4.1.4=sampleOffTestCompileClasspath,testCompileClasspath
org.springframework.data:spring-data-commons:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.data:spring-data-commons:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.data:spring-data-jpa:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.data:spring-data-jpa:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.graphql:spring-graphql:2.0.0=conditionalTransportTestRuntimeClasspath org.springframework.graphql:spring-graphql:2.0.0=conditionalTransportTestRuntimeClasspath
org.springframework.integration:spring-integration-core:7.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.integration:spring-integration-core:7.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.integration:spring-integration-jdbc:7.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.integration:spring-integration-jdbc:7.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.security:spring-security-config:7.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.security:spring-security-config:7.0.0=compileClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.security:spring-security-core:7.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.security:spring-security-core:7.0.0=compileClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.security:spring-security-crypto:7.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.security:spring-security-crypto:7.0.0=compileClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.security:spring-security-oauth2-client:7.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.security:spring-security-oauth2-client:7.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.security:spring-security-oauth2-core:7.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.security:spring-security-oauth2-core:7.0.0=compileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.security:spring-security-oauth2-jose:7.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath org.springframework.security:spring-security-oauth2-jose:7.0.0=functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.springframework.security:spring-security-oauth2-resource-server:7.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath org.springframework.security:spring-security-oauth2-resource-server:7.0.0=functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.springframework.security:spring-security-test:7.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.security:spring-security-test:7.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.security:spring-security-web:7.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.security:spring-security-web:7.0.0=compileClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.session:spring-session-core:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath org.springframework.session:spring-session-core:4.0.0=functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.springframework:spring-aop:7.0.1=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework:spring-aop:7.0.1=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-aspects:7.0.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework:spring-aspects:7.0.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-beans:7.0.1=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework:spring-beans:7.0.1=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-context:7.0.1=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework:spring-context:7.0.1=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-core:7.0.1=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework:spring-core:7.0.1=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-expression:7.0.1=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework:spring-expression:7.0.1=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-jdbc:7.0.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework:spring-jdbc:7.0.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-messaging:7.0.1=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework:spring-messaging:7.0.1=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-orm:7.0.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework:spring-orm:7.0.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-test:7.0.1=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework:spring-test:7.0.1=functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-tx:7.0.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework:spring-tx:7.0.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-web:7.0.1=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework:spring-web:7.0.1=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-webflux:7.0.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath org.springframework:spring-webflux:7.0.1=functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.springframework:spring-webmvc:7.0.1=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework:spring-webmvc:7.0.1=conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-websocket:7.0.1=conditionalTransportTestRuntimeClasspath,sampleOffTestCompileClasspath,testCompileClasspath org.springframework:spring-websocket:7.0.1=conditionalTransportTestRuntimeClasspath,sampleOffTestCompileClasspath,testCompileClasspath
org.testcontainers:testcontainers-database-commons:2.0.2=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.testcontainers:testcontainers-database-commons:2.0.2=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.testcontainers:testcontainers-jdbc:2.0.2=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.testcontainers:testcontainers-jdbc:2.0.2=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
@@ -367,10 +372,10 @@ org.testcontainers:testcontainers-junit-jupiter:2.0.2=sampleOffTestCompileClassp
org.testcontainers:testcontainers-postgresql:2.0.2=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.testcontainers:testcontainers-postgresql:2.0.2=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.testcontainers:testcontainers:2.0.2=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.testcontainers:testcontainers:2.0.2=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs
org.xmlunit:xmlunit-core:2.10.4=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.xmlunit:xmlunit-core:2.10.4=functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.yaml:snakeyaml:2.5=compileClasspath,conditionalTransportTestCompileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.yaml:snakeyaml:2.5=compileClasspath,conditionalTransportTestCompileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
redis.clients.authentication:redis-authx-core:0.1.1-beta2=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath redis.clients.authentication:redis-authx-core:0.1.1-beta2=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
tools.jackson.core:jackson-core:3.0.2=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath tools.jackson.core:jackson-core:3.0.2=conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
tools.jackson.core:jackson-databind:3.0.2=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath tools.jackson.core:jackson-databind:3.0.2=conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
tools.jackson:jackson-bom:3.0.2=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath tools.jackson:jackson-bom:3.0.2=conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
empty=developmentOnly,testAndDevelopmentOnly empty=developmentOnly,testAndDevelopmentOnly
@@ -0,0 +1,895 @@
package dev.caskeleton.bootstrap.contract;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import dev.caskeleton.adapter.inbound.web.config.PresentationWebConfig;
import dev.caskeleton.adapter.inbound.web.envelope.EnvelopeBodyAdvice;
import dev.caskeleton.adapter.inbound.web.settings.PresentationSettings;
import dev.caskeleton.adapter.inbound.web.settings.SecuritySettings;
import dev.caskeleton.adapter.inbound.web.techlog.studio.controller.StudioCatalogController;
import dev.caskeleton.adapter.inbound.web.techlog.studio.controller.StudioSessionController;
import dev.caskeleton.application.techlog.studio.port.out.CatalogQueryPort;
import dev.caskeleton.application.techlog.studio.query.CatalogEntryType;
import dev.caskeleton.application.techlog.studio.query.CatalogPageView;
import dev.caskeleton.application.techlog.studio.service.ListCatalogUseCase;
import dev.caskeleton.application.transaction.TransactionPort;
import dev.caskeleton.bootstrap.contract.support.RepositoryContractResources;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.function.Supplier;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.SpringBootConfiguration;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.security.autoconfigure.SecurityAutoConfiguration;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Import;
import org.springframework.test.web.servlet.MockMvc;
import org.yaml.snakeyaml.Yaml;
/**
* feature-techlog-studio-backend Task 10 the drift gate spec §5.5 calls for: springdoc's
* published {@code /v3/api-docs} is diffed against the vendored {@code
* config/openapi/studio-v1.yaml} for every {@code /api/v1/studio/**} path it actually exposes. Only
* <em>implemented</em> operations are checked (direction is "published ⊆ contract", never the
* reverse), so this stays green as slices 2-5 add the other 17 operations <b>on one
* condition</b>: the new controllers must live somewhere under {@code
* dev.caskeleton.adapter.inbound.web.techlog}, the package {@link
* ContractSurface.ContractSurfaceApp} and {@link EnvelopeWrapping.EnvelopeApp}
* {@code @ComponentScan}. A controller placed there is picked up automatically, with no edit to
* this file. A controller placed <em>outside</em> that package tree is invisible to both minimal
* contexts springdoc never sees it, so this gate stays green even if its path/method/operationId
* contradicts the contract and the {@code @ComponentScan} base package below must be widened (or
* the new controller moved) before this gate can be trusted again. (An earlier draft of this class
* named the two controllers directly via {@code @Import} instead of scanning; that hardcoded list
* had exactly this blind spot confirmed by temporarily reintroducing it and observing a
* controller with an out-of-contract mapping pass silently, see task-10-report.md.) This test also
* fails the moment an in-scan controller's method name drifts from its {@code operationId} or ships
* an endpoint outside the contract.
*
* <h2>Why a hand-built minimal context rather than {@code CaSkeletonApplication}</h2>
*
* <p>This repository's own tests never boot the full app under test: {@code
* FileserverRoundTripContractTest} and {@code ActuatorSecurityHttpTest} (both in this module's
* {@code src/test}) spell out why {@code application.yml} resolves ~50 {@code ${...}} 자리표시자 from
* {@code src/.env} (datasource, OIDC issuer, Redis, messaging, ...), so a full boot drags in
* infrastructure a contract-shape test has nothing to say about. This test follows the same
* playbook: a throwaway {@code @SpringBootConfiguration} that {@code @ComponentScan}s the studio
* web package (so real production controllers like {@link StudioSessionController} and {@link
* StudioCatalogController} are picked up the same way the real app's component scan finds them
* see the class-level "why scan, not @Import" note above), with {@link SecurityAutoConfiguration}
* excluded and MockMvc filters off ({@code addFilters = false}) the exclude/addFilters
* combination {@code EnvelopeBodyAdviceTest} and {@code NoResourceFoundErrorHandlingTest}
* (adapter-inbound-web's own test sourceSet) already use for this class of test.
*
* <p>Because this functionalTest module depends only on {@code :adapter:inbound:web} and {@code
* :application-core} (not {@code :adapter:outbound:persistence-jpa}, cache, or messaging), none of
* the DataSource/Flyway/Redis auto-configuration classes are even on the classpath for
* {@code @EnableAutoConfiguration} to attempt there is nothing to exclude for them, unlike {@code
* ActuatorSecurityHttpTest}'s explicit JPA/Flyway exclude list.
*
* <p>{@code @SpringBootTest} (full, unsliced {@code @EnableAutoConfiguration}) is used instead of
* {@code @WebMvcTest}: springdoc's own auto-configuration is a third-party {@code
* AutoConfiguration.imports} entry, not part of Boot's curated {@code @WebMvcTest} slice allowlist,
* so {@code /v3/api-docs} would not be exposed under a sliced test. A full, unsliced context that
* only sees two modules' worth of dependencies keeps the cost bounded to "start web MVC +
* springdoc" without paying for DB/security infrastructure.
*
* <h2>Why two nested contexts instead of one</h2>
*
* <p>The obvious design is one shared {@code @SpringBootTest} context for both tests. That does not
* work here, and the reason is worth recording: springdoc's {@code /v3/api-docs} handler ({@code
* OpenApiWebMvcResource.openapiJson}) returns raw {@code byte[]} it serializes the OpenAPI model
* itself and hands Spring MVC already-encoded bytes. {@link EnvelopeBodyAdvice#supports} returns
* {@code true} unconditionally (by design it wraps every controller response in the real app, not
* just Studio's), so if it is on the classpath of *that* request it rewrites the body from {@code
* byte[]} to {@code Envelope<byte[]>} but Spring MVC picks the {@code HttpMessageConverter} from
* the *original* return type before the advice runs, so {@code ByteArrayHttpMessageConverter}
* (already selected for {@code byte[]}) is then asked to write an {@code Envelope}, and {@code
* writeInternal} throws {@code ClassCastException: Envelope cannot be cast to [B}. This reproduced
* with a full stack trace during this task (see task-10-report.md) it is a real, pre-existing
* defect in shared skeleton code ({@code EnvelopeBodyAdvice} is not Studio-owned and not part of
* this task's brief), not an artifact of this test's plumbing: any app that boots both springdoc
* and {@code EnvelopeBodyAdvice} together and serves {@code /v3/api-docs} unauthenticated would hit
* the same crash. Fixing that advice is out of scope for a contract-regression test, so {@link
* ContractSurface} boots a context *without* {@link EnvelopeBodyAdvice} (springdoc doesn't invoke
* it for anything test 1 checks anyway introspection is pure reflection over the mapping), and
* {@link EnvelopeWrapping} boots a separate context *with* it, hitting {@link
* StudioCatalogController} instead, whose {@code CatalogPage} return type is an ordinary POJO that
* the same JSON converter handles before and after wrapping.
*
* <h2>Why {@link ListCatalogUseCase} is real, not mocked</h2>
*
* <p>{@link StudioCatalogController}'s constructor takes the concrete (non-interface) {@code
* ListCatalogUseCase}, so there is no seam to substitute a fake at that boundary. Its own two
* constructor collaborators, {@link CatalogQueryPort} and {@link TransactionPort}, <em>are</em>
* interfaces (application ports), so this test wires trivial in-memory implementations of those
* instead of pulling in a real persistence adapter springdoc never invokes a controller method to
* build {@code /v3/api-docs} (pure reflection over the mapping/return-type shape), and the envelope
* test only needs the query to return successfully, not to hold meaningful catalog data.
*
* <h2>Second test: envelope wrapping, without real DB/auth infrastructure</h2>
*
* <p>The brief's original template hits the endpoint through {@code TestRestTemplate} against a
* fully DB+auth-backed app; this functionalTest sourceSet has neither (confirmed: before this task
* it declared only {@code gradleTestKit()} + JUnit + AssertJ, no Spring dependency at all). Rather
* than skip the envelope assertion or force real persistence/security infrastructure into a
* contract-shape test, {@link EnvelopeWrapping} proves the same regression the brief wants {@link
* EnvelopeBodyAdvice} still wraps {@link StudioCatalogController}'s response against a minimal
* slice. Envelope wrapping is a {@code ResponseBodyAdvice} concern that is orthogonal to
* persistence and authentication, so stubbing those out does not weaken what the assertion proves,
* and no production security surface changes: {@code SECURITY_PUBLIC_PATHS} is untouched, and this
* slice simply never wires a {@code SecurityFilterChain} at all (same as the two
* adapter-inbound-web precedents cited above), rather than widening what unauthenticated callers
* may reach in the real app.
*/
class StudioContractDriftTest {
@Nested
@SpringBootTest(classes = ContractSurface.ContractSurfaceApp.class)
@AutoConfigureMockMvc(addFilters = false)
class ContractSurface {
@Autowired private MockMvc mvc;
/**
* "구현된 것만 검사" 방향 고정: 계약(19개 operation) 아니라 published(springdoc이 실제로 내놓는 , 지금은 2개) 순회한다. 슬라이스
* 2~5가 나머지 17개를 추가해도 순회 방향 덕분에 테스트는 그대로 통과한다 반대로 순회했다면 미구현 operation마다 매번 실패했을 것이다.
*/
@Test
void publishedStudioOperationsMatchTheContract() throws Exception {
JsonNode contract = readContract();
JsonNode published = readPublishedApiDocs();
List<String> problems = new ArrayList<>();
List<String> compared = new ArrayList<>();
JsonNode publishedPaths = published.path("paths");
for (Map.Entry<String, JsonNode> path : publishedPaths.properties()) {
if (!path.getKey().startsWith("/api/v1/studio/")) {
continue;
}
compared.add(path.getKey());
JsonNode contractPath = contract.path("paths").path(path.getKey());
if (contractPath.isMissingNode()) {
problems.add("계약에 없는 path: " + path.getKey());
continue;
}
for (Map.Entry<String, JsonNode> method : path.getValue().properties()) {
JsonNode contractOp = contractPath.path(method.getKey());
if (contractOp.isMissingNode()) {
problems.add("계약에 없는 method: " + method.getKey() + " " + path.getKey());
continue;
}
String publishedId = method.getValue().path("operationId").asText("");
String contractId = contractOp.path("operationId").asText("");
if (!publishedId.equals(contractId)) {
problems.add(
"operationId 불일치 "
+ method.getKey()
+ " "
+ path.getKey()
+ ": published="
+ publishedId
+ " contract="
+ contractId);
}
}
}
assertThat(problems).isEmpty();
// 순회는 "/api/v1/studio/" 시작하는 published path 본다. prefix 배선이 빠지면
// 모든 경로가 필터에 걸러져 아무것도 비교하지 않은 green 된다 게이트가 드리프트를
// "못 보는" 상태와 "없는" 상태가 구별되지 않는다. 그래서 최소 하나는 실제로 대조했음을 함께 고정한다.
assertThat(compared)
.as(
"published 표면에서 /api/v1/studio/ 경로를 하나도 대조하지 못했다 —"
+ " PresentationWebConfig 의 api-base-path 배선이 빠졌는지 확인하라."
+ " published paths="
+ publishedPaths.properties().stream().map(Map.Entry::getKey).toList())
.isNotEmpty();
}
/**
* 반대 방향 계약의 operation 전부 published 표면에 있는가.
*
* <p>슬라이스 2~5 끝나 19개 operation 모두 구현됐으므로 이제 "published ⊆ 계약" 방향만으로는 부족하다. 방향은
* <b>사라진</b> operation 잡지 못한다 컨트롤러를 지우거나 매핑을 잘못 옮겨도 남은 것들이 계약과 맞으면 통과한다. 양방향이 되어야 게이트가
* 완성된다.
*
* <p> operation 계약에 추가하면 테스트가 먼저 빨간불이 된다. 그게 의도다 계약이 약속한 것을 서버가 아직 제공하지 않는다는 사실이 배포 전에
* 드러나야 한다.
*/
@Test
void everyContractOperationIsPublished() throws Exception {
JsonNode contract = readContract();
JsonNode published = readPublishedApiDocs();
List<String> missing = new ArrayList<>();
for (Map.Entry<String, JsonNode> path : contract.path("paths").properties()) {
for (Map.Entry<String, JsonNode> method : path.getValue().properties()) {
JsonNode operationId = method.getValue().path("operationId");
if (operationId.isMissingNode()) {
continue;
}
JsonNode publishedOperation =
published.path("paths").path(path.getKey()).path(method.getKey());
if (publishedOperation.isMissingNode()
|| !operationId.asText().equals(publishedOperation.path("operationId").asText(""))) {
missing.add(operationId.asText() + " (" + method.getKey() + " " + path.getKey() + ")");
}
}
}
assertThat(missing).as("계약이 약속했는데 서버가 제공하지 않는 operation").isEmpty();
}
/**
* SnakeYaml(이미 {@code StudioErrorRegistryTest} error-codes.yaml에 쓰는 라이브러리) 읽은 {@code
* ObjectMapper#valueToTree} {@link JsonNode} 옮긴다 {@code jackson-dataformat-yaml} 컴파일
* 의존으로 끌어오지 않고 기존 라이브러리 조합만으로 브리프 템플릿과 같은 {@code JsonNode} 기반 대조 로직을 있다({@code
* jackson-dataformat-yaml} 모듈의 {@code compileClasspath} 아니라 {@code runtimeClasspath}에만
* 전이적으로 있었다 springdoc이 YAML 응답을 만들 때만 필요해서다).
*/
private static JsonNode readContract() throws Exception {
Path contractFile =
RepositoryContractResources.fromSystemProperty()
.requireTrackedFile("src/config/openapi/studio-v1.yaml");
Map<String, Object> contractYaml;
try (InputStream in = Files.newInputStream(contractFile)) {
contractYaml = new Yaml().load(in);
}
return new ObjectMapper().valueToTree(contractYaml);
}
/**
* {@code /api/v3/api-docs} springdoc 엔드포인트에도 {@link PresentationWebConfig} {@code
* api-base-path} 붙는다. 실제 앱에서 published 문서가 실제로 서비스되는 주소이며, 여기서 {@code /v3/api-docs} 두드리면
* 404 난다.
*/
private JsonNode readPublishedApiDocs() throws Exception {
String body =
mvc.perform(get("/api/v3/api-docs"))
.andExpect(status().isOk())
.andReturn()
.getResponse()
.getContentAsString();
return new ObjectMapper().readTree(body);
}
/**
* {@code @Import} 컨트롤러를 하나씩 나열하지 않고 {@code @ComponentScan}으로 studio web 패키지 전체를 스캔한다. 나열 방식은
* 슬라이스 2~5가 컨트롤러를 추가해도 파일을 고치지 않는 published 표면에 잡히는 채로 green을 유지하는 함정이 있었다 드리프트가 "없는"
* 아니라 게이트가 "못 보는" 상태였다. 리뷰에서 패키지 아래에 계약 매핑을 가진 임시 컨트롤러를 하나 추가해(어떤
* {@code @Import}/{@code @ComponentScan} 목록에도 넣고) 실측으로 확인했다 {@code @Import} 목록으로는 테스트가
* 통과, {@code @ComponentScan}으로는 실패. 재현 절차와 결과 모두 task-10-report.md의 "자동 포함 성질 RED 검증" 절에 남아
* 있다. {@code EnvelopeBodyAdvice} 패키지 ({@code ...web.envelope})이라 스캔에 걸린다 일부러 두지 않는다(클래스
* javadoc "Why two nested contexts" 참조). springdoc은 리플렉션만 하므로 test1엔 애초에 관여하지 않는다.
*/
@SpringBootConfiguration
@EnableAutoConfiguration(exclude = SecurityAutoConfiguration.class)
@ComponentScan("dev.caskeleton.adapter.inbound.web.techlog")
@Import({PresentationWebConfig.class, StudioContractDriftTest.StudioDocumentTestBeans.class})
static class ContractSurfaceApp {
/**
* 실제 앱의 {@code PRESENTATION_API_BASE_PATH}. {@link PresentationWebConfig} 값을 모든 컨트롤러 매핑에
* 붙이므로, published 표면의 경로는 컨트롤러가 선언한 {@code /v1/studio/...} 아니라 계약이 선언한 {@code
* /api/v1/studio/...} 여야 한다.
*/
@Bean
PresentationSettings presentationSettings() {
return new PresentationSettings("/api");
}
@Bean
SecuritySettings securitySettings() {
return StudioContractDriftTest.securitySettingsForTest();
}
@Bean
ListCatalogUseCase listCatalogUseCase() {
return StudioContractDriftTest.listCatalogUseCaseForTest();
}
}
}
@Nested
@SpringBootTest(classes = EnvelopeWrapping.EnvelopeApp.class)
@AutoConfigureMockMvc(addFilters = false)
class EnvelopeWrapping {
@Autowired private MockMvc mvc;
@Test
void everyStudioResponseIsWrappedInTheEnvelope() throws Exception {
String body =
mvc.perform(get("/api/v1/studio/catalog").param("type", "TOPIC"))
.andExpect(status().isOk())
.andReturn()
.getResponse()
.getContentAsString();
assertThat(body).contains("\"success\"").contains("\"data\"").contains("\"meta\"");
assertThat(body).doesNotContain("\"data\":{\"success\"");
}
/**
* {@code adapter-inbound-web} 실제 애플리케이션이 없어({@code CaSkeletonApplication} app-bootstrap 소유)
* {@code @SpringBootTest} 부트스트랩할 {@code @SpringBootConfiguration} 필요하다 {@code
* StudioSessionEnvelopeTest}(adapter-inbound-web 자체 테스트) 쓰는 것과 같은 이유의 같은 패턴. {@link
* ContractSurface.ContractSurfaceApp} 마찬가지로 {@code @ComponentScan}으로 studio web 패키지를 스캔하고,
* {@link EnvelopeBodyAdvice} 별도로 {@code @Import}한다(스캔 범위 패키지라서) 컨텍스트는 {@code
* /v3/api-docs} 두드리지 않으므로 클래스 javadoc이 설명하는 {@code byte[]} 크래시를 겪지 않는다.
*/
@SpringBootConfiguration
@EnableAutoConfiguration(exclude = SecurityAutoConfiguration.class)
@ComponentScan("dev.caskeleton.adapter.inbound.web.techlog")
@Import({
EnvelopeBodyAdvice.class,
PresentationWebConfig.class,
StudioContractDriftTest.StudioDocumentTestBeans.class
})
static class EnvelopeApp {
/**
* 실제 앱의 {@code PRESENTATION_API_BASE_PATH}. {@link PresentationWebConfig} 값을 모든 컨트롤러 매핑에
* 붙이므로, published 표면의 경로는 컨트롤러가 선언한 {@code /v1/studio/...} 아니라 계약이 선언한 {@code
* /api/v1/studio/...} 여야 한다.
*/
@Bean
PresentationSettings presentationSettings() {
return new PresentationSettings("/api");
}
@Bean
SecuritySettings securitySettings() {
return StudioContractDriftTest.securitySettingsForTest();
}
@Bean
ListCatalogUseCase listCatalogUseCase() {
return StudioContractDriftTest.listCatalogUseCaseForTest();
}
}
}
/**
* {@code csrfHeaderName} 계약 const {@code X-CSRF-TOKEN} 다르면 {@link StudioSessionController}
* 생성자가 즉시 실패한다.
*/
private static SecuritySettings securitySettingsForTest() {
SecuritySettings.SessionCookieSettings session =
new SecuritySettings.SessionCookieSettings(
null, null, null, null, null, null, "X-CSRF-TOKEN");
return new SecuritySettings(
SecuritySettings.AuthenticationMode.JWT,
"https://issuer.example",
null,
List.of(),
session);
}
/**
* 실제 영속성 어댑터 대신 최소 stub 포트로 구성한 진짜 {@link ListCatalogUseCase}. springdoc은 컨트롤러 메서드를 호출하지 않고 리플렉션만
* 하므로 번째 테스트에는 아예 관여하지 않고, 번째 테스트(봉투 확인) 결과 내용이 아니라 감싸는 모양만 보므로 목록으로 충분하다.
*/
private static ListCatalogUseCase listCatalogUseCaseForTest() {
return new ListCatalogUseCase(new StubCatalogQueryPort(), new PassThroughTransactionPort());
}
/**
* 슬라이스 2의 {@code StudioDocumentController} {@code @ComponentScan} 걸리면서 필요해진 협력자들.
*
* <p> 비용은 게이트가 "패키지를 스캔한다" 성질의 뒷면이다 컨트롤러가 자동으로 감시 대상이 되는 대신, 컨트롤러의 협력자를 여기에 채워야 컨텍스트가
* 뜬다. 채우지 않으면 게이트가 통과가 아니라 실패로 알려준다.
*
* <p>포트 구현은 전부 stub 이다. 번째 테스트는 springdoc 리플렉션이라 컨트롤러 메서드를 아예 호출하지 않고, 번째 테스트는 catalog
* 엔드포인트만 두드린다.
*/
@org.springframework.context.annotation.Configuration(proxyBeanMethods = false)
static class StudioDocumentTestBeans {
@Bean
java.time.Clock studioTestClock() {
return java.time.Clock.systemUTC();
}
@Bean
dev.caskeleton.adapter.inbound.web.techlog.studio.support.StudioSettings studioSettings() {
return new dev.caskeleton.adapter.inbound.web.techlog.studio.support.StudioSettings(
"functional-test-cursor-signing-key", null, null);
}
@Bean
dev.caskeleton.adapter.inbound.web.idempotency.IdempotencyKeySupport idempotencyKeySupport(
tools.jackson.databind.ObjectMapper objectMapper) {
return new dev.caskeleton.adapter.inbound.web.idempotency.IdempotencyKeySupport(objectMapper);
}
@Bean
dev.caskeleton.application.techlog.studio.service.ListStudioDocumentsUseCase
listStudioDocumentsUseCase() {
return new dev.caskeleton.application.techlog.studio.service.ListStudioDocumentsUseCase(
query ->
new dev.caskeleton.application.techlog.studio.model.DocumentPageView(List.of(), null),
new PassThroughTransactionPort());
}
@Bean
dev.caskeleton.application.techlog.studio.service.CreateStudioDocumentUseCase
createStudioDocumentUseCase() {
return new dev.caskeleton.application.techlog.studio.service.CreateStudioDocumentUseCase(
new StubWorkingCopyRepositoryPort(), new PassThroughTransactionPort());
}
@Bean
dev.caskeleton.application.techlog.studio.service.GetStudioDocumentUseCase
getStudioDocumentUseCase() {
return new dev.caskeleton.application.techlog.studio.service.GetStudioDocumentUseCase(
new StubWorkingCopyRepositoryPort(), assembler(), new PassThroughTransactionPort());
}
@Bean
dev.caskeleton.application.techlog.studio.service.SaveStudioDocumentUseCase
saveStudioDocumentUseCase() {
return new dev.caskeleton.application.techlog.studio.service.SaveStudioDocumentUseCase(
new StubWorkingCopyRepositoryPort(), assembler(), new PassThroughTransactionPort());
}
@Bean
dev.caskeleton.application.techlog.studio.service.StudioDocumentLoader studioDocumentLoader() {
return new dev.caskeleton.application.techlog.studio.service.StudioDocumentLoader(
new StubWorkingCopyRepositoryPort());
}
@Bean
dev.caskeleton.application.techlog.studio.port.out.StudioDependencyResolverPort
studioDependencyResolverPort() {
return (document, keys) ->
new dev.caskeleton.application.techlog.studio.port.out.StudioDependencyResolverPort
.Resolved(
null,
false,
null,
false,
List.of(),
List.of(),
java.util.Map.of(),
java.util.Map.of(),
null,
null,
null);
}
@Bean
dev.caskeleton.application.techlog.studio.service.ValidateStudioDocumentUseCase
validateStudioDocumentUseCase(
dev.caskeleton.application.techlog.studio.service.StudioDocumentLoader documents,
dev.caskeleton.application.techlog.studio.port.out.StudioDependencyResolverPort
dependencies,
dev.caskeleton.application.techlog.studio.port.out.ContentAnalyzerPort
contentAnalyzer) {
return new dev.caskeleton.application.techlog.studio.service.ValidateStudioDocumentUseCase(
documents,
dependencies,
contentAnalyzer,
(kind, id) -> "test-dependency-revision",
new StubValidationArtifactPort(),
new PassThroughTransactionPort(),
java.util.UUID::randomUUID,
java.time.Clock.systemUTC(),
java.time.Duration.ofHours(1));
}
@Bean
dev.caskeleton.application.techlog.studio.service.CreateStudioPreviewUseCase
createStudioPreviewUseCase(
dev.caskeleton.application.techlog.studio.service.StudioDocumentLoader documents,
dev.caskeleton.application.techlog.studio.port.out.StudioDependencyResolverPort
dependencies,
dev.caskeleton.application.techlog.studio.port.out.ContentAnalyzerPort contentAnalyzer,
dev.caskeleton.application.techlog.studio.port.out.RenderModelPort renderer) {
return new dev.caskeleton.application.techlog.studio.service.CreateStudioPreviewUseCase(
documents,
dependencies,
contentAnalyzer,
(kind, id) -> "test-dependency-revision",
new StubValidationArtifactPort(),
new StubPreviewArtifactPort(),
renderer,
new PassThroughTransactionPort(),
java.util.UUID::randomUUID,
java.time.Clock.systemUTC(),
java.time.Duration.ofHours(24));
}
@Bean
dev.caskeleton.application.techlog.studio.service.GetCurrentStudioPreviewUseCase
getCurrentStudioPreviewUseCase(
dev.caskeleton.application.techlog.studio.service.StudioDocumentLoader documents) {
return new dev.caskeleton.application.techlog.studio.service.GetCurrentStudioPreviewUseCase(
documents,
new StubPreviewArtifactPort(),
new StubValidationArtifactPort(),
(kind, id) -> "test-dependency-revision",
new PassThroughTransactionPort(),
java.time.Clock.systemUTC());
}
@Bean
dev.caskeleton.application.techlog.studio.port.out.PublicationHistoryQueryPort
publicationHistoryQueryPort() {
return new StubPublicationHistoryQueryPort();
}
@Bean
dev.caskeleton.application.techlog.studio.service.PublishStudioDocumentUseCase
publishStudioDocumentUseCase(
dev.caskeleton.application.techlog.studio.service.StudioDocumentLoader documents,
dev.caskeleton.application.techlog.studio.port.out.StudioDependencyResolverPort
dependencies,
dev.caskeleton.application.techlog.studio.port.out.ContentAnalyzerPort
contentAnalyzer) {
return new dev.caskeleton.application.techlog.studio.service.PublishStudioDocumentUseCase(
documents,
dependencies,
contentAnalyzer,
(kind, id) -> "test-dependency-revision",
new StubValidationArtifactPort(),
new StubPreviewArtifactPort(),
new StubPublicationWriterPort(),
new PassThroughTransactionPort(),
java.time.Clock.systemUTC());
}
@Bean
dev.caskeleton.application.techlog.studio.service.UnpublishStudioPublicationUseCase
unpublishStudioPublicationUseCase(
dev.caskeleton.application.techlog.studio.port.out.PublicationHistoryQueryPort history,
dev.caskeleton.application.techlog.studio.service.StudioDocumentLoader documents) {
return new dev.caskeleton.application.techlog.studio.service
.UnpublishStudioPublicationUseCase(
history, new StubPublicationWriterPort(), documents, new PassThroughTransactionPort());
}
@Bean
dev.caskeleton.application.techlog.studio.service.ListStudioPublicationsUseCase
listStudioPublicationsUseCase(
dev.caskeleton.application.techlog.studio.port.out.PublicationHistoryQueryPort
history) {
return new dev.caskeleton.application.techlog.studio.service.ListStudioPublicationsUseCase(
history, new PassThroughTransactionPort());
}
@Bean
dev.caskeleton.application.techlog.studio.service.GetStudioPublicationSnapshotUseCase
getStudioPublicationSnapshotUseCase(
dev.caskeleton.application.techlog.studio.port.out.PublicationHistoryQueryPort
history) {
return new dev.caskeleton.application.techlog.studio.service
.GetStudioPublicationSnapshotUseCase(history, new PassThroughTransactionPort());
}
@Bean
dev.caskeleton.application.techlog.studio.service.GetStudioDashboardUseCase
getStudioDashboardUseCase(
dev.caskeleton.application.techlog.studio.port.out.PublicationHistoryQueryPort
history) {
return new dev.caskeleton.application.techlog.studio.service.GetStudioDashboardUseCase(
new StubStudioDashboardQueryPort(), history, new PassThroughTransactionPort());
}
@Bean
dev.caskeleton.application.techlog.studio.port.out.AssetRepositoryPort assetRepositoryPort() {
return new StubAssetRepositoryPort();
}
@Bean
dev.caskeleton.application.techlog.studio.port.out.AssetBinaryStoragePort
assetBinaryStoragePort() {
return new dev.caskeleton.application.techlog.studio.port.out.AssetBinaryStoragePort() {
@Override
public String store(String objectKey, byte[] content, String mediaType) {
return objectKey;
}
@Override
public void delete(String objectKey) {
// 게이트는 바이너리를 다루지 않는다.
}
};
}
@Bean
dev.caskeleton.application.techlog.studio.service.ListStudioAssetsUseCase
listStudioAssetsUseCase(
dev.caskeleton.application.techlog.studio.port.out.AssetRepositoryPort assets) {
return new dev.caskeleton.application.techlog.studio.service.ListStudioAssetsUseCase(
assets, new PassThroughTransactionPort());
}
@Bean
dev.caskeleton.application.techlog.studio.service.UploadStudioAssetUseCase
uploadStudioAssetUseCase(
dev.caskeleton.application.techlog.studio.port.out.AssetRepositoryPort assets,
dev.caskeleton.application.techlog.studio.port.out.AssetBinaryStoragePort binaries) {
return new dev.caskeleton.application.techlog.studio.service.UploadStudioAssetUseCase(
assets, binaries, new PassThroughTransactionPort(), java.util.UUID::randomUUID);
}
@Bean
dev.caskeleton.application.techlog.studio.service.GetStudioAssetUseCase getStudioAssetUseCase(
dev.caskeleton.application.techlog.studio.port.out.AssetRepositoryPort assets) {
return new dev.caskeleton.application.techlog.studio.service.GetStudioAssetUseCase(
assets, new PassThroughTransactionPort());
}
@Bean
dev.caskeleton.application.techlog.studio.service.UpdateStudioAssetUseCase
updateStudioAssetUseCase(
dev.caskeleton.application.techlog.studio.port.out.AssetRepositoryPort assets) {
return new dev.caskeleton.application.techlog.studio.service.UpdateStudioAssetUseCase(
assets, new PassThroughTransactionPort());
}
@Bean
dev.caskeleton.application.techlog.studio.service.DeleteStudioAssetUseCase
deleteStudioAssetUseCase(
dev.caskeleton.application.techlog.studio.port.out.AssetRepositoryPort assets,
dev.caskeleton.application.techlog.studio.port.out.AssetBinaryStoragePort binaries) {
return new dev.caskeleton.application.techlog.studio.service.DeleteStudioAssetUseCase(
assets, binaries, new PassThroughTransactionPort());
}
private static dev.caskeleton.application.techlog.studio.service.WorkingCopyDetailAssembler
assembler() {
return new dev.caskeleton.application.techlog.studio.service.WorkingCopyDetailAssembler(
new StubValidationArtifactPort(),
new StubPreviewArtifactPort(),
documentId -> java.util.Optional.empty(),
(kind, documentId) -> "test-dependency-revision",
java.time.Clock.systemUTC());
}
}
private static final class StubWorkingCopyRepositoryPort
implements dev.caskeleton.application.techlog.studio.port.out.WorkingCopyRepositoryPort {
@Override
public java.util.Optional<dev.caskeleton.application.techlog.studio.model.RecordKind> findKind(
java.util.UUID documentId) {
return java.util.Optional.empty();
}
@Override
public java.util.Optional<dev.caskeleton.application.techlog.studio.model.WorkingCopyView> find(
java.util.UUID documentId) {
return java.util.Optional.empty();
}
@Override
public dev.caskeleton.application.techlog.studio.model.WorkingCopyView create(
dev.caskeleton.application.techlog.studio.model.WorkingCopyInputView input,
String principal) {
throw new UnsupportedOperationException("this contract-shape gate never creates a document");
}
@Override
public java.util.Optional<dev.caskeleton.application.techlog.studio.model.WorkingCopyView> save(
java.util.UUID documentId,
long expectedVersion,
dev.caskeleton.application.techlog.studio.model.WorkingCopyInputView input,
String principal) {
return java.util.Optional.empty();
}
}
private static final class StubValidationArtifactPort
implements dev.caskeleton.application.techlog.studio.port.out.ValidationArtifactPort {
@Override
public java.util.Optional<dev.caskeleton.application.techlog.studio.model.ValidationReportView>
latestFor(
dev.caskeleton.application.techlog.studio.model.RecordKind kind,
java.util.UUID documentId) {
return java.util.Optional.empty();
}
@Override
public java.util.Optional<dev.caskeleton.application.techlog.studio.model.ValidationReportView>
findById(java.util.UUID validationId) {
return java.util.Optional.empty();
}
@Override
public dev.caskeleton.application.techlog.studio.model.ValidationReportView save(
dev.caskeleton.application.techlog.studio.model.RecordKind kind,
dev.caskeleton.application.techlog.studio.model.ValidationReportView report,
String principal) {
return report;
}
}
private static final class StubPreviewArtifactPort
implements dev.caskeleton.application.techlog.studio.port.out.PreviewArtifactPort {
@Override
public java.util.Optional<dev.caskeleton.application.techlog.studio.model.PublicPreviewView>
latestFor(
dev.caskeleton.application.techlog.studio.model.RecordKind kind,
java.util.UUID documentId) {
return java.util.Optional.empty();
}
@Override
public java.util.Optional<dev.caskeleton.application.techlog.studio.model.PublicPreviewView>
findById(java.util.UUID previewId) {
return java.util.Optional.empty();
}
@Override
public dev.caskeleton.application.techlog.studio.model.PublicPreviewView save(
dev.caskeleton.application.techlog.studio.model.RecordKind kind,
dev.caskeleton.application.techlog.studio.model.PublicPreviewView preview,
String principal) {
return preview;
}
}
private static final class StubPublicationWriterPort
implements dev.caskeleton.application.techlog.studio.port.out.PublicationWriterPort {
@Override
public java.util.Optional<
dev.caskeleton.application.techlog.studio.model.PublicationAggregateView>
lockCurrentPublication(
dev.caskeleton.application.techlog.studio.model.RecordKind kind,
java.util.UUID documentId) {
return java.util.Optional.empty();
}
@Override
public dev.caskeleton.application.techlog.studio.model.PublishResultView publish(
PublishRequest request) {
throw new UnsupportedOperationException("this contract-shape gate never publishes");
}
@Override
public dev.caskeleton.application.techlog.studio.model.PublishResultView unpublish(
UnpublishRequest request) {
throw new UnsupportedOperationException("this contract-shape gate never unpublishes");
}
}
private static final class StubPublicationHistoryQueryPort
implements dev.caskeleton.application.techlog.studio.port.out.PublicationHistoryQueryPort {
@Override
public dev.caskeleton.application.techlog.studio.model.PublicationPageView list(
dev.caskeleton.application.techlog.studio.query.ListPublicationsQuery query) {
return new dev.caskeleton.application.techlog.studio.model.PublicationPageView(
List.of(), null);
}
@Override
public java.util.Optional<
dev.caskeleton.application.techlog.studio.model.PublicationSnapshotView>
findSnapshot(java.util.UUID publicationEventId) {
return java.util.Optional.empty();
}
@Override
public java.util.Optional<
dev.caskeleton.application.techlog.studio.model.PublicationAggregateView>
findById(java.util.UUID publicationId) {
return java.util.Optional.empty();
}
}
private static final class StubStudioDashboardQueryPort
implements dev.caskeleton.application.techlog.studio.port.out.StudioDashboardQueryPort {
@Override
public List<dev.caskeleton.application.techlog.studio.model.DocumentSummaryView>
topByNextAction(
List<dev.caskeleton.application.techlog.studio.model.NextAction> actions, int limit) {
return List.of();
}
@Override
public dev.caskeleton.application.techlog.studio.model.DashboardTotalsView totals() {
return new dev.caskeleton.application.techlog.studio.model.DashboardTotalsView(0, 0, 0, 0);
}
}
private static final class StubAssetRepositoryPort
implements dev.caskeleton.application.techlog.studio.port.out.AssetRepositoryPort {
@Override
public dev.caskeleton.application.techlog.studio.model.AssetPageView list(
dev.caskeleton.application.techlog.studio.query.ListAssetsQuery query) {
return new dev.caskeleton.application.techlog.studio.model.AssetPageView(List.of(), null);
}
@Override
public java.util.Optional<dev.caskeleton.application.techlog.studio.model.AssetView> find(
java.util.UUID assetId) {
return java.util.Optional.empty();
}
@Override
public java.util.Optional<dev.caskeleton.application.techlog.studio.model.AssetDetailView>
findDetail(java.util.UUID assetId) {
return java.util.Optional.empty();
}
@Override
public dev.caskeleton.application.techlog.studio.model.AssetView create(
NewAsset asset, String principal) {
throw new UnsupportedOperationException("this contract-shape gate never stores an asset");
}
@Override
public java.util.Optional<dev.caskeleton.application.techlog.studio.model.AssetView> update(
java.util.UUID assetId,
long expectedVersion,
dev.caskeleton.application.techlog.studio.model.AssetKindView kind,
String altText,
boolean altTextProvided,
Boolean decorative,
dev.caskeleton.application.techlog.studio.model.AssetManagementStatusView managementStatus,
String principal) {
return java.util.Optional.empty();
}
@Override
public java.util.Optional<String> findObjectKey(java.util.UUID assetId) {
return java.util.Optional.empty();
}
@Override
public void delete(java.util.UUID assetId) {
// 게이트는 삭제하지 않는다.
}
}
private static final class StubCatalogQueryPort implements CatalogQueryPort {
@Override
public CatalogPageView search(CatalogEntryType type, String query, String cursor, int limit) {
return new CatalogPageView(List.of(), null);
}
}
/** 실제 트랜잭션 관리자 없이 액션을 곧장 실행한다 — 이 슬라이스에는 커밋/롤백할 트랜잭션 리소스가 없다. */
private static final class PassThroughTransactionPort implements TransactionPort {
@Override
public <T> T inWrite(Supplier<T> action) {
return action.get();
}
@Override
public <T> T inRootWrite(Supplier<T> action) {
return action.get();
}
@Override
public <T> T inRead(Supplier<T> action) {
return action.get();
}
@Override
public <T> T inNew(Supplier<T> action) {
return action.get();
}
}
}
@@ -0,0 +1,51 @@
package dev.caskeleton.bootstrap.techlog;
import dev.caskeleton.application.storage.ObjectStoragePort;
import dev.caskeleton.application.techlog.error.StudioError;
import dev.caskeleton.application.techlog.error.StudioException;
import dev.caskeleton.application.techlog.studio.port.out.AssetBinaryStoragePort;
import org.springframework.beans.factory.ObjectProvider;
/**
* Studio Asset 바이너리를 기존 object storage 어댑터에 위임한다(spec §9 저장 계층을 만들지 않는다).
*
* <p> 브리지가 app-bootstrap 있는 이유: 기존 포트를 잇는 <b>구성</b>이라 어느 한쪽 어댑터 모듈의 소유가 아니다. objectstorage 모듈은
* techlog 모르고, techlog 영속 모듈은 저장 백엔드를 모른다.
*
* <p>{@code ObjectStoragePort} {@code @Deprecated(forRemoval = true)} . 그럼에도 쓰는 이유는 저장소에서 실제로
* 동작하는 어댑터(filesystem/S3) 붙어 있는 유일한 포트이기 때문이다 후속 {@code objectstorage.port.*} 계열에는 아직 구현이
* 없다(실측). 선택을 클래스에 가둬 두었으므로 API 옮길 바뀌는 것은 여기뿐이다.
*
* <p>저장 백엔드가 구성되지 않은 배포에서는 빈이 없다. 그때는 업로드·삭제만 {@code STUDIO_UNAVAILABLE} 거절하고 목록·조회·메타데이터 수정은 그대로
* 동작한다 없는 기능 때문에 있는 기능까지 막지 않는다.
*/
@SuppressWarnings("removal")
final class ObjectStorageAssetBinaryAdapter implements AssetBinaryStoragePort {
private final ObjectProvider<ObjectStoragePort> objectStorage;
ObjectStorageAssetBinaryAdapter(ObjectProvider<ObjectStoragePort> objectStorage) {
this.objectStorage = objectStorage;
}
@Override
public String store(String objectKey, byte[] content, String mediaType) {
return require().put(objectKey, content, mediaType).key();
}
@Override
public void delete(String objectKey) {
require().delete(objectKey);
}
private ObjectStoragePort require() {
ObjectStoragePort port = objectStorage.getIfAvailable();
if (port == null) {
throw StudioException.of(
StudioError.STUDIO_UNAVAILABLE,
"no object storage backend is configured; set ca-skeleton.objectstorage.* to enable"
+ " Studio asset uploads");
}
return port;
}
}
@@ -0,0 +1,253 @@
package dev.caskeleton.bootstrap.techlog;
import dev.caskeleton.adapter.inbound.web.techlog.studio.support.StudioSettings;
import dev.caskeleton.application.storage.ObjectStoragePort;
import dev.caskeleton.application.techlog.studio.port.out.AssetBinaryStoragePort;
import dev.caskeleton.application.techlog.studio.port.out.AssetRepositoryPort;
import dev.caskeleton.application.techlog.studio.port.out.CatalogQueryPort;
import dev.caskeleton.application.techlog.studio.port.out.ContentAnalyzerPort;
import dev.caskeleton.application.techlog.studio.port.out.DependencyRevisionPort;
import dev.caskeleton.application.techlog.studio.port.out.PreviewArtifactPort;
import dev.caskeleton.application.techlog.studio.port.out.PublicationHistoryQueryPort;
import dev.caskeleton.application.techlog.studio.port.out.PublicationQueryPort;
import dev.caskeleton.application.techlog.studio.port.out.PublicationWriterPort;
import dev.caskeleton.application.techlog.studio.port.out.RenderModelPort;
import dev.caskeleton.application.techlog.studio.port.out.StudioDashboardQueryPort;
import dev.caskeleton.application.techlog.studio.port.out.StudioDependencyResolverPort;
import dev.caskeleton.application.techlog.studio.port.out.StudioDocumentQueryPort;
import dev.caskeleton.application.techlog.studio.port.out.ValidationArtifactPort;
import dev.caskeleton.application.techlog.studio.port.out.WorkingCopyRepositoryPort;
import dev.caskeleton.application.techlog.studio.service.CreateStudioDocumentUseCase;
import dev.caskeleton.application.techlog.studio.service.CreateStudioPreviewUseCase;
import dev.caskeleton.application.techlog.studio.service.DeleteStudioAssetUseCase;
import dev.caskeleton.application.techlog.studio.service.GetCurrentStudioPreviewUseCase;
import dev.caskeleton.application.techlog.studio.service.GetStudioAssetUseCase;
import dev.caskeleton.application.techlog.studio.service.GetStudioDashboardUseCase;
import dev.caskeleton.application.techlog.studio.service.GetStudioDocumentUseCase;
import dev.caskeleton.application.techlog.studio.service.GetStudioPublicationSnapshotUseCase;
import dev.caskeleton.application.techlog.studio.service.ListCatalogUseCase;
import dev.caskeleton.application.techlog.studio.service.ListStudioAssetsUseCase;
import dev.caskeleton.application.techlog.studio.service.ListStudioDocumentsUseCase;
import dev.caskeleton.application.techlog.studio.service.ListStudioPublicationsUseCase;
import dev.caskeleton.application.techlog.studio.service.PublishStudioDocumentUseCase;
import dev.caskeleton.application.techlog.studio.service.SaveStudioDocumentUseCase;
import dev.caskeleton.application.techlog.studio.service.StudioDocumentLoader;
import dev.caskeleton.application.techlog.studio.service.UnpublishStudioPublicationUseCase;
import dev.caskeleton.application.techlog.studio.service.UpdateStudioAssetUseCase;
import dev.caskeleton.application.techlog.studio.service.UploadStudioAssetUseCase;
import dev.caskeleton.application.techlog.studio.service.ValidateStudioDocumentUseCase;
import dev.caskeleton.application.techlog.studio.service.WorkingCopyDetailAssembler;
import dev.caskeleton.application.transaction.TransactionPort;
import java.time.Clock;
import java.util.UUID;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/** Tech Log Studio 조립. application-core는 Spring을 보지 않으므로 여기서 배선한다. */
@Configuration
public class TechLogStudioConfig {
@Bean
ListCatalogUseCase listCatalogUseCase(
CatalogQueryPort catalogQueryPort, TransactionPort transactionPort) {
return new ListCatalogUseCase(catalogQueryPort, transactionPort);
}
@Bean
WorkingCopyDetailAssembler workingCopyDetailAssembler(
ValidationArtifactPort validations,
PreviewArtifactPort previews,
PublicationQueryPort publications,
DependencyRevisionPort dependencyRevisions,
Clock clock) {
return new WorkingCopyDetailAssembler(
validations, previews, publications, dependencyRevisions, clock);
}
@Bean
ListStudioDocumentsUseCase listStudioDocumentsUseCase(
StudioDocumentQueryPort documents, TransactionPort transactionPort) {
return new ListStudioDocumentsUseCase(documents, transactionPort);
}
@Bean
GetStudioDocumentUseCase getStudioDocumentUseCase(
WorkingCopyRepositoryPort workingCopies,
WorkingCopyDetailAssembler assembler,
TransactionPort transactionPort) {
return new GetStudioDocumentUseCase(workingCopies, assembler, transactionPort);
}
@Bean
CreateStudioDocumentUseCase createStudioDocumentUseCase(
WorkingCopyRepositoryPort workingCopies, TransactionPort transactionPort) {
return new CreateStudioDocumentUseCase(workingCopies, transactionPort);
}
@Bean
StudioDocumentLoader studioDocumentLoader(WorkingCopyRepositoryPort workingCopies) {
return new StudioDocumentLoader(workingCopies);
}
@Bean
ValidateStudioDocumentUseCase validateStudioDocumentUseCase(
StudioDocumentLoader documents,
StudioDependencyResolverPort dependencies,
ContentAnalyzerPort contentAnalyzer,
DependencyRevisionPort dependencyRevisions,
ValidationArtifactPort validations,
TransactionPort transactionPort,
Clock clock,
StudioSettings settings) {
return new ValidateStudioDocumentUseCase(
documents,
dependencies,
contentAnalyzer,
dependencyRevisions,
validations,
transactionPort,
UUID::randomUUID,
clock,
settings.validationTtl());
}
@Bean
CreateStudioPreviewUseCase createStudioPreviewUseCase(
StudioDocumentLoader documents,
StudioDependencyResolverPort dependencies,
ContentAnalyzerPort contentAnalyzer,
DependencyRevisionPort dependencyRevisions,
ValidationArtifactPort validations,
PreviewArtifactPort previews,
RenderModelPort renderer,
TransactionPort transactionPort,
Clock clock,
StudioSettings settings) {
return new CreateStudioPreviewUseCase(
documents,
dependencies,
contentAnalyzer,
dependencyRevisions,
validations,
previews,
renderer,
transactionPort,
UUID::randomUUID,
clock,
settings.previewTtl());
}
@Bean
GetCurrentStudioPreviewUseCase getCurrentStudioPreviewUseCase(
StudioDocumentLoader documents,
PreviewArtifactPort previews,
ValidationArtifactPort validations,
DependencyRevisionPort dependencyRevisions,
TransactionPort transactionPort,
Clock clock) {
return new GetCurrentStudioPreviewUseCase(
documents, previews, validations, dependencyRevisions, transactionPort, clock);
}
@Bean
SaveStudioDocumentUseCase saveStudioDocumentUseCase(
WorkingCopyRepositoryPort workingCopies,
WorkingCopyDetailAssembler assembler,
TransactionPort transactionPort) {
return new SaveStudioDocumentUseCase(workingCopies, assembler, transactionPort);
}
@Bean
PublishStudioDocumentUseCase publishStudioDocumentUseCase(
StudioDocumentLoader documents,
StudioDependencyResolverPort dependencies,
ContentAnalyzerPort contentAnalyzer,
DependencyRevisionPort dependencyRevisions,
ValidationArtifactPort validations,
PreviewArtifactPort previews,
PublicationWriterPort publications,
TransactionPort transactionPort,
Clock clock) {
return new PublishStudioDocumentUseCase(
documents,
dependencies,
contentAnalyzer,
dependencyRevisions,
validations,
previews,
publications,
transactionPort,
clock);
}
@Bean
UnpublishStudioPublicationUseCase unpublishStudioPublicationUseCase(
PublicationHistoryQueryPort history,
PublicationWriterPort publications,
StudioDocumentLoader documents,
TransactionPort transactionPort) {
return new UnpublishStudioPublicationUseCase(history, publications, documents, transactionPort);
}
@Bean
ListStudioPublicationsUseCase listStudioPublicationsUseCase(
PublicationHistoryQueryPort history, TransactionPort transactionPort) {
return new ListStudioPublicationsUseCase(history, transactionPort);
}
@Bean
GetStudioPublicationSnapshotUseCase getStudioPublicationSnapshotUseCase(
PublicationHistoryQueryPort history, TransactionPort transactionPort) {
return new GetStudioPublicationSnapshotUseCase(history, transactionPort);
}
@Bean
GetStudioDashboardUseCase getStudioDashboardUseCase(
StudioDashboardQueryPort dashboard,
PublicationHistoryQueryPort history,
TransactionPort transactionPort) {
return new GetStudioDashboardUseCase(dashboard, history, transactionPort);
}
/** spec §9 — Asset 은 새 저장 계층을 만들지 않고 기존 object storage 어댑터를 재사용한다. */
@Bean
@SuppressWarnings("removal")
AssetBinaryStoragePort assetBinaryStoragePort(ObjectProvider<ObjectStoragePort> objectStorage) {
return new ObjectStorageAssetBinaryAdapter(objectStorage);
}
@Bean
UploadStudioAssetUseCase uploadStudioAssetUseCase(
AssetRepositoryPort assets,
AssetBinaryStoragePort binaries,
TransactionPort transactionPort) {
return new UploadStudioAssetUseCase(assets, binaries, transactionPort, UUID::randomUUID);
}
@Bean
ListStudioAssetsUseCase listStudioAssetsUseCase(
AssetRepositoryPort assets, TransactionPort transactionPort) {
return new ListStudioAssetsUseCase(assets, transactionPort);
}
@Bean
GetStudioAssetUseCase getStudioAssetUseCase(
AssetRepositoryPort assets, TransactionPort transactionPort) {
return new GetStudioAssetUseCase(assets, transactionPort);
}
@Bean
UpdateStudioAssetUseCase updateStudioAssetUseCase(
AssetRepositoryPort assets, TransactionPort transactionPort) {
return new UpdateStudioAssetUseCase(assets, transactionPort);
}
@Bean
DeleteStudioAssetUseCase deleteStudioAssetUseCase(
AssetRepositoryPort assets,
AssetBinaryStoragePort binaries,
TransactionPort transactionPort) {
return new DeleteStudioAssetUseCase(assets, binaries, transactionPort);
}
}
@@ -6,9 +6,11 @@
# a value this file can know. What belongs here is the shape dev must have whatever the operator # a value this file can know. What belongs here is the shape dev must have whatever the operator
# sets: the vendor and the schema owner. # sets: the vendor and the schema owner.
# #
# Both keys below restate the repository default rather than change it, so adding this file moves # The flyway/persistence keys below restate the repository default rather than change it, so they
# no behaviour. That is the point — the moment dev and prod diverge from local, the difference has # move no behaviour on their own. That is the point — the moment dev and prod diverge from local,
# a declared home instead of being implied by whatever the environment happened to inject. # the difference has a declared home instead of being implied by whatever the environment happened
# to inject. The security.session key further down is the one exception: it genuinely overrides the
# template default for Tech Log Studio's contract. See the comment there for why.
# ============================================================================= # =============================================================================
spring: spring:
@@ -20,3 +22,29 @@ spring:
ca-skeleton: ca-skeleton:
persistence: persistence:
vendor: postgresql vendor: postgresql
authz:
role-permissions:
# Studio 편집 권한(@RequiresPermission("studio:write")). 키는 IdP 가 주는 RAW role 이름이라
# 배포마다 다르다 — APP_STUDIO_AUTHOR_ROLE 로 자기 realm 의 이름을 준다.
#
# application.yml 이 아니라 프로파일에 두는 이유: SampleRemovalSmokeContractTest 가 템플릿
# 기준선인 `role-permissions: {}` 가 그대로 있는지를 검사한다. 제품 권한 매핑은 그 기준선을
# 흔들지 않고 프로파일에서 더한다.
${APP_STUDIO_AUTHOR_ROLE:studio-author}:
- studio:write
security:
# Studio's contract (studio-v1.yaml StudioSession.csrfHeaderName) fixes this header name as a
# `const`. The template default is X-XSRF-TOKEN; Studio needs X-CSRF-TOKEN to match.
#
# auth-mode intentionally stays at the repository default (jwt) here. Task 8's brief proposed
# switching it to redis-session, but src/app-bootstrap's AuthenticationModeCompositionConfig
# requires a complete Redis session repository/filter pair (`redisVersionedSessionRepository`,
# `springSessionRepositoryFilter`) once that mode is active, and neither bean exists in this
# branch yet — the Redis session infrastructure is out of this task's scope (its design package
# was removed from the working tree ahead of this task; see AGENTS.md / task-8 report). Setting
# auth-mode: redis-session here would make a real `--spring.profiles.active=dev` boot fail the
# composition validator with "Redis Session repository/filter is incomplete". This csrf-header-
# name override is independent of auth-mode and safe on its own.
session:
csrf-header-name: X-CSRF-TOKEN
@@ -65,6 +65,25 @@ spring:
properties: properties:
hibernate: hibernate:
format_sql: false format_sql: false
objectstorage:
# Studio Asset 바이너리 저장소. ObjectStorageConfig 는 이 prefix 의 property 가 하나라도 있어야
# 활성화된다(LegacyObjectStorageActivationGuard) — application.yml 이 아니라 프로파일에 두는 이유는,
# 저장 백엔드 선택이 배포마다 다른 결정이라 템플릿 기준선이 그것을 대신 정해서는 안 되기 때문이다.
# 이 값이 없는 배포에서는 업로드·삭제만 STUDIO_UNAVAILABLE 로 거절되고 나머지 Asset operation 은
# 그대로 동작한다.
backend: filesystem
authz:
role-permissions:
# Studio 편집 권한(@RequiresPermission("studio:write")). 키는 IdP 가 주는 RAW role 이름이라
# 배포마다 다르다 — APP_STUDIO_AUTHOR_ROLE 로 자기 realm 의 이름을 준다.
#
# application.yml 이 아니라 프로파일에 두는 이유: SampleRemovalSmokeContractTest 가 템플릿
# 기준선인 `role-permissions: {}` 가 그대로 있는지를 검사한다. 제품 권한 매핑은 그 기준선을
# 흔들지 않고 프로파일에서 더한다.
${APP_STUDIO_AUTHOR_ROLE:studio-author}:
- studio:write
security: security:
oauth2: oauth2:
resourceserver: resourceserver:
@@ -143,6 +162,15 @@ ca-skeleton:
issuer-uri: http://localhost:8081/realms/ca-skeleton issuer-uri: http://localhost:8081/realms/ca-skeleton
audience: ca-skeleton-api audience: ca-skeleton-api
public-paths: /api/healthcheck public-paths: /api/healthcheck
# Studio's contract (studio-v1.yaml StudioSession.csrfHeaderName) fixes this header name as a
# `const`. The template default is X-XSRF-TOKEN (application.yml:498, restated verbatim by
# src/.env:125, the profile src/.env:8 activates) — StudioSessionController's constructor
# rejects any other value with IllegalStateException, and because that controller is an
# unconditional @RestController bean, the failure is a BeanCreationException that fails context
# refresh and kills the whole process, not just Studio. See application-dev.yml's identical
# override for the full rationale.
session:
csrf-header-name: X-CSRF-TOKEN
cors: cors:
enabled: true enabled: true
allowed-origins: http://localhost:3000 allowed-origins: http://localhost:3000
@@ -26,3 +26,24 @@ spring:
ca-skeleton: ca-skeleton:
persistence: persistence:
vendor: postgresql vendor: postgresql
authz:
role-permissions:
# Studio 편집 권한(@RequiresPermission("studio:write")). 키는 IdP 가 주는 RAW role 이름이라
# 배포마다 다르다 — APP_STUDIO_AUTHOR_ROLE 로 자기 realm 의 이름을 준다.
#
# application.yml 이 아니라 프로파일에 두는 이유: SampleRemovalSmokeContractTest 가 템플릿
# 기준선인 `role-permissions: {}` 가 그대로 있는지를 검사한다. 제품 권한 매핑은 그 기준선을
# 흔들지 않고 프로파일에서 더한다.
${APP_STUDIO_AUTHOR_ROLE:studio-author}:
- studio:write
security:
# Studio's contract (studio-v1.yaml StudioSession.csrfHeaderName) fixes this header name as a
# `const`. The template default is X-XSRF-TOKEN; Studio needs X-CSRF-TOKEN to match.
# StudioSessionController's constructor rejects any other configured value with
# IllegalStateException, and because that controller is an unconditional @RestController bean,
# the failure is a BeanCreationException that fails context refresh and kills the whole
# process on this profile, not just Studio. See application-dev.yml's identical override for
# the full rationale.
session:
csrf-header-name: X-CSRF-TOKEN
@@ -446,6 +446,16 @@ ca-skeleton:
# prefix "/v1" (major-version path, AIP-185); override via env, or set "" for # prefix "/v1" (major-version path, AIP-185); override via env, or set "" for
# no prefix. The supplemental "X-Api-Version" header never overrides the path. # no prefix. The supplemental "X-Api-Version" header never overrides the path.
api-base-path: ${PRESENTATION_API_BASE_PATH:/v1} api-base-path: ${PRESENTATION_API_BASE_PATH:/v1}
techlog:
studio:
# Studio 문서 목록 커서 서명 키. 값이 없거나 16바이트 미만이면 StudioSettings가 경고하고 개발용
# 값으로 대체한다 — 커서에 권한이 실리지 않아 부팅을 막을 사유는 아니지만, 인스턴스마다 값이
# 다르면 한 인스턴스가 발급한 커서를 다른 인스턴스가 거부한다.
cursor-signing-key: ${APP_STUDIO_CURSOR_SIGNING_KEY:}
# 검증 결과가 유효한 기간(studio_validation.valid_until).
validation-ttl: ${APP_STUDIO_VALIDATION_TTL:1h}
# 미리보기가 유효한 기간(studio_preview.expires_at).
preview-ttl: ${APP_STUDIO_PREVIEW_TTL:24h}
idempotency: idempotency:
# feature-rate-limit-idempotency-contract D6/§E. ttl is env-driven (<=72h, # feature-rate-limit-idempotency-contract D6/§E. ttl is env-driven (<=72h,
# validated in IdempotencyProperties); reaper-interval is literal operational tuning. # validated in IdempotencyProperties); reaper-interval is literal operational tuning.
@@ -0,0 +1,208 @@
package dev.caskeleton.bootstrap.architecture;
import static org.assertj.core.api.Assertions.assertThat;
import dev.caskeleton.adapter.inbound.web.techlog.StudioClientSafeMessages;
import dev.caskeleton.application.techlog.error.StudioError;
import dev.caskeleton.bootstrap.contract.support.RepositoryContractResources;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.yaml.snakeyaml.Yaml;
/**
* feature-techlog-studio-backend pins {@link StudioError} against {@code
* docs/registries/error-codes.yaml} (the SSOT the contract and log tooling read) on three axes:
*
* <ol>
* <li>every {@link StudioError} constant has a matching registry row (code presence);
* <li>that row's {@code category}/{@code http_status}/{@code retryable} match the enum's declared
* values exactly a standing drift gate. Nothing else in the suite checks this for {@link
* StudioError}: {@code ErrorCodeRegistryMappingTest} (a different branch's template) only
* walks {@code OperationalError}. Its absence let the {@code VALIDATION_FAILED} {@code
* OperationalError.VALIDATION_FAILED} code-name collision ship once already (see
* task-5-report.md Fix Round 1) this closes that gap for good;
* <li>{@link StudioClientSafeMessages#forError(StudioError)}'s text matches the row's {@code
* client_safe_message} exactly the single source of truth for {@code error.message} is the
* registry, and code drifting from it must fail here rather than silently changing what
* clients see.
* </ol>
*
* <p>Uses {@link RepositoryContractResources} (the same repository-root resolver the sibling
* registry contract tests in {@code dev.caskeleton.bootstrap.contract} use) rather than a
* hand-rolled relative {@code Path.of("..", "..", ...)}, since Gradle's test working directory is
* not guaranteed to be the module directory the brief's naive relative path assumed. Parses the
* registry with SnakeYaml the same library/pattern {@code ErrorCodeRegistryMappingTest} and
* {@code RunbookCoverageContractTest} already use in this suite rather than line-scanning, since
* this test needs structured field access (category/http_status/retryable/client_safe_message), not
* just the {@code code:} key.
*/
class StudioErrorRegistryTest {
private static Map<String, Map<String, Object>> registryRowsByCode;
@BeforeAll
@SuppressWarnings("unchecked")
static void loadRegistry() throws Exception {
Path registry =
RepositoryContractResources.fromSystemProperty()
.requireTrackedFile("docs/registries/error-codes.yaml");
registryRowsByCode = new LinkedHashMap<>();
try (InputStream in = Files.newInputStream(registry)) {
Map<String, Object> root = new Yaml().load(in);
List<Map<String, Object>> errors = (List<Map<String, Object>>) root.get("errors");
for (Map<String, Object> row : errors) {
registryRowsByCode.put((String) row.get("code"), row);
}
}
}
@Test
void everyStudioErrorHasARegistryRow() {
Set<String> declared =
Arrays.stream(StudioError.values()).map(StudioError::code).collect(Collectors.toSet());
assertThat(registryRowsByCode.keySet()).containsAll(declared);
}
/**
* The value-drift gate: every {@link StudioError}'s registry row must carry the exact same
* category/http_status/retryable the enum declares. {@code PAYLOAD_TOO_LARGE}/{@code
* UNSUPPORTED_MEDIA_TYPE} reuse a pre-existing {@code feature-api-contract-baseline} row instead
* of a Studio-owned one (task-5-report.md §5) this still holds them to the same standard.
*/
@Test
void everyStudioErrorRowMatchesCategoryHttpStatusAndRetryable() {
for (StudioError error : StudioError.values()) {
Map<String, Object> row = registryRowsByCode.get(error.code());
assertThat(row).as("registry row for %s", error.code()).isNotNull();
assertThat(row.get("category"))
.as("category for %s", error.code())
.isEqualTo(error.category().name());
assertThat(((Number) row.get("http_status")).intValue())
.as("http_status for %s", error.code())
.isEqualTo(error.httpStatus());
assertThat(row.get("retryable"))
.as("retryable for %s", error.code())
.isEqualTo(error.retryable());
}
}
/**
* {@code StudioClientSafeMessages} must never drift from the registry's {@code
* client_safe_message} that column is the single source of truth for what {@code error.message}
* clients see (task-5-report.md Important 1).
*/
@Test
void everyStudioErrorClientSafeMessageMatchesRegistry() {
for (StudioError error : StudioError.values()) {
Map<String, Object> row = registryRowsByCode.get(error.code());
assertThat(row).as("registry row for %s", error.code()).isNotNull();
assertThat(StudioClientSafeMessages.forError(error))
.as("client_safe_message for %s", error.code())
.isEqualTo(row.get("client_safe_message"));
}
}
/**
* final whole-branch review B3: {@code
* StudioErrorTest.declaresExactlyTheTwentyThreeContractCodes} only counts ({@code hasSize(23)})
* it never reads the contract, so a rename or a 1:1 code substitution on either side (enum or
* {@code studio-v1.yaml}) leaves the count at 23 and passes. This is the gate that reads {@code
* src/config/openapi/studio-v1.yaml}'s {@code components.schemas.ApiError.properties.code.enum}
* and requires the two sets to be identical in both directions a code present only in the
* contract, or only in the enum, fails here. This drift already happened once for real (Task 5's
* vendor copy carrying stale names) and a human caught it, not a gate; this closes that gap.
*/
@Test
void enumMatchesContractCodeSetExactly() throws Exception {
Path contract =
RepositoryContractResources.fromSystemProperty()
.requireTrackedFile("src/config/openapi/studio-v1.yaml");
Set<String> contractCodes = contractApiErrorCodes(contract);
Set<String> enumCodes =
Arrays.stream(StudioError.values()).map(StudioError::code).collect(Collectors.toSet());
assertThat(enumCodes)
.as("StudioError enum vs studio-v1.yaml ApiError.code enum")
.containsExactlyInAnyOrderElementsOf(contractCodes);
}
@SuppressWarnings("unchecked")
private static Set<String> contractApiErrorCodes(Path contract) throws IOException {
try (InputStream in = Files.newInputStream(contract)) {
Map<String, Object> root = new Yaml().load(in);
Map<String, Object> components = (Map<String, Object>) root.get("components");
Map<String, Object> schemas = (Map<String, Object>) components.get("schemas");
Map<String, Object> apiError = (Map<String, Object>) schemas.get("ApiError");
Map<String, Object> properties = (Map<String, Object>) apiError.get("properties");
Map<String, Object> code = (Map<String, Object>) properties.get("code");
List<String> enumValues = (List<String>) code.get("enum");
return Set.copyOf(enumValues);
}
}
/**
* final whole-branch review B3: {@code src/config/openapi/studio-v1.yaml} is a vendored copy of
* the design package's contract (MANIFEST.sha256's {@code # source:} line records where from) and
* had zero consumers before this test nothing detected a local edit to the vendor copy drifting
* from the hash the manifest recorded at vendoring time. This makes {@code MANIFEST.sha256} an
* actual tamper/drift gate rather than a file nobody reads.
*/
@Test
void vendoredContractMatchesTheRecordedManifestHash() throws Exception {
RepositoryContractResources resources = RepositoryContractResources.fromSystemProperty();
Path contract = resources.requireTrackedFile("src/config/openapi/studio-v1.yaml");
Path manifest = resources.requireTrackedFile("src/config/openapi/MANIFEST.sha256");
String recordedHash = recordedSha256(manifest, "studio-v1.yaml");
String actualHash = sha256Hex(contract);
assertThat(actualHash)
.as(
"src/config/openapi/studio-v1.yaml sha256 must match the value MANIFEST.sha256 recorded"
+ " for it (local edit or vendoring drift)")
.isEqualTo(recordedHash);
}
/**
* Parses lines shaped {@code <hex sha256> <filename>}, skipping {@code #}-prefixed comment lines
* such as MANIFEST.sha256's {@code # source: ...} provenance line.
*/
private static String recordedSha256(Path manifest, String filename) throws IOException {
return Files.readAllLines(manifest).stream()
.map(String::strip)
.filter(line -> !line.isEmpty() && !line.startsWith("#"))
.filter(line -> line.endsWith(filename))
.map(line -> line.substring(0, line.indexOf(' ')).strip())
.findFirst()
.orElseThrow(
() ->
new IllegalStateException(
"MANIFEST.sha256 has no hash row for " + filename + ": " + manifest));
}
private static String sha256Hex(Path file) throws IOException, NoSuchAlgorithmException {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hash = digest.digest(Files.readAllBytes(file));
StringBuilder hex = new StringBuilder(hash.length * 2);
for (byte b : hash) {
hex.append(String.format("%02x", b));
}
return hex.toString();
}
}

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