Compare commits
6
Commits
697fc740e6
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d5889d644a | ||
|
|
48ff648112 | ||
|
|
e615c24152 | ||
|
|
ab0447a0f9 | ||
|
|
6eec8a0656 | ||
|
|
91e6d99654 |
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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`
|
||||||
@@ -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)
|
|
||||||
@@ -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는 명시된 구현 가정이다.
|
|
||||||
-3635
File diff suppressed because it is too large
Load Diff
-1956
File diff suppressed because it is too large
Load Diff
@@ -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)
|
|
||||||
@@ -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`를 읽어 재생성하고 정책 오버레이를 적용하도록 설계했다.
|
|
||||||
@@ -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 |
|
|
||||||
-2233
File diff suppressed because it is too large
Load Diff
-1497
File diff suppressed because it is too large
Load Diff
@@ -1,135 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Fail when the HTTP Client Platform's code and documentation have drifted.
|
|
||||||
|
|
||||||
The design (§33 "Documentation") requires the support matrix, configuration reference, security
|
|
||||||
guide, runbook, and migration guide to match the code. Review cannot hold that line by itself, so
|
|
||||||
this verifier extracts the names that are part of the public contract -- stable exceptions, metric
|
|
||||||
names, configuration properties, startup violation codes, and transports -- and fails when one
|
|
||||||
exists in code but nowhere in the documentation.
|
|
||||||
|
|
||||||
It deliberately checks one direction only. A name documented but not yet implemented is a plan; a
|
|
||||||
name implemented but undocumented is a surprise for whoever is on call.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import re
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
|
||||||
PLATFORM = REPO_ROOT / "src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient"
|
|
||||||
BOOTSTRAP = REPO_ROOT / "src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/httpclient"
|
|
||||||
DOCS_DIR = REPO_ROOT / "docs/httpclient"
|
|
||||||
ENV_FIELD_MANIFEST = REPO_ROOT / "docs/httpclient/env-fields.yaml"
|
|
||||||
|
|
||||||
REQUIRED_DOCS = [
|
|
||||||
"support-matrix.md",
|
|
||||||
"configuration-reference.md",
|
|
||||||
"retry-and-ambiguity.md",
|
|
||||||
"security.md",
|
|
||||||
"streaming.md",
|
|
||||||
"operations.md",
|
|
||||||
"migration-guide.md",
|
|
||||||
"release-checklist.md",
|
|
||||||
"performance-baseline.md",
|
|
||||||
"repository-adaptation.md",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def read_docs() -> str:
|
|
||||||
return "\n".join(
|
|
||||||
(DOCS_DIR / name).read_text(encoding="utf-8") for name in REQUIRED_DOCS
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def stable_exceptions() -> list[str]:
|
|
||||||
error_dir = PLATFORM / "api/error"
|
|
||||||
return sorted(
|
|
||||||
path.stem
|
|
||||||
for path in error_dir.glob("Http*Exception.java")
|
|
||||||
if path.stem != "HttpClientException"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def metric_names() -> list[str]:
|
|
||||||
source = (PLATFORM / "observation/HttpClientObservationNames.java").read_text(encoding="utf-8")
|
|
||||||
return sorted(set(re.findall(r'"(http\.client\.[a-z_.]+)"', source)))
|
|
||||||
|
|
||||||
|
|
||||||
def violation_codes() -> list[str]:
|
|
||||||
codes: set[str] = set()
|
|
||||||
for source_file in [
|
|
||||||
PLATFORM / "profile/ClientProfileValidator.java",
|
|
||||||
PLATFORM / "security/TlsPolicyValidator.java",
|
|
||||||
BOOTSTRAP / "HttpClientStartupValidator.java",
|
|
||||||
]:
|
|
||||||
source = source_file.read_text(encoding="utf-8")
|
|
||||||
codes.update(re.findall(r'"([A-Z][A-Z0-9_]{4,})"', source))
|
|
||||||
return sorted(codes)
|
|
||||||
|
|
||||||
|
|
||||||
def configuration_properties() -> list[str]:
|
|
||||||
"""Every leaf property under `app.httpclient`, nested and dynamic blocks included.
|
|
||||||
|
|
||||||
Read from the environment-field manifest rather than from the record source. The manifest is
|
|
||||||
derived from `HttpClientPlatformSettings` by `HttpClientEnvironmentKeys` and held to it in both
|
|
||||||
directions by `HttpClientPlatformEnvManifestTest`, so it cannot drift from the code; parsing the
|
|
||||||
record here a second time, with a regex, could only agree with it by luck. The previous version
|
|
||||||
of this function did exactly that and saw eighteen top-level names, which is why a nested pool,
|
|
||||||
timeout or TLS setting could be added and documented nowhere.
|
|
||||||
"""
|
|
||||||
names: set[str] = set()
|
|
||||||
for line in ENV_FIELD_MANIFEST.read_text(encoding="utf-8").splitlines():
|
|
||||||
stripped = line.strip()
|
|
||||||
if not stripped.startswith("- field:"):
|
|
||||||
continue
|
|
||||||
path = stripped[len("- field:") :].strip()
|
|
||||||
leaf = path.split(".")[-1]
|
|
||||||
# `clients[N]` and `allowed-hosts[M]` are documented by name, not by position.
|
|
||||||
names.add(re.sub(r"\[[NM]\]$", "", leaf))
|
|
||||||
return sorted(names)
|
|
||||||
|
|
||||||
|
|
||||||
def transports() -> list[str]:
|
|
||||||
source = (PLATFORM / "profile/TransportType.java").read_text(encoding="utf-8")
|
|
||||||
body = source[source.index("public enum TransportType") :]
|
|
||||||
return sorted(set(re.findall(r"^\s{2}([A-Z][A-Z_]*),?$", body, flags=re.MULTILINE)))
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
|
||||||
missing_docs = [name for name in REQUIRED_DOCS if not (DOCS_DIR / name).is_file()]
|
|
||||||
if missing_docs:
|
|
||||||
print("FAIL missing documentation file(s): " + ", ".join(missing_docs))
|
|
||||||
return 1
|
|
||||||
|
|
||||||
documentation = read_docs()
|
|
||||||
failures: list[str] = []
|
|
||||||
|
|
||||||
checks = {
|
|
||||||
"stable exception": stable_exceptions(),
|
|
||||||
"metric": metric_names(),
|
|
||||||
"startup violation code": violation_codes(),
|
|
||||||
"configuration property": configuration_properties(),
|
|
||||||
"transport": transports(),
|
|
||||||
}
|
|
||||||
for kind, names in checks.items():
|
|
||||||
for name in names:
|
|
||||||
if name not in documentation:
|
|
||||||
failures.append(f"{kind} '{name}' exists in code but is not documented")
|
|
||||||
|
|
||||||
if failures:
|
|
||||||
print(f"FAIL httpclient documentation drift ({len(failures)} finding(s)):")
|
|
||||||
for failure in failures:
|
|
||||||
print(" - " + failure)
|
|
||||||
return 1
|
|
||||||
|
|
||||||
total = sum(len(names) for names in checks.values())
|
|
||||||
print(f"PASS httpclient documentation covers {total} code-derived name(s):")
|
|
||||||
for kind, names in checks.items():
|
|
||||||
print(f" {kind}: {len(names)}")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
sys.exit(main())
|
|
||||||
@@ -1,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/**') } }
|
||||||
|
|||||||
@@ -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=
|
||||||
|
|||||||
+52
@@ -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 서비스를 일시적으로 사용할 수 없습니다. 잠시 후 다시 시도해 주세요";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
+92
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+220
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+65
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+226
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+121
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+177
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+111
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+262
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+203
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+258
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+236
@@ -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;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
+71
@@ -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); // 호환용 자모
|
||||||
|
}
|
||||||
|
}
|
||||||
+117
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+92
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+166
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+47
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+181
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+47
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
+122
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
+90
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+23
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+44
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+72
@@ -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 {}
|
||||||
|
}
|
||||||
+62
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+142
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+151
@@ -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 {}
|
||||||
|
}
|
||||||
+106
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+129
@@ -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 {}
|
||||||
|
}
|
||||||
+154
@@ -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 {}
|
||||||
|
}
|
||||||
+223
@@ -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=
|
||||||
|
|||||||
+2
-3
@@ -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) {
|
||||||
|
|||||||
+2
-2
@@ -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);
|
||||||
|
|||||||
+6
-6
@@ -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
|
||||||
|
|||||||
+8
-8
@@ -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(
|
||||||
|
|||||||
+91
@@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
+143
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+78
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+41
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
+42
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+57
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+225
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+141
@@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
+48
@@ -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")));
|
||||||
|
}
|
||||||
|
}
|
||||||
+130
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+244
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
+192
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+514
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+25
@@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
+255
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+128
@@ -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);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
+180
@@ -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;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
+81
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+296
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+131
@@ -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("");
|
||||||
|
}
|
||||||
|
}
|
||||||
+89
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
+49
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+779
@@ -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);
|
||||||
+92
@@ -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;
|
||||||
+1
-1
@@ -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()
|
||||||
|
|||||||
+157
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+121
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+716
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+5
-4
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
+895
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+51
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+253
@@ -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.
|
||||||
|
|||||||
+208
@@ -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
Reference in New Issue
Block a user