From 91e6d99654bbb386e347a7f41730a365b9c5918e Mon Sep 17 00:00:00 2001 From: DongHyeonka Date: Wed, 19 Aug 2026 15:14:52 +0900 Subject: [PATCH 01/10] =?UTF-8?q?feat:=20Tech=20Log=20Studio=20=EB=B0=B1?= =?UTF-8?q?=EC=97=94=EB=93=9C=20=EA=B8=B0=EB=B0=98=20=E2=80=94=20=EA=B3=84?= =?UTF-8?q?=EC=95=BD=20=EB=B0=B0=EC=84=A0,=20=EC=98=A4=EB=A5=98=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C,=20=EA=B2=BD=EA=B3=84=20=EA=B7=9C=EC=B9=99,?= =?UTF-8?q?=20=EC=8A=A4=ED=82=A4=EB=A7=88,=20=EC=97=94=EB=93=9C=ED=8F=AC?= =?UTF-8?q?=EC=9D=B8=ED=8A=B8=202=EC=A2=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 설계 패키지의 studio-v1.yaml(v3.0.0, 응답 봉투)을 이 저장소에 배선하고 슬라이스 1의 기반을 세운다. 19개 operation 중 getStudioSession과 listStudioCatalog를 구현했다. 계약과 생성 - src/config/openapi/studio-v1.yaml 을 vendor하고 MANIFEST에 출처 커밋을 기록 - openapi-generator로 DTO(model)만 생성한다. generateApis 대신 globalProperties.set(['models': '']) — 그 두 속성은 플러그인 7.18.0에 없다 - useOneOfInterfaces=false. 그 대가로 discriminator union 5종의 Jackson 배선이 깨진다(spec §3.1). 그 5종을 쓰는 7개 operation은 Plan 02에서 전략을 정한 뒤 구현한다 - 생성 코드는 별도 generatedOpenapi sourceSet에 둔다. -Werror가 생성물의 deprecated API 사용을 빌드 실패로 승격하기 때문이다. jar와 test 클래스패스에 별도로 얹는다 오류 계약 - StudioError 23종(계약 ApiError.code와 1:1) + StudioException(ApiErrorCarrier) - StudioExceptionHandler는 techlog 패키지로 범위를 좁힌다. 다른 기능의 오류 응답을 바꾸지 않기 위해서다 - 클라이언트 문구는 레지스트리의 client_safe_message에서 가져오고 예외 메시지는 로그 전용이다(ApiErrorCarrier javadoc의 요구) - 바인딩 예외를 봉투로 옮긴다. 그러지 않으면 bare RFC 7807이 새어 나가 ADR-006을 위반한다 게이트 - TechLogBoundaryArchTest 7종 — spec §4.3의 bounded context 경계. Gradle leaf를 늘릴 수 없어 이 규칙이 경계의 유일한 방어선이다 - StudioErrorRegistryTest — enum ↔ 레지스트리 ↔ 계약 3축 대조, vendor 사본 해시 검증 - StudioContractDriftTest — springdoc 표면이 계약을 벗어나면 실패. @ComponentScan이라 새 컨트롤러가 자동으로 걸린다 - StudioSessionCsrfHeaderProfileContractTest — 배포 가능한 세 프로파일이 계약의 csrf-header-name const로 해소되는지 고정. 이 저장소는 실제 composition root를 테스트에서 부팅할 수 없어 파일 단언으로 그 층을 덮는다 스키마 - V7__techlog_core.sql, 28 테이블. 설계 DDL에서 studio_idempotency(기존 idempotency_record 재사용)와 범위 밖 6종을 제외했다 - 원본의 tech_log 스키마 대신 public을 쓴다. 원본의 SET search_path는 Flyway 세션에만 적용되고 런타임 커넥션 풀은 상속하지 않는다 알려진 제약 - getStudioSession은 세션 인프라(redis-session)가 없어 503 STUDIO_UNAVAILABLE을 반환한다. 계약이 이 operation에 허용하는 유일한 실패 코드다. 가짜 CSRF 토큰으로 200을 만들지 않았다 - 따라서 슬라이스 1의 "프론트 로그인 실동작" 목표는 아직 달성되지 않았다 이 커밋은 AGENTS.md의 human-only 커밋 정책에 대한 저장소 소유자의 명시적 지시로 작성됐다. Co-Authored-By: Claude Opus 5 (1M context) --- docs/registries/error-codes.yaml | 312 ++ docs/runbooks/auth-token-missing.md | 7 +- .../runbooks/authz-insufficient-permission.md | 7 +- docs/runbooks/studio-unavailable.md | 95 + ...18-techlog-studio-backend-01-foundation.md | 2594 +++++++++++++++++ ...026-08-18-techlog-studio-backend-design.md | 870 ++++++ src/adapter/inbound/web/build.gradle | 140 + src/adapter/inbound/web/gradle.lockfile | 206 +- .../web/techlog/StudioClientSafeMessages.java | 52 + .../web/techlog/StudioExceptionHandler.java | 92 + .../controller/StudioCatalogController.java | 59 + .../controller/StudioSessionController.java | 105 + .../StudioExceptionHandlerScopeTest.java | 72 + .../techlog/StudioExceptionHandlerTest.java | 62 + ...StudioCatalogBindingErrorEnvelopeTest.java | 138 + .../StudioSessionControllerTest.java | 106 + .../StudioSessionCsrfDisabledTest.java | 116 + .../controller/StudioSessionEnvelopeTest.java | 141 + .../outbound/persistence-jpa/build.gradle | 9 + .../query/JdbcCatalogQueryAdapter.java | 78 + .../migration/postgresql/V7__techlog_core.sql | 779 +++++ .../PostgreSqlMigrationIntegrationTest.java | 2 +- .../techlog/TechLogSchemaMigrationTest.java | 157 + .../query/JdbcCatalogQueryAdapterTest.java | 121 + src/app-bootstrap/build.gradle | 44 +- src/app-bootstrap/gradle.lockfile | 216 +- .../contract/StudioContractDriftTest.java | 336 +++ .../techlog/TechLogStudioConfig.java | 18 + .../src/main/resources/application-dev.yml | 23 +- .../src/main/resources/application-local.yml | 9 + .../src/main/resources/application-prod.yml | 10 + .../architecture/StudioErrorRegistryTest.java | 208 ++ .../architecture/TechLogBoundaryArchTest.java | 141 + ...oSessionCsrfHeaderProfileContractTest.java | 96 + .../techlog/error/StudioError.java | 64 + .../techlog/error/StudioException.java | 44 + .../studio/port/out/CatalogQueryPort.java | 11 + .../studio/query/CatalogEntryType.java | 9 + .../studio/query/CatalogEntryView.java | 14 + .../techlog/studio/query/CatalogPageView.java | 10 + .../studio/query/ListCatalogQuery.java | 6 + .../studio/service/ListCatalogUseCase.java | 86 + .../techlog/error/StudioErrorTest.java | 48 + .../service/ListCatalogUseCaseTest.java | 138 + src/build.gradle | 2 + src/config/openapi/MANIFEST.sha256 | 2 + src/config/openapi/studio-v1.yaml | 1836 ++++++++++++ src/config/spotbugs/exclude.xml | 24 + 48 files changed, 9495 insertions(+), 220 deletions(-) create mode 100644 docs/runbooks/studio-unavailable.md create mode 100644 docs/superpowers/plans/2026-08-18-techlog-studio-backend-01-foundation.md create mode 100644 docs/superpowers/specs/2026-08-18-techlog-studio-backend-design.md create mode 100644 src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/StudioClientSafeMessages.java create mode 100644 src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/StudioExceptionHandler.java create mode 100644 src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/controller/StudioCatalogController.java create mode 100644 src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/controller/StudioSessionController.java create mode 100644 src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/error/StudioExceptionHandlerScopeTest.java create mode 100644 src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/techlog/StudioExceptionHandlerTest.java create mode 100644 src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/techlog/studio/controller/StudioCatalogBindingErrorEnvelopeTest.java create mode 100644 src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/techlog/studio/controller/StudioSessionControllerTest.java create mode 100644 src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/techlog/studio/controller/StudioSessionCsrfDisabledTest.java create mode 100644 src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/techlog/studio/controller/StudioSessionEnvelopeTest.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/query/JdbcCatalogQueryAdapter.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/resources/db/migration/postgresql/V7__techlog_core.sql create mode 100644 src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/techlog/TechLogSchemaMigrationTest.java create mode 100644 src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/techlog/query/JdbcCatalogQueryAdapterTest.java create mode 100644 src/app-bootstrap/src/functionalTest/java/dev/caskeleton/bootstrap/contract/StudioContractDriftTest.java create mode 100644 src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/techlog/TechLogStudioConfig.java create mode 100644 src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/StudioErrorRegistryTest.java create mode 100644 src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/TechLogBoundaryArchTest.java create mode 100644 src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/StudioSessionCsrfHeaderProfileContractTest.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/error/StudioError.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/error/StudioException.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/port/out/CatalogQueryPort.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/query/CatalogEntryType.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/query/CatalogEntryView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/query/CatalogPageView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/query/ListCatalogQuery.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/ListCatalogUseCase.java create mode 100644 src/application-core/src/test/java/dev/caskeleton/application/techlog/error/StudioErrorTest.java create mode 100644 src/application-core/src/test/java/dev/caskeleton/application/techlog/studio/service/ListCatalogUseCaseTest.java create mode 100644 src/config/openapi/MANIFEST.sha256 create mode 100644 src/config/openapi/studio-v1.yaml diff --git a/docs/registries/error-codes.yaml b/docs/registries/error-codes.yaml index 06c865d..fcd6ff7 100644 --- a/docs/registries/error-codes.yaml +++ b/docs/registries/error-codes.yaml @@ -916,3 +916,315 @@ errors: runbook_link: "runbook://management/actuator-forbidden" compatibility_impact: none required_test: contract-verification:management-actuator + + # ============================================================ + # TECH LOG STUDIO (studio-v1.yaml ApiError.code — 23종, feature-techlog-studio-backend) + # + # StudioError(dev.caskeleton.application.techlog.error.StudioError)와 1:1 매핑. + # category/http_status/retryable은 그 enum의 선언과 정확히 같아야 한다 + # (StudioErrorRegistryTest가 코드 존재만 보고, 값 일치는 이 파일의 리뷰 책임). + # + # PAYLOAD_TOO_LARGE / UNSUPPORTED_MEDIA_TYPE은 StudioError에도 있지만 별도 row를 + # 추가하지 않는다 — feature-api-contract-baseline이 이미 동일 code로 아래(L514, + # L545)에 VALIDATION/413/415/false, VALIDATION/415/false row를 갖고 있고 값이 + # StudioError 선언과 정확히 일치한다. error-codes.yaml의 identity column은 `code` + # 하나뿐이라 같은 code로 두 번째 row를 추가하면 ContractRegistrySchemaGovernanceTest + # 의 "duplicate identity" 게이트가 깨진다. 즉 이 두 코드는 기존 row가 이미 커버한다. + # ============================================================ + + # source: studio-v1.yaml ApiError.code — AUTHENTICATION_REQUIRED (StudioError.AUTHENTICATION_REQUIRED) + - code: AUTHENTICATION_REQUIRED + category: AUTH + http_status: 401 + retryable: false + retry_after_seconds: null + owner_branch: feature-techlog-studio-backend + owner_layer: presentation + client_safe_message: "Studio 인증이 필요합니다" + log_level: INFO + runbook_link: "runbook://auth/token-missing" + compatibility_impact: additive + required_test: StudioErrorTest + + # source: studio-v1.yaml ApiError.code — STUDIO_ACCESS_DENIED (StudioError.STUDIO_ACCESS_DENIED) + - code: STUDIO_ACCESS_DENIED + category: AUTHZ + http_status: 403 + retryable: false + retry_after_seconds: null + owner_branch: feature-techlog-studio-backend + owner_layer: application + client_safe_message: "이 Studio 리소스에 접근할 권한이 없습니다" + log_level: WARN + runbook_link: "runbook://authz/insufficient-permission" + compatibility_impact: additive + required_test: StudioErrorTest + + # source: studio-v1.yaml ApiError.code — DOCUMENT_NOT_FOUND (StudioError.DOCUMENT_NOT_FOUND) + - code: DOCUMENT_NOT_FOUND + category: NOT_FOUND + http_status: 404 + retryable: false + retry_after_seconds: null + owner_branch: feature-techlog-studio-backend + owner_layer: application + client_safe_message: "요청한 문서를 찾을 수 없습니다" + log_level: INFO + runbook_link: null + compatibility_impact: additive + required_test: StudioErrorTest + + # source: studio-v1.yaml ApiError.code — VERSION_CONFLICT (StudioError.VERSION_CONFLICT) + - code: VERSION_CONFLICT + category: CONFLICT + http_status: 409 + retryable: false + retry_after_seconds: null + owner_branch: feature-techlog-studio-backend + owner_layer: application + client_safe_message: "저장된 version이 더 최신입니다" + log_level: INFO + runbook_link: null + compatibility_impact: additive + required_test: StudioErrorTest + + # source: studio-v1.yaml ApiError.code — REQUEST_VALIDATION_FAILED (StudioError.REQUEST_VALIDATION_FAILED) + - code: REQUEST_VALIDATION_FAILED + category: VALIDATION + http_status: 422 + retryable: false + retry_after_seconds: null + owner_branch: feature-techlog-studio-backend + owner_layer: presentation + client_safe_message: "요청 형식이 올바르지 않습니다" + log_level: WARN + runbook_link: null + compatibility_impact: additive + required_test: StudioErrorTest + + # source: studio-v1.yaml ApiError.code — DOCUMENT_VALIDATION_FAILED + # (StudioError.DOCUMENT_VALIDATION_FAILED). 원래 계약 이름은 VALIDATION_FAILED였으나 + # 스켈레톤 전역 OperationalError.VALIDATION_FAILED(400, VALIDATION)와 code 문자열이 + # 충돌해(같은 문자열, 다른 http_status) 개명했다 — controller 판정, 2026-08-19. + - code: DOCUMENT_VALIDATION_FAILED + category: VALIDATION + http_status: 422 + retryable: false + retry_after_seconds: null + owner_branch: feature-techlog-studio-backend + owner_layer: application + client_safe_message: "문서 검증에 실패했습니다" + log_level: WARN + runbook_link: null + compatibility_impact: additive + required_test: StudioErrorTest + + # source: studio-v1.yaml ApiError.code — VALIDATION_STALE (StudioError.VALIDATION_STALE) + - code: VALIDATION_STALE + category: CONFLICT + http_status: 409 + retryable: false + retry_after_seconds: null + owner_branch: feature-techlog-studio-backend + owner_layer: application + client_safe_message: "검증 결과가 최신 문서 기준이 아닙니다. 다시 검증해 주세요" + log_level: INFO + runbook_link: null + compatibility_impact: additive + required_test: StudioErrorTest + + # source: studio-v1.yaml ApiError.code — PREVIEW_NOT_FOUND (StudioError.PREVIEW_NOT_FOUND) + - code: PREVIEW_NOT_FOUND + category: NOT_FOUND + http_status: 404 + retryable: false + retry_after_seconds: null + owner_branch: feature-techlog-studio-backend + owner_layer: application + client_safe_message: "요청한 미리보기를 찾을 수 없습니다" + log_level: INFO + runbook_link: null + compatibility_impact: additive + required_test: StudioErrorTest + + # source: studio-v1.yaml ApiError.code — PREVIEW_STALE (StudioError.PREVIEW_STALE) + - code: PREVIEW_STALE + category: CONFLICT + http_status: 409 + retryable: false + retry_after_seconds: null + owner_branch: feature-techlog-studio-backend + owner_layer: application + client_safe_message: "미리보기가 최신 문서 기준이 아닙니다. 다시 생성해 주세요" + log_level: INFO + runbook_link: null + compatibility_impact: additive + required_test: StudioErrorTest + + # source: studio-v1.yaml ApiError.code — PREVIEW_EXPIRED (StudioError.PREVIEW_EXPIRED) + - code: PREVIEW_EXPIRED + category: CONFLICT + http_status: 409 + retryable: false + retry_after_seconds: null + owner_branch: feature-techlog-studio-backend + owner_layer: application + client_safe_message: "미리보기가 만료되었습니다. 다시 생성해 주세요" + log_level: INFO + runbook_link: null + compatibility_impact: additive + required_test: StudioErrorTest + + # source: studio-v1.yaml ApiError.code — PUBLICATION_NOT_FOUND (StudioError.PUBLICATION_NOT_FOUND) + - code: PUBLICATION_NOT_FOUND + category: NOT_FOUND + http_status: 404 + retryable: false + retry_after_seconds: null + owner_branch: feature-techlog-studio-backend + owner_layer: application + client_safe_message: "요청한 게시물을 찾을 수 없습니다" + log_level: INFO + runbook_link: null + compatibility_impact: additive + required_test: StudioErrorTest + + # source: studio-v1.yaml ApiError.code — PUBLICATION_CONFLICT (StudioError.PUBLICATION_CONFLICT) + - code: PUBLICATION_CONFLICT + category: CONFLICT + http_status: 409 + retryable: false + retry_after_seconds: null + owner_branch: feature-techlog-studio-backend + owner_layer: application + client_safe_message: "게시 작업이 다른 변경과 충돌했습니다" + log_level: INFO + runbook_link: null + compatibility_impact: additive + required_test: StudioErrorTest + + # source: studio-v1.yaml ApiError.code — PUBLICATION_EVENT_NOT_FOUND (StudioError.PUBLICATION_EVENT_NOT_FOUND) + - code: PUBLICATION_EVENT_NOT_FOUND + category: NOT_FOUND + http_status: 404 + retryable: false + retry_after_seconds: null + owner_branch: feature-techlog-studio-backend + owner_layer: application + client_safe_message: "요청한 게시 이벤트를 찾을 수 없습니다" + log_level: INFO + runbook_link: null + compatibility_impact: additive + required_test: StudioErrorTest + + # source: studio-v1.yaml ApiError.code — PUBLICATION_SNAPSHOT_NOT_FOUND (StudioError.PUBLICATION_SNAPSHOT_NOT_FOUND) + - code: PUBLICATION_SNAPSHOT_NOT_FOUND + category: NOT_FOUND + http_status: 404 + retryable: false + retry_after_seconds: null + owner_branch: feature-techlog-studio-backend + owner_layer: application + client_safe_message: "요청한 게시 스냅샷을 찾을 수 없습니다" + log_level: INFO + runbook_link: null + compatibility_impact: additive + required_test: StudioErrorTest + + # source: studio-v1.yaml ApiError.code — WARNING_ACKNOWLEDGEMENT_REQUIRED (StudioError.WARNING_ACKNOWLEDGEMENT_REQUIRED) + - code: WARNING_ACKNOWLEDGEMENT_REQUIRED + category: VALIDATION + http_status: 422 + retryable: false + retry_after_seconds: null + owner_branch: feature-techlog-studio-backend + owner_layer: application + client_safe_message: "경고 확인이 필요합니다. 확인 후 다시 시도해 주세요" + log_level: WARN + runbook_link: null + compatibility_impact: additive + required_test: StudioErrorTest + + # source: studio-v1.yaml ApiError.code — IDEMPOTENCY_KEY_REUSED (StudioError.IDEMPOTENCY_KEY_REUSED) + - code: IDEMPOTENCY_KEY_REUSED + category: CONFLICT + http_status: 409 + retryable: false + retry_after_seconds: null + owner_branch: feature-techlog-studio-backend + owner_layer: application + client_safe_message: "Idempotency 키가 다른 요청에 재사용되었습니다" + log_level: INFO + runbook_link: null + compatibility_impact: additive + required_test: StudioErrorTest + + # source: studio-v1.yaml ApiError.code — ASSET_NOT_FOUND (StudioError.ASSET_NOT_FOUND) + - code: ASSET_NOT_FOUND + category: NOT_FOUND + http_status: 404 + retryable: false + retry_after_seconds: null + owner_branch: feature-techlog-studio-backend + owner_layer: application + client_safe_message: "요청한 자산을 찾을 수 없습니다" + log_level: INFO + runbook_link: null + compatibility_impact: additive + required_test: StudioErrorTest + + # source: studio-v1.yaml ApiError.code — ASSET_NOT_READY (StudioError.ASSET_NOT_READY) + - code: ASSET_NOT_READY + category: CONFLICT + http_status: 409 + retryable: false + retry_after_seconds: null + owner_branch: feature-techlog-studio-backend + owner_layer: application + client_safe_message: "자산 처리가 아직 완료되지 않았습니다" + log_level: INFO + runbook_link: null + compatibility_impact: additive + required_test: StudioErrorTest + + # source: studio-v1.yaml ApiError.code — ASSET_IN_USE (StudioError.ASSET_IN_USE) + - code: ASSET_IN_USE + category: CONFLICT + http_status: 409 + retryable: false + retry_after_seconds: null + owner_branch: feature-techlog-studio-backend + owner_layer: application + client_safe_message: "자산이 사용 중이라 이 작업을 수행할 수 없습니다" + log_level: INFO + runbook_link: null + compatibility_impact: additive + required_test: StudioErrorTest + + # source: studio-v1.yaml ApiError.code — ASSET_QUARANTINED (StudioError.ASSET_QUARANTINED) + - code: ASSET_QUARANTINED + category: DATA_INTEGRITY + http_status: 409 + retryable: false + retry_after_seconds: null + owner_branch: feature-techlog-studio-backend + owner_layer: application + client_safe_message: "자산이 격리 처리되어 사용할 수 없습니다" + log_level: WARN + runbook_link: null + compatibility_impact: additive + required_test: StudioErrorTest + + # source: studio-v1.yaml ApiError.code — STUDIO_UNAVAILABLE (StudioError.STUDIO_UNAVAILABLE) + - code: STUDIO_UNAVAILABLE + category: TRANSIENT_DEPENDENCY + http_status: 503 + retryable: true + retry_after_seconds: 5 + owner_branch: feature-techlog-studio-backend + owner_layer: infrastructure + client_safe_message: "Studio 서비스를 일시적으로 사용할 수 없습니다. 잠시 후 다시 시도해 주세요" + log_level: ERROR + runbook_link: "runbook://studio/unavailable" + compatibility_impact: additive + required_test: StudioErrorTest diff --git a/docs/runbooks/auth-token-missing.md b/docs/runbooks/auth-token-missing.md index 78a74da..84099eb 100644 --- a/docs/runbooks/auth-token-missing.md +++ b/docs/runbooks/auth-token-missing.md @@ -1,10 +1,10 @@ --- title: Runbook — AUTH_TOKEN_MISSING (인증 토큰 누락) category: AUTH -error_codes: [AUTH_TOKEN_MISSING] +error_codes: [AUTH_TOKEN_MISSING, AUTHENTICATION_REQUIRED] severity: P3 owner: oncall -last_updated: 2026-06-15 +last_updated: 2026-08-18 status: stub --- @@ -14,6 +14,9 @@ status: stub - HTTP 401 responses with `error.code=AUTH_TOKEN_MISSING` - Client missing Authorization header or Bearer token +- Tech Log Studio (`studio-v1.yaml`) surfaces the same missing-authentication scenario as + `error.code=AUTHENTICATION_REQUIRED` — same root cause, Studio-scoped code + (feature-techlog-studio-backend, `StudioError.AUTHENTICATION_REQUIRED`) ## Diagnosis diff --git a/docs/runbooks/authz-insufficient-permission.md b/docs/runbooks/authz-insufficient-permission.md index 5199ccb..0d4201c 100644 --- a/docs/runbooks/authz-insufficient-permission.md +++ b/docs/runbooks/authz-insufficient-permission.md @@ -1,10 +1,10 @@ --- title: Runbook — AUTHZ_INSUFFICIENT_PERMISSION (권한 부족) category: AUTHZ -error_codes: [AUTHZ_INSUFFICIENT_PERMISSION] +error_codes: [AUTHZ_INSUFFICIENT_PERMISSION, STUDIO_ACCESS_DENIED] severity: P3 owner: oncall -last_updated: 2026-06-15 +last_updated: 2026-08-18 status: stub --- @@ -14,6 +14,9 @@ status: stub - HTTP 403 with `error.code=AUTHZ_INSUFFICIENT_PERMISSION` - Valid token but missing required role or permission +- Tech Log Studio (`studio-v1.yaml`) surfaces the same resource-authorization scenario as + `error.code=STUDIO_ACCESS_DENIED` — same root cause, Studio-scoped code + (feature-techlog-studio-backend, `StudioError.STUDIO_ACCESS_DENIED`) ## Diagnosis diff --git a/docs/runbooks/studio-unavailable.md b/docs/runbooks/studio-unavailable.md new file mode 100644 index 0000000..ed765f8 --- /dev/null +++ b/docs/runbooks/studio-unavailable.md @@ -0,0 +1,95 @@ +--- +title: Runbook — STUDIO_UNAVAILABLE (Tech Log Studio 일시 장애) +category: TRANSIENT_DEPENDENCY +error_codes: [STUDIO_UNAVAILABLE] +severity: P1 +owner: oncall +last_updated: 2026-08-18 +status: active +--- + +# Runbook: STUDIO_UNAVAILABLE (`runbook://studio/unavailable`) + +## 1. Trigger + +이 runbook은 Tech Log Studio API(`studio-v1.yaml`)가 `error.code=STUDIO_UNAVAILABLE` +(HTTP 503, category `TRANSIENT_DEPENDENCY`, `retryable=true`)을 응답할 때 발동됩니다. + +- alert name: `studio_error_rate_critical` 또는 `studio_dependency_unavailable` +- alert payload 필수 field: `operation`(문서/미리보기/게시 중 어느 슬라이스인지), `error.code`, + `error.category`, `dependency_name`(가능하면), `runbook_link` +- 임계: + - P1: `STUDIO_UNAVAILABLE` 비율이 1분간 Studio 전체 트래픽의 10% 초과 + - P2: 단발성 spike이나 5분 내 자연 회복 + +`StudioExceptionHandler`(adapter/inbound/web)가 `StudioException`을 봉투로 변환하는 +지점이므로, 이 코드는 항상 Studio facade/use case(`application-core`)가 자신의 하위 +의존성(영속성, 캐시, 오브젝트 스토리지, 렌더링/미리보기 파이프라인 등) 실패를 +클라이언트에 안전한 단일 코드로 접어(classify) 던진 결과입니다 — 원인 그 자체가 아니라 +**Studio가 판단한 결과**라는 점을 유의합니다. + +## 2. First Response (5분 이내) + +### Step 1 — 확인 +1. 최근 배포 이력 확인 (`app-bootstrap` 롤아웃, config 변경) — 배포 직후 spike면 롤백 우선 검토 +2. 로그에서 `StudioException`이 감싸고 있던 실제 원인을 확인: `dev.caskeleton.application.techlog` + 패키지의 use case/facade 로그에서 `STUDIO_UNAVAILABLE`로 분류되기 직전의 원인 예외 + (`PersistenceFailureException`, `DependencyFailureException` 등)를 추적 +3. runtime-health Dependency Matrix에서 Studio가 의존하는 구성요소(DB, 캐시, object storage)의 + required/optional 분류와 현재 상태 확인 +4. 특정 slice(문서 편집/미리보기/게시)에 국한된 장애인지, Studio 전역 장애인지 구분 + +### Step 2 — 임시 격리 +- 원인이 특정 하위 의존성이면 해당 의존성의 runbook으로 전환 (예: `runbook://db/unavailable`, + `runbook://cache/unavailable`) — `STUDIO_UNAVAILABLE`은 진입점일 뿐, 근본 원인 대응은 + 하위 의존성 runbook이 담당 +- 클라이언트(Frontend)는 이미 `retryable=true`를 신뢰해 지수 백오프 재시도를 수행하므로, + 단기 spike는 자연 회복을 우선 관찰 (조기 개입으로 인한 추가 부하 유발 방지) + +## 3. Diagnosis + +- log query: `{service="app"} | error.code="STUDIO_UNAVAILABLE" | stats count by operation` +- 원인 추적: 같은 요청의 correlation id로 use case 로그를 따라가 어떤 하위 호출이 + `StudioException.withDetails(StudioError.STUDIO_UNAVAILABLE, ...)`로 재분류됐는지 확인 +- metric panel: + - `http_server_requests_seconds_count{uri=~"/api/v1/studio/.*", outcome="SERVER_ERROR"}` + - 하위 의존성 metric (`hikaricp_connections_active`, `resilience4j_circuitbreaker_state`, + object storage client 오류율) +- 가능한 원인: + - DB/캐시/오브젝트 스토리지 등 필수 의존성 장애가 Studio 계층까지 전파 + - Studio 자체 리소스 고갈 (스레드풀, 커넥션풀) + - 미리보기/렌더링 파이프라인의 타임아웃 누적 + - 배포 직후 신규 코드 경로의 미검증 예외가 fallback으로 `STUDIO_UNAVAILABLE`에 접힘 + +## 4. Mitigation + +- 단기: 근본 원인이 확인된 하위 의존성이면 해당 dependency runbook의 mitigation을 적용 +- Studio 자체 리소스 고갈이면 인스턴스 스케일아웃 또는 커넥션/스레드풀 상향 검토 +- 특정 slice(예: 미리보기)만 영향받는다면 해당 slice만 일시적으로 기능 차단하고 + 나머지(문서 편집/게시)는 정상 유지 검토 — 전면 장애보다 부분 degrade 우선 +- 장기: 반복되는 하위 의존성 장애가 `STUDIO_UNAVAILABLE`로 잦게 나타나면, 해당 의존성의 + circuit breaker/timeout 임계를 재조정하고 fallback 경로 보강 + +## 5. Escalation + +- P1 5분 내 회복 신호 없으면 해당 하위 의존성 오너 팀에 page +- 여러 slice(문서/미리보기/게시)에서 동시에 발생하면 incident commander 호출 + (공유 인프라 계층 문제 의심) + +## 6. Recovery / Verification + +- 회복 확인 metric: `STUDIO_UNAVAILABLE` 비율이 5분간 1% 미만으로 유지 +- 하위 의존성 metric(circuit breaker CLOSED, connection pool 정상)도 함께 확인 +- post-incident: + - 어떤 하위 의존성이 `STUDIO_UNAVAILABLE`로 접혔는지 기록하고 근본 원인 runbook에 링크 + - Studio 클라이언트(Frontend `STUDIO_ERROR_CODES`) 쪽 재시도/백오프 동작이 기대대로 + 작동했는지 확인 + - 특정 slice 반복 장애면 chaos test 시나리오 추가 검토 + +## 7. Related + +- error-codes.yaml row: `STUDIO_UNAVAILABLE` (feature-techlog-studio-backend, + `dev.caskeleton.application.techlog.error.StudioError.STUDIO_UNAVAILABLE`) +- 매핑 지점: `dev.caskeleton.adapter.inbound.web.techlog.StudioExceptionHandler` +- 관련 runbook: `runbook://db/unavailable`, `runbook://cache/unavailable` +- 관련 branch: [[feature-techlog-studio-backend]] diff --git a/docs/superpowers/plans/2026-08-18-techlog-studio-backend-01-foundation.md b/docs/superpowers/plans/2026-08-18-techlog-studio-backend-01-foundation.md new file mode 100644 index 0000000..05153ab --- /dev/null +++ b/docs/superpowers/plans/2026-08-18-techlog-studio-backend-01-foundation.md @@ -0,0 +1,2594 @@ +# Tech Log Studio Backend — Plan 01: Foundation & First Vertical + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Studio 계약을 응답 봉투 형태로 확정하고 세 저장소를 정합시킨 뒤, `getStudioSession`과 `listStudioCatalog`가 실제 백엔드에서 동작하게 만든다. + +**Architecture:** 계약(`studio-v1.yaml`)이 세 저장소의 SSOT다. 계약을 먼저 봉투로 재정의하고 → 프론트가 재생성·언랩하고 → 백엔드가 payload DTO만 생성해 얇은 controller로 서빙한다. 백엔드 코드는 새 Gradle leaf 없이 기존 18 leaf 안의 `techlog` 하위 패키지에 들어간다. 응답 봉투는 기존 `EnvelopeBodyAdvice`가 그대로 씌운다. + +**Tech Stack:** Java 21, Spring Boot 4.0.0, Gradle (dependency lock), Flyway, PostgreSQL, JPA + JdbcClient, openapi-generator (models only), ArchUnit, JUnit 5 / AssertJ · TypeScript, Vite, Vitest, openapi-typescript 7.9.1 · Python 3 (설계 패키지 검증 스크립트) + +**Spec:** `docs/superpowers/specs/2026-08-18-techlog-studio-backend-design.md` + +## Global Constraints + +- **저장소 3개.** 작업 순서는 `DP → FE → BE`로 고정한다. 계약이 SSOT이기 때문이다. + - `DP` = `/home/donghyeon/workspace/tech-log-design-package` (branch `master`) + - `FE` = `/home/donghyeon/workspace/desktop-server-git/tech-log-frontend` + - `BE` = `/home/donghyeon/workspace/desktop-server-git/tech-log-backend` (branch `feature/techlog-studio-backend`) +- **BE는 커밋하지 않는다.** `AGENTS.md:64` — commit 정책 `human-only`. BE 태스크의 마지막 스텝은 커밋이 아니라 "변경 파일 목록 보고"다. DP·FE는 커밋한다. +- **BE 패키지 루트를 바꾸지 않는다.** 신규 코드는 `dev.caskeleton.domain.techlog.*`, `dev.caskeleton.application.techlog.*`, `dev.caskeleton.adapter.outbound.persistence.techlog.*`, `dev.caskeleton.adapter.inbound.web.techlog.*`에 넣는다. `dev.caskeleton.techlog.*`처럼 루트를 벗어나면 ArchUnit 규칙(`CleanArchitectureTest.java:221`)이 미적용된다. +- **`src/config/architecture/modules.json`과 `src/settings.gradle`을 수정하지 않는다.** leaf 18개는 fail-closed 불변식이다. +- **`application-core`에 외부 의존을 추가하지 않는다.** `verifyApplicationCoreDependencyPurity`가 Spring·slf4j·logback·micrometer를 클래스패스에서 금지한다. 트랜잭션은 `@Transactional`이 아니라 `dev.caskeleton.application.transaction.TransactionPort`를 쓴다. +- **`CommandUseCase`/`QueryUseCase` 구현은 이름이 `UseCase`로 끝나야 하고 `@UseCaseCapability`를 선언해야 한다.** enum 값은 `TransactionMode.{WRITE,READ_ONLY}`, `Idempotency.{IDEMPOTENT,KEYED,NOT_IDEMPOTENT}`, `RepositoryAccess.{NONE,READ_REPOSITORY,WRITE_REPOSITORY}`. +- **템플릿 파일을 고치지 않는다.** 특히 `EnvelopeBodyAdvice.java`, `GlobalExceptionHandler.java`, `SecurityConfig.java`, `ErrorResponseFactory.java`. 확장은 새 클래스로 한다. +- **Flyway 마이그레이션은 `src/adapter/outbound/persistence-jpa/src/main/resources/db/migration/postgresql/` 에 `V7`부터 추가한다.** 기존 최대는 `V6`이고 `out-of-order: false`다. 적용된 마이그레이션은 절대 수정하지 않는다. +- **CSRF 헤더 이름은 `X-CSRF-TOKEN`이다.** 계약이 `const`로 고정한다. 템플릿 기본값은 `X-XSRF-TOKEN`이므로 설정으로 바꾼다. +- **계약 오류 코드 23개는 그대로 쓴다.** 새 코드를 발명하지 않는다. + +--- + +## File Structure + +### DP — 계약 (SSOT) + +| 파일 | 책임 | +| --- | --- | +| `contracts/openapi/studio-v1.yaml` | Studio HTTP 계약. 이번에 봉투 형태로 재정의 | +| `decisions/ADR-006-response-envelope.md` | 봉투 채택과 RFC 7807 미채택 근거 (신규) | +| `docs/specs/06-api-contract-design.md` | 7장 오류 계약을 봉투로 재작성 | +| `contracts/openapi/public-v1.yaml` | 배너만 추가 (변환은 구현 착수 시) | +| `contracts/openapi/studio-management-v1.yaml` | 배너만 추가 | +| `scripts/check-consistency.py` | `ProblemDetails.code` → `ApiError.code` 참조 변경 | +| `scripts/check-contract-parity.py` | 동일 | + +### FE — 소비자 + +| 파일 | 책임 | +| --- | --- | +| `src/features/tech-log/contracts/studio/{studio-api.openapi.yaml,generated.ts,canonical-source.json}` | 생성물. `pnpm generate:tech-log-contract`가 만든다 | +| `src/features/tech-log/contracts/tech-log-studio-contract-contribution.ts` | 봉투 언랩 validator 2개 | +| `src/features/tech-log/adapters/http/studio-error-mapping.ts` | `ApiError` → `StudioGatewayError` | + +### BE — 구현 + +| 파일 | 책임 | +| --- | --- | +| `src/config/openapi/studio-v1.yaml` | 생성 입력 (DP에서 vendor) | +| `src/adapter/inbound/web/build.gradle` | openapi-generator 배선 | +| `src/application-core/.../application/techlog/error/StudioError.java` | 23개 코드 enum (`ApiErrorCode` 구현) | +| `src/application-core/.../application/techlog/error/StudioException.java` | `ApiErrorCarrier` 예외 | +| `src/adapter/inbound/web/.../web/techlog/StudioExceptionHandler.java` | `StudioException` → 봉투 | +| `src/adapter/inbound/web/.../web/techlog/studio/controller/*.java` | 얇은 controller | +| `src/adapter/outbound/persistence-jpa/.../db/migration/postgresql/V7__techlog_core.sql` | Tech Log 스키마 | +| `src/app-bootstrap/src/test/.../architecture/TechLogBoundaryArchTest.java` | bounded context 경계 규칙 | +| `docs/registries/error-codes.yaml` | 23개 row 추가 | + +--- + +## Task 1: 계약을 봉투 형태로 재정의 (DP) + +**Files:** +- Create: `DP/decisions/ADR-006-response-envelope.md` +- Modify: `DP/contracts/openapi/studio-v1.yaml` + +**Interfaces:** +- Produces: `ErrorEnvelope`, `ApiError`, `ResponseMeta`, `ValidationErrorDetails`, `VersionConflictDetails`, `PublicationConflictDetails` 스키마와 15개 `Envelope` 래퍼. Task 3(FE)과 Task 4(BE)가 이 계약에서 타입을 생성한다. +- Removes: `ProblemDetails` 스키마. Task 2가 이를 참조하는 스크립트를 고친다. + +- [ ] **Step 1: 현재 상태를 기록해 둔다** + +```bash +cd /home/donghyeon/workspace/tech-log-design-package +git status --short +grep -c 'ProblemDetails' contracts/openapi/studio-v1.yaml +``` + +Expected: 워킹트리 clean, `ProblemDetails` 참조 17개 (스키마 정의 1 + 응답 16). + +- [ ] **Step 2: ADR-006을 쓴다** + +`DP/decisions/ADR-006-response-envelope.md`: + +```markdown +# ADR-006: 응답 봉투를 wire format으로 채택한다 + +- 상태: 채택 +- 날짜: 2026-08-18 +- 관련: ADR-004 (Contract First OpenAPI) + +## 맥락 + +Backend 구현 저장소 `tech-log-backend`는 `clean-architecture-backend-template` +스냅샷이며, 모든 JSON 응답을 봉투로 감싸는 것이 그 템플릿의 문서화된 결정이다. + +> `shared-contract/README.md` — "RFC 7807 ProblemDetail 을 대체한다(boundary D5/D6)", +> "D5 가 RFC 7807 ProblemDetail 을 거부하고, D10 이 `category`를 1급 필드로 추가했다" + +반면 이 설계 패키지의 `studio-v1.yaml`은 bare payload + RFC 7807 `ProblemDetails`를 +쓰고 있었다. 두 문서화된 결정이 충돌한다. + +## 결정 + +**Studio 계약의 wire format을 봉투로 통일한다.** + +```jsonc +// 성공 +{ "success": true, "data": { ... }, "meta": { "requestId": "...", "traceId": "...", "correlationId": null, "page": null } } +// 실패 (HTTP status는 그대로 의미를 갖는다) +{ "success": false, "error": { "code": "VERSION_CONFLICT", "category": "CONFLICT", + "message": "...", "retryable": false, "details": { ... } }, + "meta": { ... } } +``` + +미디어 타입은 성공·실패 모두 `application/json`이다. `application/problem+json`은 +쓰지 않는다. + +## 근거 + +1. **정보 손실이 없다.** `ProblemDetails`가 담던 것을 전부 옮길 수 있고, + `error.category`(10-value enum)가 덤으로 붙는다. `type`/`title`은 버리되 + Frontend가 이미 `code`에서 합성하고 있다. +2. **적응 코드의 위치.** 봉투를 벗기려면 Backend 템플릿 파일 + `EnvelopeBodyAdvice`를 고쳐야 하는데, 그 저장소는 tracked-snapshot이라 + 이후 모든 template sync의 충돌 지점이 된다. 봉투를 유지하면 적응이 Frontend + 제품 코드 안에서 끝나고 Frontend 플랫폼도 무변경이다. +3. **두 규약 모두 실무 표준이다.** RFC 9457은 IETF 표준이고 봉투는 1st-party SPA + 조합에서 널리 쓰인다. 어느 쪽도 틀리지 않으므로 변경 표면이 작은 쪽을 고른다. + +## 대가 + +- OpenAPI 계약이 봉투를 기술하게 되어 payload 스키마가 한 겹 안으로 들어간다. +- Backend는 생성 API interface를 쓸 수 없다. 생성 interface가 봉투 wrapper 타입을 + 반환하면 `EnvelopeBodyAdvice`가 한 번 더 감싸 이중 래핑이 된다. 따라서 Backend는 + **model만 생성하고 controller를 손으로 쓴다.** + +## 범위 + +이번 개정은 `studio-v1.yaml`에만 적용한다. `public-v1.yaml`과 +`studio-management-v1.yaml`은 소비자가 없으므로 배너만 붙이고 구현 착수 시 변환한다. +``` + +- [ ] **Step 3: 계약 버전을 올린다** + +봉투 도입은 breaking change다. + +```bash +cd /home/donghyeon/workspace/tech-log-design-package +sed -i 's/^ version: 2\.0\.0$/ version: 3.0.0/' contracts/openapi/studio-v1.yaml +grep -n '^ version:' contracts/openapi/studio-v1.yaml +``` + +Expected: `version: 3.0.0` + +- [ ] **Step 4: 오류 응답 16개를 봉투로 바꾼다** + +flow-style이라 한 줄 치환으로 끝난다. + +```bash +cd /home/donghyeon/workspace/tech-log-design-package +sed -i 's|application/problem+json: { schema: { \$ref: "#/components/schemas/ProblemDetails" } }|application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } }|g' contracts/openapi/studio-v1.yaml +grep -c 'ErrorEnvelope' contracts/openapi/studio-v1.yaml +grep -c 'application/problem+json' contracts/openapi/studio-v1.yaml +``` + +Expected: `ErrorEnvelope` 16개, `application/problem+json` 0개. + +- [ ] **Step 5: 성공 응답 18개를 래퍼로 바꾼다** + +`paths` 구간(1행 ~ `components:` 직전)에서, `requestBody`가 없는 줄의 payload +`$ref`만 `Envelope`로 바꾼다. requestBody는 봉투로 감싸지 않는다. + +```bash +cd /home/donghyeon/workspace/tech-log-design-package +python3 - <<'PY' +PATH_ = "contracts/openapi/studio-v1.yaml" +PAYLOADS = ["StudioSession","StudioDashboard","DocumentPage","WorkingCopyDetail","WorkingCopy", + "ValidationReport","PreviewDetail","PublicPreview","PublishResult","PublicationPage", + "PublicationSnapshot","CatalogPage","AssetPage","AssetDetail","Asset"] +lines = open(PATH_, encoding="utf-8").read().split("\n") +end = next(i for i, l in enumerate(lines) if l.startswith("components:")) +changed = 0 +for i in range(end): + line = lines[i] + if "requestBody" in line or "application/json" not in line: + continue + for p in PAYLOADS: # 긴 이름부터 매칭돼야 Asset이 AssetPage를 먹지 않는다 + old = f'"#/components/schemas/{p}"' + if old in line: + lines[i] = line.replace(old, f'"#/components/schemas/{p}Envelope"') + changed += 1 + break +open(PATH_, "w", encoding="utf-8").write("\n".join(lines)) +print("wrapped:", changed) +PY +``` + +Expected: `wrapped: 18` + +- [ ] **Step 6: 치환 결과를 눈으로 확인한다** + +```bash +cd /home/donghyeon/workspace/tech-log-design-package +grep -nE 'Envelope"' contracts/openapi/studio-v1.yaml | head -20 +grep -n 'requestBody' contracts/openapi/studio-v1.yaml | grep Envelope +``` + +Expected: 첫 명령은 18줄, 두 번째 명령은 **출력 없음**(requestBody가 감싸이지 않았다). + +- [ ] **Step 7: 봉투 스키마를 추가하고 `ProblemDetails`를 제거한다** + +`components.schemas`의 맨 앞(`StudioSession:` 바로 위)에 아래를 넣는다. + +```yaml + # ------------------------------------------------------------- envelope + # wire format은 봉투다 (ADR-006). payload 스키마는 그대로 두고 + # 응답만 Envelope으로 감싼다. + ResponseMeta: + type: object + additionalProperties: false + required: [requestId, traceId] + properties: + requestId: { type: string, minLength: 1, maxLength: 200 } + traceId: { type: string, minLength: 1, maxLength: 200 } + correlationId: { type: [string, "null"], maxLength: 200 } + page: { type: "null", description: Studio는 body 안 cursor 페이지네이션을 쓰므로 항상 null이다 } + ApiError: + type: object + additionalProperties: false + required: [code, category, message, retryable] + properties: + code: + type: string + enum: [AUTHENTICATION_REQUIRED, STUDIO_ACCESS_DENIED, DOCUMENT_NOT_FOUND, VERSION_CONFLICT, + REQUEST_VALIDATION_FAILED, VALIDATION_FAILED, VALIDATION_STALE, PREVIEW_NOT_FOUND, + PREVIEW_STALE, PREVIEW_EXPIRED, PUBLICATION_NOT_FOUND, PUBLICATION_CONFLICT, + PUBLICATION_EVENT_NOT_FOUND, PUBLICATION_SNAPSHOT_NOT_FOUND, + WARNING_ACKNOWLEDGEMENT_REQUIRED, IDEMPOTENCY_KEY_REUSED, ASSET_NOT_FOUND, + ASSET_NOT_READY, ASSET_IN_USE, ASSET_QUARANTINED, PAYLOAD_TOO_LARGE, + UNSUPPORTED_MEDIA_TYPE, STUDIO_UNAVAILABLE] + category: + type: string + enum: [VALIDATION, AUTH, AUTHZ, NOT_FOUND, CONFLICT, RATE_LIMIT, + TRANSIENT_DEPENDENCY, PERMANENT_DEPENDENCY, DATA_INTEGRITY, INTERNAL] + message: { type: string, minLength: 1, maxLength: 5000 } + retryable: { type: boolean } + details: + oneOf: + - $ref: "#/components/schemas/ValidationErrorDetails" + - $ref: "#/components/schemas/VersionConflictDetails" + - $ref: "#/components/schemas/PublicationConflictDetails" + - type: "null" + ErrorEnvelope: + type: object + additionalProperties: false + required: [success, error, meta] + properties: + success: { type: boolean, const: false } + error: { $ref: "#/components/schemas/ApiError" } + meta: { $ref: "#/components/schemas/ResponseMeta" } + ValidationErrorDetails: + type: object + additionalProperties: false + required: [fieldErrors] + properties: + fieldErrors: { type: array, maxItems: 200, items: { $ref: "#/components/schemas/FieldError" } } + VersionConflictDetails: + type: object + additionalProperties: false + required: [latestDocument] + properties: + latestDocument: { $ref: "#/components/schemas/WorkingCopyDetail" } + conflictingFields: + type: array + uniqueItems: true + maxItems: 200 + items: { type: string, pattern: "^(?:/(?:[^~/]|~0|~1)*)*$" } + PublicationConflictDetails: + type: object + additionalProperties: false + required: [latestPublication] + properties: + latestPublication: { $ref: "#/components/schemas/PublicationAggregate" } +``` + +이어서 15개 래퍼를 같은 위치에 넣는다. ``는 Step 5의 `PAYLOADS` 목록과 같다. + +```yaml + StudioSessionEnvelope: + type: object + additionalProperties: false + required: [success, data, meta] + properties: + success: { type: boolean, const: true } + data: { $ref: "#/components/schemas/StudioSession" } + meta: { $ref: "#/components/schemas/ResponseMeta" } +``` + +나머지 14개(`StudioDashboardEnvelope`, `DocumentPageEnvelope`, `WorkingCopyEnvelope`, +`WorkingCopyDetailEnvelope`, `ValidationReportEnvelope`, `PreviewDetailEnvelope`, +`PublicPreviewEnvelope`, `PublishResultEnvelope`, `PublicationPageEnvelope`, +`PublicationSnapshotEnvelope`, `CatalogPageEnvelope`, `AssetPageEnvelope`, +`AssetEnvelope`, `AssetDetailEnvelope`)도 `data`의 `$ref`만 바꿔 동일하게 쓴다. + +그리고 기존 `ProblemDetails:` 스키마 블록 전체를 삭제한다. + +- [ ] **Step 8: 계약이 파싱되고 참조가 다 풀리는지 확인한다** + +```bash +cd /home/donghyeon/workspace/tech-log-design-package +./scripts/check-openapi.py contracts/openapi/studio-v1.yaml +``` + +Expected: PASS. `$ref` 미해결이나 operationId 중복이 없어야 한다. 실패하면 오타난 +래퍼 이름을 고친다. + +- [ ] **Step 9: 봉투 구조를 프로그램으로 검산한다** + +```bash +cd /home/donghyeon/workspace/tech-log-design-package +python3 - <<'PY' +import yaml +d = yaml.safe_load(open("contracts/openapi/studio-v1.yaml", encoding="utf-8")) +s, M = d["components"]["schemas"], {"get","put","post","delete","patch"} +assert "ProblemDetails" not in s, "ProblemDetails가 남아 있다" +assert len(s["ApiError"]["properties"]["code"]["enum"]) == 23 +bad = [] +for p, item in d["paths"].items(): + for m, op in item.items(): + if m not in M: continue + for code, r in op["responses"].items(): + ct = r.get("content") + if not ct: continue + ref = ct["application/json"]["schema"]["$ref"].rsplit("/", 1)[-1] + if not ref.endswith("Envelope"): bad.append(f"{op['operationId']} {code} {ref}") +for name, r in d["components"]["responses"].items(): + ref = r["content"]["application/json"]["schema"]["$ref"].rsplit("/", 1)[-1] + if ref != "ErrorEnvelope": bad.append(f"responses.{name} {ref}") +print("FAIL:", bad) if bad else print("OK: 모든 응답이 봉투다") +PY +``` + +Expected: `OK: 모든 응답이 봉투다` + +- [ ] **Step 10: 커밋** + +```bash +cd /home/donghyeon/workspace/tech-log-design-package +git add contracts/openapi/studio-v1.yaml decisions/ADR-006-response-envelope.md +git commit -m "contract: Studio 응답을 봉투 형태로 재정의하고 ADR-006 기록" +``` + +--- + +## Task 2: 설계 패키지 정합 복구 (DP) + +Task 1이 `ProblemDetails`를 제거했으므로 이를 참조하는 스크립트와 문서가 깨져 있다. + +**Files:** +- Modify: `DP/scripts/check-consistency.py:179`, `:262` +- Modify: `DP/scripts/check-contract-parity.py` (오류 코드 비교 블록) +- Modify: `DP/docs/specs/06-api-contract-design.md` (7장) +- Modify: `DP/contracts/openapi/public-v1.yaml`, `DP/contracts/openapi/studio-management-v1.yaml` (배너) +- Modify: `DP/TECH_LOG_MASTER_SPEC.md`, `DP/MANIFEST.sha256` (재생성) + +**Interfaces:** +- Consumes: Task 1의 `ApiError.code` enum +- Produces: 통과하는 검증 스크립트 4종. Task 3(FE)이 `check-contract-parity.py`로 자기 변경을 검증한다. + +- [ ] **Step 1: 깨진 것을 먼저 확인한다** + +```bash +cd /home/donghyeon/workspace/tech-log-design-package +./scripts/check-consistency.py; echo "exit=$?" +./scripts/check-contract-parity.py; echo "exit=$?" +``` + +Expected: 둘 다 `KeyError: 'ProblemDetails'` 또는 non-zero exit. + +- [ ] **Step 2: `check-consistency.py`의 참조를 옮긴다** + +`179`행 부근: + +```python +codes = set(S["ProblemDetails"]["properties"]["code"]["enum"]) +``` + +를 + +```python +codes = set(S["ApiError"]["properties"]["code"]["enum"]) +``` + +로 바꾸고, `262`행 부근의 + +```python +fe_codes = set(fe["components"]["schemas"]["ProblemDetails"]["properties"]["code"]["enum"]) +``` + +를 + +```python +fe_codes = set(fe["components"]["schemas"]["ApiError"]["properties"]["code"]["enum"]) +``` + +로 바꾼다. + +- [ ] **Step 3: `check-contract-parity.py`의 참조를 옮긴다** + +`ProblemDetails`를 읽는 두 줄(FE/BE 오류 코드 비교)을 `ApiError`로 바꾼다. + +```bash +cd /home/donghyeon/workspace/tech-log-design-package +sed -i 's/\["ProblemDetails"\]/["ApiError"]/g' scripts/check-contract-parity.py scripts/check-consistency.py +grep -n 'ApiError' scripts/check-contract-parity.py scripts/check-consistency.py +``` + +Expected: 각 파일에서 치환된 줄이 보이고 `ProblemDetails` 잔재가 없다. + +- [ ] **Step 4: `check-consistency.py`가 통과하는지 본다** + +```bash +cd /home/donghyeon/workspace/tech-log-design-package +./scripts/check-consistency.py; echo "exit=$?" +``` + +Expected: `exit=0`. `check-contract-parity.py`는 FE가 아직 재생성 전이라 이 시점에 +실패할 수 있다 — Task 3에서 통과시킨다. + +- [ ] **Step 5: 06장 오류 계약을 봉투로 재작성한다** + +`DP/docs/specs/06-api-contract-design.md`의 `## 7. 오류 계약` 절 본문을 아래로 바꾼다. +7.1~7.4 하위 절의 내용(HTTP 상태, 코드 목록, 혼동 금지, 충돌 부가 정보)은 유지하되 +표현 매체만 봉투로 옮긴다. + +```markdown +## 7. 오류 계약 + +wire format은 봉투다 (ADR-006). `application/problem+json`과 RFC 7807은 쓰지 않는다. + +```jsonc +{ + "success": false, + "error": { + "code": "VERSION_CONFLICT", + "category": "CONFLICT", + "message": "저장된 version이 더 최신입니다", + "retryable": false, + "details": { "latestDocument": { }, "conflictingFields": ["/title"] } + }, + "meta": { "requestId": "...", "traceId": "...", "correlationId": null, "page": null } +} +``` + +HTTP status는 그대로 의미를 갖는다. `success: false`는 status를 대체하지 않고 중복 +표기한다. + +`error.category`는 10개 값이며 클라이언트가 23개 코드를 전부 열거하지 않고도 굵게 +분기할 수 있게 한다. + +```text +VALIDATION AUTH AUTHZ NOT_FOUND CONFLICT RATE_LIMIT +TRANSIENT_DEPENDENCY PERMANENT_DEPENDENCY DATA_INTEGRITY INTERNAL +``` + +`error.details`는 code별 polymorphic이며 계약에서 `oneOf`로 선언한다. + +```text +REQUEST_VALIDATION_FAILED / VALIDATION_FAILED → ValidationErrorDetails fieldErrors[] +VERSION_CONFLICT → VersionConflictDetails latestDocument, conflictingFields[] +PUBLICATION_CONFLICT → PublicationConflictDetails latestPublication +그 외 → null +``` + +`traceId`는 `meta.traceId`에 있으며 응답에서 절대 null이 아니다. +``` + +- [ ] **Step 6: 나머지 두 계약에 배너를 붙인다** + +`public-v1.yaml`과 `studio-management-v1.yaml`의 `info.description` 맨 앞에 넣는다. + +```text +⛔ 봉투 결정(ADR-006) 반영 대기 — 이 계약은 아직 bare payload + ProblemDetails다. +소비자가 없어 변환을 미뤘다. 구현에 착수할 때 studio-v1.yaml과 같은 방식으로 +ErrorEnvelope / Envelope으로 변환한다. +``` + +- [ ] **Step 7: MASTER_SPEC과 MANIFEST를 재생성한다** + +```bash +cd /home/donghyeon/workspace/tech-log-design-package +./scripts/build-master-spec.sh +./scripts/update-manifest.sh +./scripts/build-master-spec.sh --check; echo "master=$?" +./scripts/update-manifest.sh --check; echo "manifest=$?" +``` + +Expected: 둘 다 `=0`. + +- [ ] **Step 8: 커밋** + +```bash +cd /home/donghyeon/workspace/tech-log-design-package +git add scripts/ docs/specs/06-api-contract-design.md contracts/openapi/ TECH_LOG_MASTER_SPEC.md MANIFEST.sha256 +git commit -m "spec/scripts: 봉투 결정에 맞춰 오류 계약과 검증 스크립트 정합" +``` + +--- + +## Task 3: 프론트 계약 재생성과 봉투 언랩 (FE) + +**Files:** +- Modify: `FE/src/features/tech-log/contracts/studio/studio-api.openapi.yaml` (생성물) +- Modify: `FE/src/features/tech-log/contracts/studio/generated.ts` (생성물) +- Modify: `FE/src/features/tech-log/contracts/studio/canonical-source.json` (생성물) +- Modify: `FE/src/features/tech-log/contracts/tech-log-studio-contract-contribution.ts` +- Modify: `FE/src/features/tech-log/adapters/http/studio-error-mapping.ts` + +**Interfaces:** +- Consumes: Task 1의 `studio-v1.yaml` v3.0.0 +- Produces: 봉투를 언랩하는 `envelopeData` / `envelopeError` validator. 앱·도메인 계층은 기존과 같은 payload 타입을 계속 받는다 — `StudioGateway` 포트 시그니처는 바뀌지 않는다. + +- [ ] **Step 1: 작업 브랜치를 만든다** + +현재 브랜치 `fix/techlog-alignment-followups`가 최신 작업 상태이므로 그 위에서 딴다. + +```bash +cd /home/donghyeon/workspace/desktop-server-git/tech-log-frontend +git status --short +git checkout -b feature/studio-response-envelope +``` + +Expected: 새 브랜치로 전환. 워킹트리가 더러우면 먼저 사용자에게 보고하고 멈춘다. + +- [ ] **Step 2: 드리프트 게이트가 지금은 통과하는지 확인한다 (기준선)** + +```bash +cd /home/donghyeon/workspace/desktop-server-git/tech-log-frontend +corepack pnpm check:tech-log-contract; echo "exit=$?" +``` + +Expected: `exit=0` (아직 vendor된 구 계약과 digest가 맞는다). + +- [ ] **Step 3: 계약을 재생성한다** + +생성기는 `$TECH_LOG_DESIGN_PACKAGE/contracts/openapi/studio-v1.yaml`을 읽고 +`pnpm dlx openapi-typescript@7.9.1`로 타입을 만든다. + +```bash +cd /home/donghyeon/workspace/desktop-server-git/tech-log-frontend +corepack pnpm generate:tech-log-contract +git diff --stat src/features/tech-log/contracts/studio/ +``` + +Expected: 세 파일 모두 변경. `pnpm dlx`가 네트워크를 쓰므로 실패하면 오프라인이 +원인이다 — 그 경우 사용자에게 보고하고 멈춘다(수기 편집으로 우회하지 않는다. +`--check`가 digest로 잡아낸다). + +- [ ] **Step 4: 생성 타입에 봉투가 들어왔는지 확인한다** + +```bash +cd /home/donghyeon/workspace/desktop-server-git/tech-log-frontend +grep -c 'Envelope' src/features/tech-log/contracts/studio/generated.ts +grep -c 'ProblemDetails' src/features/tech-log/contracts/studio/generated.ts +``` + +Expected: `Envelope` 다수, `ProblemDetails` 0. + +- [ ] **Step 5: 언랩 validator의 실패 테스트를 쓴다** + +`FE/tests/features/tech-log/studio-envelope-unwrap.test.ts`: + +```ts +import { describe, expect, it } from "vitest"; +import { envelopeData, envelopeError } from "../../../src/features/tech-log/contracts/tech-log-studio-contract-contribution.ts"; + +describe("studio 봉투 언랩", () => { + it("성공 봉투에서 data를 꺼낸다", () => { + const result = envelopeData("getStudioSessionOutput").safeParse({ + success: true, + data: { authenticated: true, displayName: "d", roles: [], csrfToken: "t", csrfHeaderName: "X-CSRF-TOKEN" }, + meta: { requestId: "r", traceId: "t", correlationId: null, page: null }, + }); + expect(result.success).toBe(true); + if (result.success) expect(result.data).toMatchObject({ displayName: "d" }); + }); + + it("봉투가 아닌 본문을 거절한다", () => { + const result = envelopeData("getStudioSessionOutput").safeParse({ displayName: "d" }); + expect(result.success).toBe(false); + }); + + it("오류 봉투를 ProblemDetails 형태로 옮긴다", () => { + const result = envelopeError().safeParse({ + success: false, + error: { code: "VERSION_CONFLICT", category: "CONFLICT", message: "conflict", retryable: false, details: null }, + meta: { requestId: "r", traceId: "tr", correlationId: null, page: null }, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.code).toBe("VERSION_CONFLICT"); + expect(result.data.status).toBe(0); + expect(result.data.title).toBe("VERSION_CONFLICT"); + } + }); + + it("계약 밖 코드를 거절한다", () => { + const result = envelopeError().safeParse({ + success: false, + error: { code: "NOT_A_STUDIO_CODE", category: "INTERNAL", message: "x", retryable: false, details: null }, + meta: { requestId: "r", traceId: "tr", correlationId: null, page: null }, + }); + expect(result.success).toBe(false); + }); +}); +``` + +- [ ] **Step 6: 테스트를 돌려 실패를 확인한다** + +```bash +cd /home/donghyeon/workspace/desktop-server-git/tech-log-frontend +corepack pnpm vitest run tests/features/tech-log/studio-envelope-unwrap.test.ts +``` + +Expected: FAIL — `envelopeData`/`envelopeError` export가 없다. + +- [ ] **Step 7: validator를 구현한다** + +`FE/src/features/tech-log/contracts/tech-log-studio-contract-contribution.ts`에서 +`passthrough`/`problemSchema`/`PROBLEM` 정의부를 아래로 교체한다. + +```ts +/** + * wire format은 봉투다 (ADR-006). 전송 계층은 봉투 뼈대만 검증하고 payload는 + * 통과시킨다 — generated 타입이 컴파일 시점 계약이고, 런타임 재검증은 계약 갱신 + * 때마다 두 곳을 고치게 만든다. 다만 봉투 자체는 반드시 검증한다: 여기서 통과시키면 + * 잘못된 모양이 앱 계층까지 조용히 흘러간다. + */ +const metaSchema = z + .object({ requestId: z.string().min(1), traceId: z.string().min(1) }) + .loose(); + +export const envelopeData = (schemaId: string): RuntimeValidator => + zodValidator( + schemaId, + z + .object({ success: z.literal(true), data: z.unknown(), meta: metaSchema }) + .loose() + .transform((envelope) => envelope.data as T) as unknown as z.ZodType, + ); + +const apiErrorSchema = z + .object({ + code: z.enum(STUDIO_ERROR_CODES as unknown as [string, ...string[]]), + category: z.string().min(1), + message: z.string().min(1).max(5000), + retryable: z.boolean(), + }) + .loose(); + +/** + * 봉투 오류를 기존 ProblemDetails 형태로 옮긴다. 앱 계층(`StudioGatewayError`)은 + * 그 모양을 계속 쓰므로 매핑을 여기서 끝내면 아래 계층이 무변경이다. + * `status`는 봉투에 없다 — 전송 계층이 실제 HTTP status를 따로 들고 있으므로 + * 0으로 두고 `toStudioGatewayError`가 outcome의 status로 덮는다. + */ +export const envelopeError = (): RuntimeValidator => + zodValidator( + "StudioErrorEnvelope", + z + .object({ success: z.literal(false), error: apiErrorSchema, meta: metaSchema }) + .loose() + .transform((envelope) => ({ + type: `https://techlog.local/problems/${envelope.error.code.toLowerCase().replaceAll("_", "-")}`, + title: envelope.error.code, + status: 0, + detail: envelope.error.message, + code: envelope.error.code, + retryable: envelope.error.retryable, + category: envelope.error.category, + details: (envelope.error as { details?: unknown }).details ?? null, + })) as unknown as z.ZodType, + ); + +export type StudioProblemShape = Readonly<{ + type: string; + title: string; + status: number; + detail: string; + code: string; + retryable: boolean; + category: string; + details: unknown; +}>; + +const PROBLEM = envelopeError(); +``` + +그리고 `safeOperation`과 mutating operation 정의의 + +```ts + outputValidator: passthrough(`${operationId}Output`), +``` + +을 전부 + +```ts + outputValidator: envelopeData(`${operationId}Output`), +``` + +로 바꾼다. `inputValidator`는 요청 본문이라 그대로 `passthrough`를 쓴다. + +- [ ] **Step 8: `status`를 실제 HTTP status로 덮는다** + +`FE/src/features/tech-log/adapters/http/studio-error-mapping.ts`의 `PROBLEM` 분기에서 +`outcome.problem`의 `status`가 0이면 전송 계층이 아는 status로 채운다. + +```ts + case "PROBLEM": { + const problem = outcome.problem as ProblemDetails; + const status = problem.status === 0 + ? (outcome.metadata?.httpStatus ?? 0) + : problem.status; + if (!CODES.has(problem.code)) { + return synthetic("STUDIO_UNAVAILABLE", status, problem.detail, true); + } + return new StudioGatewayError({ ...problem, status }); + } +``` + +`outcome.metadata`의 status 필드명이 다르면 `src/adapters/http/http-execution-v3.ts`의 +`SafeResponseMetadata` 정의를 읽어 맞춘다. + +- [ ] **Step 9: 테스트를 돌려 통과를 확인한다** + +```bash +cd /home/donghyeon/workspace/desktop-server-git/tech-log-frontend +corepack pnpm vitest run tests/features/tech-log/studio-envelope-unwrap.test.ts +``` + +Expected: 4개 PASS. + +- [ ] **Step 10: tech-log 전체 테스트와 드리프트 게이트를 돌린다** + +```bash +cd /home/donghyeon/workspace/desktop-server-git/tech-log-frontend +corepack pnpm check:tech-log-contract; echo "contract=$?" +corepack pnpm test:tech-log; echo "tests=$?" +corepack pnpm exec tsc -p tsconfig.app.json --noEmit; echo "types=$?" +``` + +Expected: 셋 다 `=0`. mock 게이트웨이는 전송 경계 위에 있어 영향받지 않아야 한다. +깨지면 그 테스트가 HTTP 본문을 직접 만들고 있는 것이므로 봉투로 감싸 고친다. + +- [ ] **Step 11: 계약 parity를 확인한다** + +```bash +cd /home/donghyeon/workspace/tech-log-design-package +./scripts/check-contract-parity.py; echo "exit=$?" +``` + +Expected: `exit=0`, operation 19/19 일치, 오류 코드 23/23 보존. + +- [ ] **Step 12: 커밋** + +```bash +cd /home/donghyeon/workspace/desktop-server-git/tech-log-frontend +git add src/features/tech-log tests/features/tech-log/studio-envelope-unwrap.test.ts +git commit -m "feat: Studio 응답 봉투를 전송 경계에서 언랩한다" +``` + +--- + +## Task 4: 백엔드 계약 vendor와 model 생성 배선 (BE) + +**Files:** +- Create: `BE/src/config/openapi/studio-v1.yaml` +- Create: `BE/src/config/openapi/MANIFEST.sha256` +- Modify: `BE/src/adapter/inbound/web/build.gradle` +- Modify: `BE/src/adapter/inbound/web/gradle.lockfile` (재생성) + +**Interfaces:** +- Produces: `dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.*` 패키지의 생성 DTO. Task 8·9의 controller가 이 타입을 반환한다. + +- [ ] **Step 1: 브랜치와 워킹트리를 확인한다** + +```bash +cd /home/donghyeon/workspace/desktop-server-git/tech-log-backend +git branch --show-current +git status --short | grep -v '^ D ' | head +``` + +Expected: `feature/techlog-studio-backend`. 미커밋 삭제분(`*-superpowers-package/`, +`scripts/verify-httpclient-docs.py`)은 이미 있던 것이므로 건드리지 않는다. + +- [ ] **Step 2: 계약을 vendor하고 해시를 기록한다** + +```bash +cd /home/donghyeon/workspace/desktop-server-git/tech-log-backend +mkdir -p src/config/openapi +cp /home/donghyeon/workspace/tech-log-design-package/contracts/openapi/studio-v1.yaml src/config/openapi/studio-v1.yaml +( cd src/config/openapi && sha256sum studio-v1.yaml > MANIFEST.sha256 ) +cat src/config/openapi/MANIFEST.sha256 +``` + +Expected: ` studio-v1.yaml` 한 줄. + +> **2026-08-18 실측 — 이 스파이크는 실패했고 계약을 고쳐 해소했다.** +> +> 1. `generateApis` / `generateModels`는 openapi-generator-gradle-plugin 7.18.0에 +> **존재하지 않는 속성**이다. 설정하면 Gradle 평가에서 +> `Could not set unknown property 'generateApis'`로 죽는다. 실제 API는 +> `globalProperties`이고 `['models': '']`가 "model만 생성"을 뜻한다. +> 2. 올바른 설정으로도 생성이 NPE로 죽는다 — +> `DefaultCodegen.setEnumDiscriminatorDefaultValue`에서 +> `"var.allowableValues" is null` (model `WorkingCopyInput` 처리 중). +> 계약의 5개 discriminator `oneOf` union이 하위 타입의 discriminator 필드를 +> OpenAPI 3.1 관용구인 `const`로 좁히기 때문이다. +> `legacyDiscriminatorBehavior: false`로도 동일하게 실패한다. 네트워크 문제 아님. +> 3. **해소:** discriminator로 쓰이는 필드의 `const: X`를 의미가 같은 단일값 +> `enum: [X]`로 바꾼다. `discriminator` 키워드는 3.1의 `const`보다 앞서 만들어졌고 +> 생태계 관용구가 단일값 `enum`이라, 우회가 아니라 계약을 표준 관용구에 맞추는 것이다. +> 봉투 래퍼의 `success: { const: true|false }` 16개와 `csrfHeaderName`의 `const`는 +> discriminator가 아니므로 **그대로 둔다.** +> 4. spec §5.2의 "1회 생성 후 커밋" 폴백은 **쓸 수 없다** — 생성이 한 번은 성공한다는 +> 전제인데 생성 자체가 실패하기 때문이다. + +- [ ] **Step 3: 생성기 조합을 폐기용 스파이크로 검증한다** + +openapi-generator 7.x × Spring Boot 4.0.0 조합은 이 저장소에서 검증된 적이 없다. +본 배선 전에 별도 디렉터리에서 먼저 돌려본다. + +```bash +cd /tmp/claude-1000/-home-donghyeon-workspace-tech-log-design-package/e90b7626-9073-4d69-acc3-41bfc22d3e83/scratchpad +mkdir -p genspike && cd genspike +cat > build.gradle <<'EOF' +plugins { id 'java'; id 'org.openapi.generator' version '7.18.0' } +repositories { mavenCentral() } +openApiGenerate { + generatorName = 'spring' + inputSpec = '/home/donghyeon/workspace/desktop-server-git/tech-log-backend/src/config/openapi/studio-v1.yaml' + outputDir = "$projectDir/out".toString() + apiPackage = 'spike.api' + modelPackage = 'spike.model' + globalProperties.set(['models': '']) // model만 생성 (generateApis/generateModels는 존재하지 않는 속성) + configOptions = [useSpringBoot3: 'true', useJakartaEe: 'true', openApiNullable: 'true'] +} +EOF +cat > settings.gradle <<'EOF' +rootProject.name = 'genspike' +EOF +/home/donghyeon/workspace/desktop-server-git/tech-log-backend/src/gradlew --project-dir . openApiGenerate --console=plain 2>&1 | tail -20 +ls out/src/main/java/spike/model | head +ls out/src/main/java/spike/model | wc -l +``` + +Expected: BUILD SUCCESSFUL, `spike/model`에 100개 안팎의 `.java`. 실패하면 +**여기서 멈추고** 보고한다. 위 실측 노트가 이미 원인과 해소를 담고 있다. +이 디렉터리는 폐기물이며 저장소에 남기지 않는다. + +- [ ] **Step 4: web 모듈에 생성기를 배선한다** + +`BE/src/adapter/inbound/web/build.gradle` 맨 위 `plugins` 블록이 없으면 파일 첫 줄에 추가하고, +파일 끝에 아래를 붙인다. + +```groovy +// --------------------------------------------------------------------------- +// Studio 계약 DTO 생성 (ADR-004 / ADR-006). +// generateApis=false: 계약이 봉투를 기술하므로 생성 API interface는 봉투 wrapper +// 타입을 반환하게 되고, 그 타입은 dev.caskeleton.shared.response.Envelope가 아니라서 +// EnvelopeBodyAdvice가 한 번 더 감싼다(이중 래핑). controller는 손으로 쓴다. +// --------------------------------------------------------------------------- +openApiGenerate { + generatorName = 'spring' + inputSpec = "${rootDir}/config/openapi/studio-v1.yaml".toString() + outputDir = layout.buildDirectory.dir('generated/openapi').get().asFile.path + modelPackage = 'dev.caskeleton.adapter.inbound.web.techlog.studio.api.model' + globalProperties.set(['models': '']) // model만 생성 (generateApis/generateModels는 존재하지 않는 속성) + generateModelTests = false + generateModelDocumentation = false + generateSupportingFiles = false + configOptions = [useSpringBoot3: 'true', useJakartaEe: 'true', openApiNullable: 'true'] +} + +sourceSets.main.java.srcDir( + layout.buildDirectory.dir('generated/openapi/src/main/java')) + +tasks.named('compileJava') { dependsOn 'openApiGenerate' } + +// 생성 코드는 품질 게이트 대상이 아니다. 단 기존 web 코드의 게이트는 유지한다 — +// SpotBugs를 모듈 전체에서 끄면 손으로 쓴 controller도 검사받지 않는다. +tasks.matching { it.name.startsWith('spotless') }.configureEach { + dependsOn 'openApiGenerate' +} +spotless { java { targetExclude('build/generated/**') } } +tasks.withType(com.github.spotbugs.snom.SpotBugsTask).configureEach { + dependsOn 'openApiGenerate' + excludeFilter = file("${rootDir}/config/spotbugs/generated-openapi-exclude.xml") +} +``` + +`BE/src/config/spotbugs/generated-openapi-exclude.xml`: + +```xml + + + + + + + +``` + +기존 `config/spotbugs/`에 이미 exclude filter가 있으면 새 파일을 만들지 말고 그 파일에 +위 `` 블록만 추가한다. + +```bash +ls /home/donghyeon/workspace/desktop-server-git/tech-log-backend/src/config/spotbugs/ +``` + +플러그인 선언은 `BE/src/build.gradle`의 루트 `plugins` 블록에 추가한다. + +```groovy + id 'org.openapi.generator' version '7.18.0' apply false +``` + +그리고 `BE/src/adapter/inbound/web/build.gradle` 첫 줄에 + +```groovy +plugins { id 'org.openapi.generator' } +``` + +- [ ] **Step 5: 생성과 컴파일을 확인한다** + +```bash +cd /home/donghyeon/workspace/desktop-server-git/tech-log-backend/src +./gradlew :adapter:inbound:web:openApiGenerate --console=plain +ls build/../adapter/inbound/web/build/generated/openapi/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/api/model | wc -l +./gradlew :adapter:inbound:web:compileJava --console=plain +``` + +Expected: 생성 파일 100개 안팎, `BUILD SUCCESSFUL`. + +기존 web 코드의 품질 게이트가 살아 있는지 확인한다. 아래가 통과해야 exclude filter가 +생성 패키지만 좁게 뺀 것이다. + +```bash +./gradlew :adapter:inbound:web:check --console=plain 2>&1 | tail -20 +``` + +- [ ] **Step 6: 의존 lock을 재생성한다** + +```bash +cd /home/donghyeon/workspace/desktop-server-git/tech-log-backend/src +./gradlew :adapter:inbound:web:dependencies --write-locks --console=plain > /dev/null +./gradlew :adapter:inbound:web:verifyDependencyLocks --console=plain +git diff --stat adapter/inbound/web/gradle.lockfile +``` + +Expected: `verifyDependencyLocks` 통과, lockfile 변경 있음. + +- [ ] **Step 7: 아키텍처 검증이 여전히 통과하는지 본다** + +```bash +cd /home/donghyeon/workspace/desktop-server-git/tech-log-backend/src +./gradlew verifyCleanArchitectureDependencies --console=plain +./gradlew :app-bootstrap:test --tests '*CleanArchitectureTest' --console=plain +``` + +Expected: 둘 다 PASS. + +- [ ] **Step 8: 변경 파일을 보고한다 (커밋하지 않는다)** + +```bash +cd /home/donghyeon/workspace/desktop-server-git/tech-log-backend +git status --short | grep -v '^ D ' +``` + +`AGENTS.md:64`에 따라 stage/commit/push하지 않는다. 목록만 사용자에게 보고한다. + +--- + +## Task 5: Studio 오류 코드와 예외 매핑 (BE) + +**Files:** +- Create: `BE/src/application-core/src/main/java/dev/caskeleton/application/techlog/error/StudioError.java` +- Create: `BE/src/application-core/src/main/java/dev/caskeleton/application/techlog/error/StudioException.java` +- Create: `BE/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/StudioExceptionHandler.java` +- Test: `BE/src/application-core/src/test/java/dev/caskeleton/application/techlog/error/StudioErrorTest.java` +- Test: `BE/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/techlog/StudioExceptionHandlerTest.java` +- Modify: `BE/docs/registries/error-codes.yaml` + +**Interfaces:** +- Produces: `StudioError`(enum, `ApiErrorCode` 구현) — Task 8·9와 이후 모든 슬라이스가 이 코드로 실패를 표현한다. `StudioException.of(StudioError, String)` / `withDetails(StudioError, String, Object)`. + +- [ ] **Step 1: `StudioError` 실패 테스트를 쓴다** + +`StudioErrorTest.java`: + +```java +package dev.caskeleton.application.techlog.error; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.shared.error.Category; +import java.util.Arrays; +import org.junit.jupiter.api.Test; + +class StudioErrorTest { + + @Test + void declaresExactlyTheTwentyThreeContractCodes() { + assertThat(StudioError.values()).hasSize(23); + } + + @Test + void everyCodeCarriesACategoryAndAClientFacingStatus() { + Arrays.stream(StudioError.values()) + .forEach( + error -> { + assertThat(error.code()).matches("[A-Z][A-Z0-9_]*"); + assertThat(error.category()).isNotNull(); + assertThat(error.httpStatus()).isBetween(400, 599); + }); + } + + @Test + void versionConflictIsAFourZeroNineConflict() { + assertThat(StudioError.VERSION_CONFLICT.httpStatus()).isEqualTo(409); + assertThat(StudioError.VERSION_CONFLICT.category()).isEqualTo(Category.CONFLICT); + assertThat(StudioError.VERSION_CONFLICT.retryable()).isFalse(); + } + + @Test + void studioUnavailableIsRetryable() { + assertThat(StudioError.STUDIO_UNAVAILABLE.httpStatus()).isEqualTo(503); + assertThat(StudioError.STUDIO_UNAVAILABLE.category()).isEqualTo(Category.TRANSIENT_DEPENDENCY); + assertThat(StudioError.STUDIO_UNAVAILABLE.retryable()).isTrue(); + } +} +``` + +- [ ] **Step 2: 테스트를 돌려 실패를 확인한다** + +```bash +cd /home/donghyeon/workspace/desktop-server-git/tech-log-backend/src +./gradlew :application-core:test --tests '*StudioErrorTest' --console=plain +``` + +Expected: 컴파일 실패 — `StudioError` 없음. + +- [ ] **Step 3: `StudioError`를 구현한다** + +```java +package dev.caskeleton.application.techlog.error; + +import dev.caskeleton.shared.error.ApiErrorCode; +import dev.caskeleton.shared.error.Category; + +/** + * Studio 계약(`studio-v1.yaml`)의 `ApiError.code` enum 23종. 계약과 1:1이며 여기서 + * 코드를 늘리거나 줄이면 계약과 `docs/registries/error-codes.yaml`을 함께 고쳐야 한다. + */ +public enum StudioError implements ApiErrorCode { + AUTHENTICATION_REQUIRED(Category.AUTH, 401, false), + STUDIO_ACCESS_DENIED(Category.AUTHZ, 403, false), + DOCUMENT_NOT_FOUND(Category.NOT_FOUND, 404, false), + VERSION_CONFLICT(Category.CONFLICT, 409, false), + REQUEST_VALIDATION_FAILED(Category.VALIDATION, 422, false), + VALIDATION_FAILED(Category.VALIDATION, 422, false), + VALIDATION_STALE(Category.CONFLICT, 409, false), + PREVIEW_NOT_FOUND(Category.NOT_FOUND, 404, false), + PREVIEW_STALE(Category.CONFLICT, 409, false), + PREVIEW_EXPIRED(Category.CONFLICT, 409, false), + PUBLICATION_NOT_FOUND(Category.NOT_FOUND, 404, false), + PUBLICATION_CONFLICT(Category.CONFLICT, 409, false), + PUBLICATION_EVENT_NOT_FOUND(Category.NOT_FOUND, 404, false), + PUBLICATION_SNAPSHOT_NOT_FOUND(Category.NOT_FOUND, 404, false), + WARNING_ACKNOWLEDGEMENT_REQUIRED(Category.VALIDATION, 422, false), + IDEMPOTENCY_KEY_REUSED(Category.CONFLICT, 409, false), + ASSET_NOT_FOUND(Category.NOT_FOUND, 404, false), + ASSET_NOT_READY(Category.CONFLICT, 409, false), + ASSET_IN_USE(Category.CONFLICT, 409, false), + ASSET_QUARANTINED(Category.DATA_INTEGRITY, 409, false), + PAYLOAD_TOO_LARGE(Category.VALIDATION, 413, false), + UNSUPPORTED_MEDIA_TYPE(Category.VALIDATION, 415, false), + STUDIO_UNAVAILABLE(Category.TRANSIENT_DEPENDENCY, 503, true); + + private final Category category; + private final int httpStatus; + private final boolean retryable; + + StudioError(Category category, int httpStatus, boolean retryable) { + this.category = category; + this.httpStatus = httpStatus; + this.retryable = retryable; + } + + @Override + public String code() { + return name(); + } + + @Override + public Category category() { + return category; + } + + @Override + public int httpStatus() { + return httpStatus; + } + + @Override + public boolean retryable() { + return retryable; + } +} +``` + +- [ ] **Step 4: 테스트를 돌려 통과를 확인한다** + +```bash +cd /home/donghyeon/workspace/desktop-server-git/tech-log-backend/src +./gradlew :application-core:test --tests '*StudioErrorTest' --console=plain +``` + +Expected: 4개 PASS. + +- [ ] **Step 5: `StudioException`을 만든다** + +```java +package dev.caskeleton.application.techlog.error; + +import dev.caskeleton.shared.error.ApiErrorCarrier; +import dev.caskeleton.shared.error.ApiErrorCode; + +/** + * Studio use case와 facade가 던지는 유일한 실패 표현. 전송 계층은 + * {@link ApiErrorCarrier}만 보고 봉투로 옮기므로 application이 HTTP를 알 필요가 없다. + * + *

{@code details}는 계약의 {@code ApiError.details}에 그대로 실린다 — + * {@code VERSION_CONFLICT}면 최신 문서, {@code PUBLICATION_CONFLICT}면 최신 Publication. + */ +public final class StudioException extends RuntimeException implements ApiErrorCarrier { + + private final transient StudioError error; + private final transient Object details; + + private StudioException(StudioError error, String message, Object details) { + super(message); + this.error = error; + this.details = details; + } + + public static StudioException of(StudioError error, String message) { + return new StudioException(error, message, null); + } + + public static StudioException withDetails(StudioError error, String message, Object details) { + return new StudioException(error, message, details); + } + + @Override + public ApiErrorCode errorCode() { + return error; + } + + public StudioError studioError() { + return error; + } + + public Object details() { + return details; + } +} +``` + +- [ ] **Step 6: 전송 매핑 실패 테스트를 쓴다** + +`StudioExceptionHandlerTest.java`: + +```java +package dev.caskeleton.adapter.inbound.web.techlog; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.application.techlog.error.StudioError; +import dev.caskeleton.application.techlog.error.StudioException; +import dev.caskeleton.shared.response.Envelope; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.springframework.http.ResponseEntity; + +class StudioExceptionHandlerTest { + + private final StudioExceptionHandler handler = new StudioExceptionHandler(); + + @Test + void mapsStudioExceptionToFailureEnvelopeWithContractCode() { + ResponseEntity> response = + handler.handleStudio(StudioException.of(StudioError.DOCUMENT_NOT_FOUND, "없음")); + + assertThat(response.getStatusCode().value()).isEqualTo(404); + Envelope body = response.getBody(); + assertThat(body).isNotNull(); + assertThat(body.success()).isFalse(); + assertThat(body.error().code()).isEqualTo("DOCUMENT_NOT_FOUND"); + assertThat(body.error().category()).isEqualTo("NOT_FOUND"); + assertThat(body.error().retryable()).isFalse(); + } + + @Test + void carriesDetailsForConflicts() { + ResponseEntity> response = + handler.handleStudio( + StudioException.withDetails( + StudioError.VERSION_CONFLICT, "충돌", Map.of("latestDocument", Map.of("version", 8)))); + + assertThat(response.getStatusCode().value()).isEqualTo(409); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().error().details()).isNotNull(); + } +} +``` + +- [ ] **Step 7: 테스트를 돌려 실패를 확인한다** + +```bash +cd /home/donghyeon/workspace/desktop-server-git/tech-log-backend/src +./gradlew :adapter:inbound:web:test --tests '*StudioExceptionHandlerTest' --console=plain +``` + +Expected: 컴파일 실패 — `StudioExceptionHandler` 없음. + +- [ ] **Step 8: 핸들러를 구현한다** + +`GlobalExceptionHandler`를 고치지 않고 별도 advice로 붙인다. Spring은 예외 타입이 +더 구체적인 핸들러를 고르므로 `StudioException`은 이쪽으로 온다. + +```java +package dev.caskeleton.adapter.inbound.web.techlog; + +import dev.caskeleton.adapter.inbound.web.error.ErrorResponseFactory; +import dev.caskeleton.application.techlog.error.StudioException; +import dev.caskeleton.shared.response.Envelope; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +/** + * Studio 실패를 스켈레톤 봉투로 옮긴다. 템플릿의 {@code GlobalExceptionHandler}를 + * 수정하지 않기 위해 별도 advice로 둔다 — 그 파일은 template sync 대상이다. + */ +@Order(Ordered.HIGHEST_PRECEDENCE) +@RestControllerAdvice +public class StudioExceptionHandler { + + @ExceptionHandler(StudioException.class) + public ResponseEntity> handleStudio(StudioException ex) { + return ErrorResponseFactory.envelope(ex.studioError(), ex.getMessage(), ex.details()); + } +} +``` + +- [ ] **Step 9: 테스트를 돌려 통과를 확인한다** + +```bash +cd /home/donghyeon/workspace/desktop-server-git/tech-log-backend/src +./gradlew :adapter:inbound:web:test --tests '*StudioExceptionHandlerTest' --console=plain +``` + +Expected: 2개 PASS. + +- [ ] **Step 10: 오류 코드 레지스트리에 23개를 추가한다** + +`BE/docs/registries/error-codes.yaml`의 `errors:` 목록 끝에 아래 형식으로 23개를 넣는다. +`StudioError`의 category/status/retryable과 **정확히 같아야 한다**. + +```yaml + # ============================================================ + # TECH LOG STUDIO (studio-v1.yaml ApiError.code — 23종) + # ============================================================ + + - code: AUTHENTICATION_REQUIRED + category: AUTH + http_status: 401 + retryable: false + retry_after_seconds: null + owner_branch: feature-techlog-studio-backend + owner_layer: presentation + client_safe_message: "Studio 인증이 필요합니다" + log_level: INFO + runbook_link: runbook://auth/auth-token-missing + compatibility_impact: additive + required_test: StudioErrorTest + + - code: VERSION_CONFLICT + category: CONFLICT + http_status: 409 + retryable: false + retry_after_seconds: null + owner_branch: feature-techlog-studio-backend + owner_layer: application + client_safe_message: "저장된 version이 더 최신입니다" + log_level: INFO + runbook_link: null + compatibility_impact: additive + required_test: StudioErrorTest +``` + +runbook 정책상 `runbook_link`가 필수인 것은 세 개다 — `AUTHENTICATION_REQUIRED`(AUTH), +`STUDIO_ACCESS_DENIED`(AUTHZ), `STUDIO_UNAVAILABLE`(TRANSIENT_DEPENDENCY, retryable). +앞의 둘은 기존 `runbook://auth/*`, `runbook://authz/*` 문서를 재사용하고, +`STUDIO_UNAVAILABLE`은 `BE/docs/runbooks/studio-unavailable.md`를 새로 쓴다. +나머지 20개는 client-error라 `runbook_link: null`이다. + +- [ ] **Step 11: 레지스트리와 enum이 일치하는지 테스트로 고정한다** + +`BE/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/StudioErrorRegistryTest.java`: + +```java +package dev.caskeleton.bootstrap.architecture; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.application.techlog.error.StudioError; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; +import org.junit.jupiter.api.Test; + +class StudioErrorRegistryTest { + + @Test + void everyStudioErrorHasARegistryRow() throws Exception { + Path registry = Path.of("..", "..", "docs", "registries", "error-codes.yaml").normalize(); + List lines = Files.readAllLines(registry); + Set registered = + lines.stream() + .map(String::strip) + .filter(line -> line.startsWith("- code:")) + .map(line -> line.substring("- code:".length()).strip()) + .collect(Collectors.toSet()); + + Set declared = + Arrays.stream(StudioError.values()).map(StudioError::code).collect(Collectors.toSet()); + + assertThat(registered).containsAll(declared); + } +} +``` + +경로가 맞지 않으면 `./gradlew :app-bootstrap:test`를 돌려 나오는 실제 작업 디렉터리로 +`Path.of(...)`를 조정한다. + +- [ ] **Step 12: 테스트를 돌린다** + +```bash +cd /home/donghyeon/workspace/desktop-server-git/tech-log-backend/src +./gradlew :app-bootstrap:test --tests '*StudioErrorRegistryTest' --console=plain +``` + +Expected: PASS. + +- [ ] **Step 13: 변경 파일을 보고한다 (커밋하지 않는다)** + +--- + +## Task 6: bounded context 경계 규칙 (BE) + +**Files:** +- Create: `BE/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/TechLogBoundaryArchTest.java` + +**Interfaces:** +- Produces: 이후 모든 슬라이스가 이 규칙 아래에서 코드를 놓는다. 규칙을 어기면 빌드가 깨진다. + +> **정정 (2026-08-19):** 아래 Step 1의 코드는 규칙 **5개**만 담고 있는데, spec §4.3은 **7개**를 +> 요구한다. 누락 2건은 계획 결함이며 fix round에서 보완했다. +> +> - spec §4.3 규칙 1은 `content`/`inquiry`/`project`/`asset` **네 방향 모두**를 요구한다. +> Step 1에는 앞의 셋만 있어 `asset`이 형제를 자유롭게 참조할 수 있었다. +> - spec §4.3 규칙 4(`domain.techlog.publication` 외의 domain 패키지가 `Publication`을 직접 +> 변경하지 않는다)가 Step 1에 아예 없다. ArchUnit으로 "변경"을 정적 표현할 수 없으므로 +> **타 domain context가 `..domain.techlog.publication..`에 의존하는 것 자체를 금지**하는 더 +> 엄격한 근사로 구현한다(형제 규칙들도 전면 금지이므로 일관된다). +> +> 교훈: ArchUnit 규칙은 `allowEmptyShould(true)` 때문에 **없는 규칙과 통과하는 규칙이 구분되지 +> 않는다.** 규칙 목록을 쓸 때는 spec의 항목 수와 대조하고, 각 규칙을 RED로 검증해야 한다. + +- [ ] **Step 1: 규칙 테스트를 쓴다** + +지금은 `techlog` 패키지에 클래스가 거의 없으므로 `allowEmptyShould(true)`로 두어 +빈 상태에서도 통과하게 한다. 코드가 늘면 자동으로 효력이 생긴다. + +```java +package dev.caskeleton.bootstrap.architecture; + +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses; + +import com.tngtech.archunit.junit.AnalyzeClasses; +import com.tngtech.archunit.junit.ArchTest; +import com.tngtech.archunit.lang.ArchRule; + +/** + * 설계 08장의 bounded context 경계를 빌드로 강제한다. Gradle leaf를 늘리지 않고 + * 패키지로 나눴으므로(spec D1/D2) 경계는 이 규칙이 유일한 방어선이다. + */ +@AnalyzeClasses(packages = "dev.caskeleton", importOptions = ProductionClassImportOption.class) +class TechLogBoundaryArchTest { + + @ArchTest + static final ArchRule CONTENT_DOES_NOT_DEPEND_ON_SIBLING_CONTEXTS = + noClasses() + .that() + .resideInAPackage("..techlog.content..") + .should() + .dependOnClassesThat() + .resideInAnyPackage("..techlog.inquiry..", "..techlog.project..", "..techlog.asset..") + .as("techlog.content는 형제 context에 의존하지 않는다") + .allowEmptyShould(true); + + @ArchTest + static final ArchRule INQUIRY_DOES_NOT_DEPEND_ON_SIBLING_CONTEXTS = + noClasses() + .that() + .resideInAPackage("..techlog.inquiry..") + .should() + .dependOnClassesThat() + .resideInAnyPackage("..techlog.content..", "..techlog.project..", "..techlog.asset..") + .as("techlog.inquiry는 형제 context에 의존하지 않는다") + .allowEmptyShould(true); + + @ArchTest + static final ArchRule PROJECT_DOES_NOT_DEPEND_ON_SIBLING_CONTEXTS = + noClasses() + .that() + .resideInAPackage("..techlog.project..") + .should() + .dependOnClassesThat() + .resideInAnyPackage("..techlog.content..", "..techlog.inquiry..", "..techlog.asset..") + .as("techlog.project는 형제 context에 의존하지 않는다") + .allowEmptyShould(true); + + @ArchTest + static final ArchRule STUDIO_FACADE_ONLY_TOUCHES_INBOUND_PORTS = + noClasses() + .that() + .resideInAPackage("..application.techlog.studio..") + .should() + .dependOnClassesThat() + .resideInAnyPackage( + "..application.techlog.content.service..", + "..application.techlog.content.port.out..", + "..application.techlog.inquiry.service..", + "..application.techlog.inquiry.port.out..", + "..application.techlog.project.service..", + "..application.techlog.project.port.out..", + "..application.techlog.asset.service..", + "..application.techlog.asset.port.out..", + "..application.techlog.publication.service..", + "..application.techlog.publication.port.out..", + "..domain.techlog..") + .as("studio facade는 타 context의 port.in만 호출한다 (domain·service·port.out 직접 접근 금지)") + .allowEmptyShould(true); + + @ArchTest + static final ArchRule NO_CONTEXT_DEPENDS_ON_STUDIO_FACADE = + noClasses() + .that() + .resideInAnyPackage( + "..techlog.content..", + "..techlog.inquiry..", + "..techlog.project..", + "..techlog.asset..", + "..techlog.publication..") + .should() + .dependOnClassesThat() + .resideInAPackage("..application.techlog.studio..") + .as("도메인 context는 studio facade에 역방향 의존하지 않는다") + .allowEmptyShould(true); +} +``` + +- [ ] **Step 2: 테스트를 돌린다** + +```bash +cd /home/donghyeon/workspace/desktop-server-git/tech-log-backend/src +./gradlew :app-bootstrap:test --tests '*TechLogBoundaryArchTest' --console=plain +``` + +Expected: 5개 규칙 PASS (아직 대상 클래스가 없어 vacuously true). + +`ProductionClassImportOption`을 import할 수 없으면 `CleanArchitectureTest.java`가 +쓰는 정확한 패키지 경로를 확인해 맞춘다. + +- [ ] **Step 3: 규칙이 실제로 잡는지 확인한다 (일회성 검증)** + +`application-core`에 위반 클래스를 임시로 만들어 테스트가 실패하는지 본다. + +```bash +cd /home/donghyeon/workspace/desktop-server-git/tech-log-backend/src +mkdir -p application-core/src/main/java/dev/caskeleton/application/techlog/studio +mkdir -p application-core/src/main/java/dev/caskeleton/application/techlog/content/port/out +cat > application-core/src/main/java/dev/caskeleton/application/techlog/content/port/out/TempPort.java <<'EOF' +package dev.caskeleton.application.techlog.content.port.out; +public interface TempPort {} +EOF +cat > application-core/src/main/java/dev/caskeleton/application/techlog/studio/TempViolation.java <<'EOF' +package dev.caskeleton.application.techlog.studio; +import dev.caskeleton.application.techlog.content.port.out.TempPort; +public final class TempViolation { TempPort port; } +EOF +./gradlew :app-bootstrap:test --tests '*TechLogBoundaryArchTest' --console=plain 2>&1 | tail -15 +``` + +Expected: `STUDIO_FACADE_ONLY_TOUCHES_INBOUND_PORTS` 실패. + +- [ ] **Step 4: 임시 파일을 지우고 다시 통과시킨다** + +```bash +cd /home/donghyeon/workspace/desktop-server-git/tech-log-backend/src +rm application-core/src/main/java/dev/caskeleton/application/techlog/studio/TempViolation.java +rm application-core/src/main/java/dev/caskeleton/application/techlog/content/port/out/TempPort.java +./gradlew :app-bootstrap:test --tests '*TechLogBoundaryArchTest' --console=plain +``` + +Expected: PASS. + +- [ ] **Step 5: 변경 파일을 보고한다 (커밋하지 않는다)** + +--- + +## Task 7: Tech Log 코어 스키마 마이그레이션 (BE) + +**Files:** +- Create: `BE/src/adapter/outbound/persistence-jpa/src/main/resources/db/migration/postgresql/V7__techlog_core.sql` +- Test: `BE/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/techlog/TechLogSchemaMigrationTest.java` + +**Interfaces:** +- Produces: `topic`, `tag`, `document`, `case_detail`, `reference_detail`, `document_tag`, `document_relation`, `open_question`, `question_point`, `question_update`, `question_tag`, `question_document_link`, `project`, `project_decision`, `project_document_link`, `project_question_link`, `project_activity`, `asset`, `asset_reference`, `studio_validation`, `studio_preview`, `publication`, `publication_event`, `publication_snapshot`, `public_resource_projection`, `public_route`, `public_resource_tag`, `public_resource_project_link`. Task 9(catalog)와 이후 슬라이스가 읽고 쓴다. + +- [ ] **Step 1: 원본 DDL을 가져와 차이를 확인한다** + +```bash +cd /home/donghyeon/workspace/desktop-server-git/tech-log-backend +grep -nE "^CREATE TABLE" /home/donghyeon/workspace/tech-log-design-package/database/V1__init.sql | sed 's/ (.*//' +grep -nE "^CREATE TABLE" src/adapter/outbound/persistence-jpa/src/main/resources/db/migration/postgresql/*.sql | sed 's/ (.*//' +``` + +Expected: 설계 DDL 테이블 목록과 기존 테이블 목록. **이름이 겹치는 것이 없어야 한다.** +겹치면 여기서 멈추고 보고한다. + +- [ ] **Step 2: V7을 만든다** + +설계의 `database/V1__init.sql`을 기반으로 하되 세 가지를 바꾼다. + +1. `studio_idempotency` 테이블과 그 인덱스(`idx_studio_idempotency_expiry`)를 **제외한다.** + 기존 `idempotency_record`를 쓴다 (spec D5). +2. `release`, `site_config`, `profile_page`, `home_focus_config`, `topic_featured_document`, + `project_topic` 테이블을 **제외한다.** 이번 범위 밖이다 (spec §2.2). +3. 나머지는 그대로 옮긴다. `publication.latest_event_id`의 순환 FK는 + `DEFERRABLE INITIALLY DEFERRED`를 반드시 유지한다 — 즉시 검사로 바꾸면 첫 게시가 불가능하다. + +```bash +cd /home/donghyeon/workspace/desktop-server-git/tech-log-backend +DEST=src/adapter/outbound/persistence-jpa/src/main/resources/db/migration/postgresql/V7__techlog_core.sql +{ + echo "-- Tech Log 코어 스키마." + echo "-- 원본: tech-log-design-package/database/V1__init.sql" + echo "-- 제외: studio_idempotency (기존 idempotency_record 재사용, spec D5)," + echo "-- release / site_config / profile_page / home_focus_config /" + echo "-- topic_featured_document / project_topic (spec §2.2 범위 밖)." + echo + cat /home/donghyeon/workspace/tech-log-design-package/database/V1__init.sql +} > "$DEST" +wc -l "$DEST" +``` + +그다음 편집기로 위 1·2에 해당하는 블록을 지운다. 지운 뒤 남은 참조가 없는지 확인한다. + +```bash +cd /home/donghyeon/workspace/desktop-server-git/tech-log-backend +DEST=src/adapter/outbound/persistence-jpa/src/main/resources/db/migration/postgresql/V7__techlog_core.sql +for t in studio_idempotency release site_config profile_page home_focus_config topic_featured_document project_topic; do + echo "$t: $(grep -c "$t" "$DEST")" +done +grep -n "DEFERRABLE INITIALLY DEFERRED" "$DEST" +``` + +Expected: 7개 이름 모두 `0`, `DEFERRABLE INITIALLY DEFERRED` 1줄 이상. + +- [ ] **Step 3: 마이그레이션 적용 테스트를 쓴다** + +`TechLogSchemaMigrationTest.java`: + +```java +package dev.caskeleton.adapter.outbound.persistence.techlog; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.sql.Connection; +import java.sql.ResultSet; +import java.util.ArrayList; +import java.util.List; +import javax.sql.DataSource; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +/** + * V7이 실제 PostgreSQL에 적용되는지, 그리고 순환 FK가 deferrable로 선언됐는지 본다. + * H2로는 검증할 수 없다 — deferrable 제약이 벤더 의미이기 때문이다. + */ +@SpringBootTest +class TechLogSchemaMigrationTest { + + @Autowired private DataSource dataSource; + + @Test + void createsEveryTechLogTable() throws Exception { + List expected = + List.of( + "topic", "tag", "document", "case_detail", "reference_detail", "document_tag", + "document_relation", "open_question", "question_point", "question_update", + "question_tag", "question_document_link", "project", "project_decision", + "project_document_link", "project_question_link", "project_activity", "asset", + "asset_reference", "studio_validation", "studio_preview", "publication", + "publication_event", "publication_snapshot", "public_resource_projection", + "public_route", "public_resource_tag", "public_resource_project_link"); + + List actual = new ArrayList<>(); + try (Connection connection = dataSource.getConnection(); + ResultSet rs = + connection + .createStatement() + .executeQuery( + "SELECT table_name FROM information_schema.tables WHERE table_schema = 'public'")) { + while (rs.next()) { + actual.add(rs.getString(1)); + } + } + assertThat(actual).containsAll(expected); + } + + @Test + void doesNotCreateAStudioIdempotencyTable() throws Exception { + try (Connection connection = dataSource.getConnection(); + ResultSet rs = + connection + .createStatement() + .executeQuery( + "SELECT count(*) FROM information_schema.tables " + + "WHERE table_schema = 'public' AND table_name = 'studio_idempotency'")) { + rs.next(); + assertThat(rs.getInt(1)).isZero(); + } + } + + @Test + void publicationLatestEventForeignKeyIsDeferrable() throws Exception { + try (Connection connection = dataSource.getConnection(); + ResultSet rs = + connection + .createStatement() + .executeQuery( + "SELECT condeferrable, condeferred FROM pg_constraint " + + "WHERE conname = 'fk_publication_latest_event'")) { + assertThat(rs.next()).as("fk_publication_latest_event 제약이 있어야 한다").isTrue(); + assertThat(rs.getBoolean(1)).as("deferrable").isTrue(); + assertThat(rs.getBoolean(2)).as("initially deferred").isTrue(); + } + } +} +``` + +제약 이름이 설계 DDL과 다르면 `grep -n "fk_publication_latest_event" $DEST`로 확인해 맞춘다. + +- [ ] **Step 4: 테스트를 돌린다** + +```bash +cd /home/donghyeon/workspace/desktop-server-git/tech-log-backend/src +./gradlew :adapter:outbound:persistence-jpa:postgresqlIntegrationTest --tests '*TechLogSchemaMigrationTest' --console=plain +``` + +Expected: 3개 PASS. Testcontainers가 Docker를 요구하므로 실패하면 Docker 데몬을 먼저 확인한다. +소스셋 이름이 다르면 `./gradlew :adapter:outbound:persistence-jpa:tasks --all | grep -i test`로 확인한다. + +- [ ] **Step 5: 변경 파일을 보고한다 (커밋하지 않는다)** + +--- + +## Task 8: `getStudioSession` (BE) + +**Files:** +- Create: `BE/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/controller/StudioSessionController.java` +- Test: `BE/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/techlog/studio/controller/StudioSessionControllerTest.java` +- Modify: `BE/src/app-bootstrap/src/main/resources/application-dev.yml` + +**Interfaces:** +- Consumes: Task 4의 생성 DTO `dev.caskeleton...techlog.studio.api.model.StudioSession` +- Produces: `GET /api/v1/studio/session` → 봉투에 담긴 `StudioSession` + +세션은 순수 전송 상태(principal + CSRF 토큰)이므로 application use case를 만들지 않는다. +`application-core`는 Spring을 볼 수 없어 `SecurityContext`에 접근할 수 없고, 여기에 +use case를 끼우면 아무 도메인 규칙도 없는 통과 계층이 하나 늘 뿐이다. + +- [ ] **Step 1: 실패 테스트를 쓴다** + +```java +package dev.caskeleton.adapter.inbound.web.techlog.studio.controller; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.inbound.web.auth.AuthenticatedPrincipal; +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.StudioSession; +import java.util.Set; +import org.junit.jupiter.api.Test; +import org.springframework.security.web.csrf.DefaultCsrfToken; + +class StudioSessionControllerTest { + + private final StudioSessionController controller = new StudioSessionController(); + + @Test + void reportsAuthenticatedPrincipalAndCsrfToken() { + StudioSession session = + controller.getStudioSession( + new AuthenticatedPrincipal("sub-1", "donghyeon@example.com", Set.of("STUDIO_EDITOR")), + new DefaultCsrfToken("X-CSRF-TOKEN", "_csrf", "token-value")); + + assertThat(session.getAuthenticated()).isTrue(); + assertThat(session.getDisplayName()).isEqualTo("donghyeon@example.com"); + assertThat(session.getRoles()).containsExactly("STUDIO_EDITOR"); + assertThat(session.getCsrfToken()).isEqualTo("token-value"); + assertThat(session.getCsrfHeaderName()).isEqualTo("X-CSRF-TOKEN"); + } + + @Test + void fallsBackToIdpUserIdWhenEmailIsAbsent() { + StudioSession session = + controller.getStudioSession( + new AuthenticatedPrincipal("sub-1", null, Set.of()), + new DefaultCsrfToken("X-CSRF-TOKEN", "_csrf", "t")); + + assertThat(session.getDisplayName()).isEqualTo("sub-1"); + } +} +``` + +접근자는 JavaBean 스타일(`getAuthenticated()`)이며 `roles`는 `Set`이다 — 위 실측 블록 참조. + +- [ ] **Step 2: 테스트를 돌려 실패를 확인한다** + +```bash +cd /home/donghyeon/workspace/desktop-server-git/tech-log-backend/src +./gradlew :adapter:inbound:web:test --tests '*StudioSessionControllerTest' --console=plain +``` + +Expected: 컴파일 실패 — `StudioSessionController` 없음. + +- [ ] **Step 3: controller를 구현한다** + +```java +package dev.caskeleton.adapter.inbound.web.techlog.studio.controller; + +import dev.caskeleton.adapter.inbound.web.auth.AuthenticatedPrincipal; +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.StudioSession; +import java.util.Set; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.web.csrf.CsrfToken; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * 세션은 순수 전송 상태다 — principal과 CSRF 토큰뿐이라 도메인 규칙이 없다. + * application use case를 끼우지 않는 이유이고, application-core는 Spring을 볼 수 없어 + * SecurityContext에 접근할 수도 없다. + * + *

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

RELATION / EVIDENCE는 슬라이스 2·5에서 채운다. 그때까지 빈 페이지를 반환하며 + * 이는 계약상 유효한 응답이다. + */ +@Repository +public class JdbcCatalogQueryAdapter implements CatalogQueryPort { + + private final JdbcClient jdbcClient; + + public JdbcCatalogQueryAdapter(JdbcClient jdbcClient) { + this.jdbcClient = jdbcClient; + } + + @Override + public CatalogPageView search( + CatalogEntryType type, String query, String cursor, int limit) { + String pattern = (query == null || query.isBlank()) ? "%" : "%" + query.toLowerCase() + "%"; + List items = + switch (type) { + case TOPIC -> searchTopics(pattern, limit); + case PROJECT -> searchProjects(pattern, limit); + case RELATION, EVIDENCE -> List.of(); + }; + return new CatalogPageView(items, null); + } + + private List searchTopics(String pattern, int limit) { + return jdbcClient + .sql( + "SELECT id, name, updated_at FROM topic " + + "WHERE status = 'ACTIVE' AND lower(name) LIKE :pattern " + + "ORDER BY name LIMIT :limit") + .param("pattern", pattern) + .param("limit", limit) + .query( + (rs, rowNum) -> + new CatalogEntryView( + UUID.fromString(rs.getString("id")), + CatalogEntryType.TOPIC, + rs.getString("name"), + null, + null, + "topic:" + rs.getTimestamp("updated_at").toInstant())) + .list(); + } + + private List searchProjects(String pattern, int limit) { + return jdbcClient + .sql( + "SELECT id, name, updated_at FROM project " + + "WHERE lower(name) LIKE :pattern ORDER BY name LIMIT :limit") + .param("pattern", pattern) + .param("limit", limit) + .query( + (rs, rowNum) -> + new CatalogEntryView( + UUID.fromString(rs.getString("id")), + CatalogEntryType.PROJECT, + rs.getString("name"), + "PROJECT", + null, + "project:" + rs.getTimestamp("updated_at").toInstant())) + .list(); + } +} +``` + +`dependencyRevision`은 이번 슬라이스에서 "해당 행의 updated_at"으로 둔다. 정식 +계산(설계 09장 §18A의 dependency set 해시)은 슬라이스 3에서 도입하고, 그때 이 +어댑터도 같이 고친다. 계약상 `dependencyRevision`은 1..200자 문자열이면 되므로 +지금 값도 유효하다. + +- [ ] **Step 9: 테스트를 돌려 통과를 확인한다** + +```bash +cd /home/donghyeon/workspace/desktop-server-git/tech-log-backend/src +./gradlew :adapter:outbound:persistence-jpa:postgresqlIntegrationTest --tests '*JdbcCatalogQueryAdapterTest' --console=plain +``` + +Expected: 2개 PASS. + +- [ ] **Step 10: controller를 만든다** + +```java +package dev.caskeleton.adapter.inbound.web.techlog.studio.controller; + +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.CatalogEntry; +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.CatalogEntryType; +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.CatalogPage; +import dev.caskeleton.application.techlog.studio.query.CatalogEntryType; +import dev.caskeleton.application.techlog.studio.query.CatalogEntryView; +import dev.caskeleton.application.techlog.studio.query.CatalogPageView; +import dev.caskeleton.application.techlog.studio.query.ListCatalogQuery; +import dev.caskeleton.application.techlog.studio.service.ListCatalogUseCase; +import java.util.List; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** 반환값을 Envelope로 감싸지 않는다 — EnvelopeBodyAdvice가 감싼다. */ +@RestController +public class StudioCatalogController { + + private final ListCatalogUseCase listCatalog; + + public StudioCatalogController(ListCatalogUseCase listCatalog) { + this.listCatalog = listCatalog; + } + + @GetMapping("/api/v1/studio/catalog") + public CatalogPage listStudioCatalog( + @RequestParam("type") CatalogEntryType type, // application enum. 웹 계층 enum과 이름이 같으니 import 주의 + @RequestParam(value = "q", required = false) String q, + @RequestParam(value = "cursor", required = false) String cursor, + @RequestParam(value = "limit", defaultValue = "20") int limit) { + CatalogPageView page = listCatalog.handle(new ListCatalogQuery(type, q, cursor, limit)); + CatalogPage body = new CatalogPage(); + body.setItems(page.items().stream().map(StudioCatalogController::toApi).toList()); + body.setNextCursor(page.nextCursor()); + return body; + } + + private static CatalogEntry toApi(CatalogEntryView view) { + CatalogEntry entry = new CatalogEntry(); + entry.setId(view.id()); + entry.setType(CatalogEntryType.fromValue(view.type().name())); + entry.setLabel(view.label()); + entry.setDependencyRevision(view.dependencyRevision()); + if (view.kind() != null) { + entry.setKind(CatalogEntry.KindEnum.fromValue(view.kind())); + } + entry.setPublicPath(view.publicPath()); + return entry; + } +} +``` + +> **실측 (2026-08-19):** 생성 DTO의 실제 모양이다. 추측하지 말 것. +> +> ```java +> CatalogPage List getItems()/setItems(...) String getNextCursor()/setNextCursor(...) +> CatalogEntry UUID getId()/setId(UUID) ← String 아님 +> CatalogEntryType getType()/setType(...) ← 최상위 별도 enum 클래스 +> String getLabel()/setLabel(...) +> @Nullable KindEnum getKind()/setKind(...) ← CatalogEntry 안의 중첩 enum +> String getPublicPath(), String getDependencyRevision() +> ``` +> +> 즉 **`CatalogEntry.TypeEnum`은 존재하지 않는다** — `type`은 최상위 +> `CatalogEntryType`(값 TOPIC/PROJECT/RELATION/EVIDENCE, `fromValue(String)` 있음)이고, +> `kind`만 중첩 `CatalogEntry.KindEnum`(CASE/REFERENCE/QUESTION/PROJECT/PROJECT_DECISION, +> `fromValue(String)` 있음)이다. `id`는 `UUID`라 변환이 필요 없다. +> +> `dev.caskeleton.application.query.Query`는 빈 마커 인터페이스다 — 메서드가 없다. + +- [ ] **Step 11: use case 빈을 등록한다** + +`application-core`는 Spring을 모르므로 `ListCatalogUseCase`는 bootstrap에서 조립한다. + +`BE/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/techlog/TechLogStudioConfig.java`: + +```java +package dev.caskeleton.bootstrap.techlog; + +import dev.caskeleton.application.techlog.studio.port.out.CatalogQueryPort; +import dev.caskeleton.application.techlog.studio.service.ListCatalogUseCase; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** Tech Log Studio 조립. application-core는 Spring을 보지 않으므로 여기서 배선한다. */ +@Configuration +public class TechLogStudioConfig { + + @Bean + ListCatalogUseCase listCatalogUseCase(CatalogQueryPort catalogQueryPort) { + return new ListCatalogUseCase(catalogQueryPort); + } +} +``` + +- [ ] **Step 12: 전체 검증을 돌린다** + +```bash +cd /home/donghyeon/workspace/desktop-server-git/tech-log-backend/src +./gradlew :application-core:test :adapter:inbound:web:test --console=plain +./gradlew verifyCleanArchitectureDependencies --console=plain +./gradlew :app-bootstrap:test --tests '*CleanArchitectureTest' --tests '*TechLogBoundaryArchTest' --tests '*StudioErrorRegistryTest' --console=plain +``` + +Expected: 전부 PASS. + +- [ ] **Step 13: 변경 파일을 보고한다 (커밋하지 않는다)** + +```bash +cd /home/donghyeon/workspace/desktop-server-git/tech-log-backend +git status --short | grep -v '^ D ' +``` + +--- + +## Task 10: 계약 회귀 테스트 (BE) + +spec §5.5. springdoc이 노출하는 실제 계약을 vendor된 `studio-v1.yaml`과 대조한다. +구현된 operation만 검사하므로 슬라이스가 늘어도 그대로 쓸 수 있고, 계약에 없는 +엔드포인트가 새로 생기면 즉시 실패한다. + +**Files:** +- Create: `BE/src/app-bootstrap/src/functionalTest/java/dev/caskeleton/bootstrap/contract/StudioContractDriftTest.java` + +**Interfaces:** +- Consumes: Task 4의 `src/config/openapi/studio-v1.yaml`, Task 8·9의 controller +- Produces: 이후 모든 슬라이스가 새 controller를 추가할 때 자동으로 걸리는 드리프트 게이트 + +**규약:** controller 메서드 이름은 계약의 `operationId`와 **같게 짓는다.** springdoc이 +메서드 이름에서 operationId를 만들기 때문이며, 이 테스트가 그 규약을 강제한다. +Task 8의 `getStudioSession`, Task 9의 `listStudioCatalog`가 이미 그렇다. + +- [ ] **Step 1: 드리프트 테스트를 쓴다** + +```java +package dev.caskeleton.bootstrap.contract; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; + +/** + * 구현된 Studio operation이 계약과 어긋나면 실패한다. 계약에 없는 `/api/v1/studio/**` + * 엔드포인트가 생겨도 실패한다 — 계약 밖 표면이 조용히 늘어나는 것을 막는다. + */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class StudioContractDriftTest { + + @Autowired private TestRestTemplate restTemplate; + + @Test + void publishedStudioOperationsMatchTheContract() throws Exception { + JsonNode contract = + new ObjectMapper(new YAMLFactory()) + .readTree(Path.of("..", "config", "openapi", "studio-v1.yaml").normalize().toFile()); + JsonNode published = new ObjectMapper().readTree(restTemplate.getForObject("/v3/api-docs", String.class)); + + List problems = new ArrayList<>(); + JsonNode publishedPaths = published.path("paths"); + Iterator> paths = publishedPaths.fields(); + while (paths.hasNext()) { + Map.Entry path = paths.next(); + if (!path.getKey().startsWith("/api/v1/studio/")) { + continue; + } + JsonNode contractPath = contract.path("paths").path(path.getKey()); + if (contractPath.isMissingNode()) { + problems.add("계약에 없는 path: " + path.getKey()); + continue; + } + Iterator> methods = path.getValue().fields(); + while (methods.hasNext()) { + Map.Entry method = methods.next(); + JsonNode contractOp = contractPath.path(method.getKey()); + if (contractOp.isMissingNode()) { + problems.add("계약에 없는 method: " + method.getKey() + " " + path.getKey()); + continue; + } + String publishedId = method.getValue().path("operationId").asText(""); + String contractId = contractOp.path("operationId").asText(""); + if (!publishedId.equals(contractId)) { + problems.add( + "operationId 불일치 " + method.getKey() + " " + path.getKey() + + ": published=" + publishedId + " contract=" + contractId); + } + } + } + assertThat(problems).isEmpty(); + } + + @Test + void everyStudioResponseIsWrappedInTheEnvelope() { + String body = restTemplate.getForObject("/api/v1/studio/catalog?type=TOPIC", String.class); + + assertThat(body).contains("\"success\"").contains("\"data\"").contains("\"meta\""); + assertThat(body).doesNotContain("\"data\":{\"success\""); + } +} +``` + +`jackson-dataformat-yaml`이 `functionalTest` 클래스패스에 없으면 +`app-bootstrap/build.gradle`의 `functionalTestImplementation`에 추가하고 lockfile을 +재생성한다. `Path.of("..", "config", ...)`가 맞지 않으면 실패 메시지에 찍히는 실제 +작업 디렉터리로 조정한다. + +`/api/v1/studio/catalog`는 인증이 필요하므로 두 번째 테스트가 401을 받으면 +`SECURITY_PUBLIC_PATHS`에 넣지 말고(보안 표면을 넓히면 안 된다) 기존 functional test가 +쓰는 인증 헬퍼를 따라 인증된 요청으로 바꾼다. + +```bash +ls /home/donghyeon/workspace/desktop-server-git/tech-log-backend/src/app-bootstrap/src/functionalTest/java/dev/caskeleton/bootstrap/contract/ +``` + +- [ ] **Step 2: 테스트를 돌려 실패를 확인한다** + +```bash +cd /home/donghyeon/workspace/desktop-server-git/tech-log-backend/src +./gradlew :app-bootstrap:functionalTest --tests '*StudioContractDriftTest' --console=plain +``` + +Expected: 컴파일 실패 또는 FAIL. 태스크 이름이 다르면 +`./gradlew :app-bootstrap:tasks --all | grep -i test`로 확인한다. + +- [ ] **Step 3: 드리프트가 있으면 계약이 아니라 코드를 고친다** + +계약이 SSOT다. `operationId` 불일치가 나오면 controller 메서드 이름을 계약에 맞춘다. +계약에 없는 path가 나오면 그 엔드포인트를 지운다. + +- [ ] **Step 4: 테스트를 돌려 통과를 확인한다** + +```bash +cd /home/donghyeon/workspace/desktop-server-git/tech-log-backend/src +./gradlew :app-bootstrap:functionalTest --tests '*StudioContractDriftTest' --console=plain +``` + +Expected: 2개 PASS. + +- [ ] **Step 5: 변경 파일을 보고한다 (커밋하지 않는다)** + +--- + +## 완료 판정 + +이 계획이 끝나면 다음이 참이다. + +```text +DP studio-v1.yaml v3.0.0이 봉투를 기술하고 검증 스크립트 4종이 통과한다 +FE 계약이 재생성되고 전송 경계에서 봉투를 언랩하며 test:tech-log가 통과한다 +BE feature/techlog-studio-backend에서 + - 계약 DTO가 생성되고 컴파일된다 + - StudioError 23종이 enum·레지스트리 양쪽에 있다 + - bounded context 경계가 ArchUnit으로 강제된다 + - V7 스키마가 PostgreSQL에 적용된다 + - GET /api/v1/studio/session 이 봉투에 담긴 StudioSession을 돌려준다 + - GET /api/v1/studio/catalog 가 TOPIC/PROJECT를 돌려준다 + - 구현된 operation이 계약과 어긋나면 functionalTest가 실패한다 +``` + +## 다음 계획 + +슬라이스 2~5는 각각 별도 계획으로 쓴다. 이 계획이 끝나 계약과 기반이 확정된 뒤에 +써야 추측이 들어가지 않는다. + +```text +Plan 02 문서 CRUD 4종 + 낙관적 잠금 + 멱등 +Plan 03 validate / preview / dependencyRevision / nextAction / 렌더러 + 착수 전 프론트 렌더 모델 구현을 기준선으로 대조한다 (spec §10) +Plan 04 publish 20단계 / event / snapshot / unpublish / dashboard +Plan 05 asset 5종 (fileserver · objectstorage 위에) +``` diff --git a/docs/superpowers/specs/2026-08-18-techlog-studio-backend-design.md b/docs/superpowers/specs/2026-08-18-techlog-studio-backend-design.md new file mode 100644 index 0000000..d492493 --- /dev/null +++ b/docs/superpowers/specs/2026-08-18-techlog-studio-backend-design.md @@ -0,0 +1,870 @@ +# Tech Log Studio Backend — 설계 + +- 작성일: 2026-08-18 +- 대상 저장소: `tech-log-backend` (`clean-architecture-backend-template` 스냅샷) +- 브랜치: `feature/techlog-studio-backend` +- 설계 원본: `/home/donghyeon/workspace/tech-log-design-package` +- 소비자: `/home/donghyeon/workspace/desktop-server-git/tech-log-frontend` (Studio SPA, 구현 완료) + +--- + +## 1. 배경 + +설계 패키지가 Tech Log의 제품·정보구조·계약·DB·백엔드 모듈을 확정했다. 프론트는 +Studio SPA가 이미 구현되어 있고 백엔드만 없다. 이 문서는 **설계 패키지의 결정을 +이 저장소의 구조·게이트에 맞춰 어떻게 구현할지**를 정의한다. + +설계 패키지의 `docs/plans/02-backend-core-plan.md`는 본문에 STALE 배너가 붙어 있고 +파일맵도 `services/api/...`라 이 저장소에 적용되지 않는다. 이 문서가 그 자리를 +대신한다. + +### 1.1 계약 정합 (검증됨) + +`tech-log-design-package/scripts/check-contract-parity.py` 실행 결과: + +```text +Frontend operation 19개 / Backend primary operation 19개 +[OK] Frontend operation 13개가 모두 Backend primary 계약에 존재한다. +[OK] 공통 operation의 method/path가 모두 일치한다. +RecordKind FE=BE=['CASE','PROJECT_DECISION','QUESTION','REFERENCE'] +오류 코드 FE=23개 / BE=23개 [OK] +``` + +즉 `contracts/openapi/studio-v1.yaml`이 프론트가 실제로 호출하는 계약 그대로다. + +--- + +## 2. 범위 + +### 2.1 In scope + +`studio-v1.yaml`의 19개 operation 전부. + +| operation | method · path | +| --- | --- | +| `getStudioSession` | GET `/api/v1/studio/session` | +| `getStudioDashboard` | GET `/api/v1/studio/dashboard` | +| `listStudioDocuments` | GET `/api/v1/studio/documents` | +| `createStudioDocument` | POST `/api/v1/studio/documents` | +| `getStudioDocument` | GET `/api/v1/studio/documents/{documentId}` | +| `saveStudioDocument` | PUT `/api/v1/studio/documents/{documentId}` | +| `validateStudioDocument` | POST `/api/v1/studio/documents/{documentId}/validate` | +| `createStudioPreview` | POST `/api/v1/studio/documents/{documentId}/preview` | +| `getCurrentStudioPreview` | GET `/api/v1/studio/documents/{documentId}/preview` | +| `publishStudioDocument` | POST `/api/v1/studio/documents/{documentId}/publish` | +| `listStudioPublications` | GET `/api/v1/studio/publications` | +| `unpublishStudioPublication` | POST `/api/v1/studio/publications/{publicationId}/unpublish` | +| `getStudioPublicationSnapshot` | GET `/api/v1/studio/publications/{publicationEventId}/preview` | +| `listStudioCatalog` | GET `/api/v1/studio/catalog` | +| `listStudioAssets` | GET `/api/v1/studio/assets` | +| `uploadStudioAsset` | POST `/api/v1/studio/assets` | +| `getStudioAsset` | GET `/api/v1/studio/assets/{assetId}` | +| `updateStudioAsset` | PUT `/api/v1/studio/assets/{assetId}` | +| `deleteStudioAsset` | DELETE `/api/v1/studio/assets/{assetId}` | + +`RecordKind` 4종(`CASE`, `REFERENCE`, `QUESTION`, `PROJECT_DECISION`)을 하나의 문서 +편집 흐름으로 다룬다. + +### 2.2 Out of scope (이번 브랜치) + +- `contracts/openapi/public-v1.yaml` (Public 조회 API) — 소비자(Astro Public 사이트)가 + 아직 없다. +- `contracts/openapi/studio-management-v1.yaml` (secondary capability 보존 계약). +- `release`, `topic`/`tag` 관리 UI, `identity`(SiteConfig/Profile/HomeFocus) 편집 API. +- 이번 범위에 필요한 만큼의 `topic`/`project` **조회**는 catalog에서 다루되, 그 + 편집 API는 만들지 않는다. + +--- + +## 3. 상위 결정 + +| ID | 결정 | 근거 | +| --- | --- | --- | +| D1 | 새 Gradle leaf를 만들지 않고 기존 18 leaf **안의 하위 패키지**로 배치한다 | `src/settings.gradle`이 `expectedModuleCount = 18`로 fail-closed 검증. leaf 추가는 registry·settings·dependency gate·ArchUnit을 동시에 바꾸는 template-level 변경이고 `template.lock.json` 기반 향후 sync와 충돌한다 | +| D2 | 패키지는 `dev.caskeleton.{domain,application,adapter...}` **루트 아래**에 `techlog` 하위로 넣는다 | 일부 ArchUnit 규칙이 `dev.caskeleton.application..`처럼 루트를 고정한다(`CleanArchitectureTest.java:221`). 다른 루트에 두면 가드레일이 조용히 미적용된다 | +| D3 | 계약 우선(openapi-generator)으로 **DTO(model)만** 생성하고 controller는 얇게 손으로 쓴다 (`globalProperties.set(['models': ''])`, `useOneOfInterfaces=false`) — 실제 설정은 §5.2, 실측과의 차이는 §3.1 | ADR-004를 유지하되 D4와 충돌하지 않는 형태. 생성 API interface는 봉투 wrapper 타입을 반환하게 되고, 그 타입은 `dev.caskeleton.shared.response.Envelope`가 아니라서 `EnvelopeBodyAdvice`가 **한 번 더 감싼다**(이중 래핑). 드리프트 위험의 실체는 93개 schema·enum·required 필드이고 그건 model 생성으로 덮인다. controller 19개 signature 드리프트는 §5.5 회귀 테스트가 잡는다 | +| D4 | `/api/v1/studio/**`도 템플릿 응답 봉투를 **그대로 쓴다**. 계약을 봉투 형태로 재정의한다 | 봉투는 `shared-contract/README.md:230`의 문서화된 결정(boundary D5/D6)이고 정보 손실이 없다. 적응 코드를 백엔드 *템플릿* 파일이 아니라 프론트 *제품* 코드에 두는 편이 template sync 충돌이 없다(§5.3) | +| D5 | `studio_idempotency` 테이블을 만들지 않고 기존 `idempotency_record`를 쓴다 | 기존 스키마가 설계 요구의 상위집합이고 `IdempotencyExecutorV2`까지 있다(§8.2) | +| D6 | Studio 통합 목록은 물리 인덱스 테이블 없이 union query로 시작한다 | 설계 08장 §4. 성능이 입증되면 read model 추가 | +| D7 | 커밋은 하지 않는다 | `AGENTS.md:64` — commit 정책 `human-only` | + +### 3.1 D3 정정 — 실제 구현과의 차이 (2026-08-19, final whole-branch review B2) + +이 절 작성 시점(§12 슬라이스 0 계획 단계)에는 openapi-generator 7.x가 이 저장소의 Spring Boot +버전과 함께 검증된 바 없었고, §5.2가 예정한 `generateApis=false` / `generateModels=true`는 +실제 스파이크(슬라이스 0)에서 두 가지가 틀린 것으로 드러났다. 아래는 spec이 스스로 요구한 +"폴백을 쓰면 D3을 갱신한다"(구 §5.2)는 약속이 이행되지 않고 있던 것을 바로잡는 정정이다 — +구현이 폴백으로 넘어간 것은 아니고(생성기는 여전히 model을 생성한다), **생성기 설정 자체가 +설계 시점에 존재하지 않는 API를 가정**했던 것이 실측으로 확인됐다. + +- **`generateApis`/`generateModels`는 openapi-generator-gradle-plugin 7.18.0의 `openApiGenerate` + 확장에 존재하지 않는다** (디컴파일로 확인, task-4-report.md). 대신 CLI `--global-property`와 + 같은 뜻인 `globalProperties.set(['models': ''])`로 "models만" 생성하도록 제한한다. 실제 설정은 + §5.2를 그대로 참조. +- **생성 소스는 `sourceSets.main.java.srcDir`에 있지 않다.** 별도 `generatedOpenapi` sourceSet에 + 있고, `main`/`test`의 compile·runtime classpath와 `jar` 산출물에 각각 명시적으로 이어 붙인다 + (`src/adapter/inbound/web/build.gradle:22-38`). 이유: 생성 코드가 deprecated + `org.springframework.lang.Nullable`을 참조하는데, 저장소 루트 `build.gradle`의 + `-Werror`/`-Xlint:deprecation`이 이걸 컴파일 실패로 승격한다. `main`에 직접 넣으면 그 플래그가 + 생성 코드에도 적용돼 빌드가 깨진다 — 별도 sourceSet으로 분리하고 그 sourceSet의 + `compileGeneratedOpenapiJava` 태스크에서만 `-Werror`/`-Xlint:deprecation`을 뺀다. +- **spec에 없던 `useOneOfInterfaces=false`가 결정적 옵션으로 추가됐다.** discriminator(`oneOf`) + union을 부모 Java interface로 생성하면(`useOneOfInterfaces=true`, openapi-generator 기본값) + discriminator getter가 항상 `String`을 반환하도록 SpringCodegen이 고정하는데, 하위 타입의 실제 + getter 타입(공유 enum이든 아니든)과 충돌해 컴파일이 깨진다. 판별 필드를 하위 타입에서 + narrowing하지 않도록 계약을 고쳐도 동일하게 깨지는 것까지 스크래치에서 직접 검증했다(3개 설정 + 조합, task-4-report.md) — 계약 쪽에서 우회할 수 없는 SpringCodegen 자체의 제약이다. + +**`useOneOfInterfaces=false`의 대가 — 생성 union 5종이 파손됐다.** 이 경고는 지금 +`src/adapter/inbound/web/build.gradle:139-155`의 주석에만 있고 spec 본문에는 없었다 — Plan 02 +작성자가 읽는 문서는 이 spec이므로 여기 옮긴다. + +`useOneOfInterfaces=false`는 컴파일은 통과시키지만, discriminator union 5종 +(`WorkingCopyInput`, `WorkingCopy`, `Inline`, `CaseRenderBlock`, `PublicRenderModel`)의 하위 +타입이 Java `implements` 관계를 전혀 갖지 않는 독립 클래스로 생성된다. 실측 결과 Jackson +양방향 배선도 계약을 어긴다: + +- **역직렬화**는 `InvalidTypeIdException`으로 실패한다 (예: `Class CaseInput not subtype of + WorkingCopyInput`). +- **직렬화**는 생성된 `@JsonIgnoreProperties(value="kind", allowSetters=true)` 때문에 실제 + discriminator 값 대신 클래스 simple name이 나간다 (예: 응답에 `"kind":"WorkingCopyInput"`). + +이 union들을 요청·응답에 직접 또는 (409 `VersionConflictDetails`처럼) 간접적으로 포함하는 +operation은 최소 `createStudioDocument`/`getStudioDocument`/`saveStudioDocument` +(`WorkingCopyInput`/`WorkingCopy`)와 `createStudioPreview`/`getCurrentStudioPreview`/ +`getStudioPublicationSnapshot`(`PublicRenderModel`)이며, `validateStudioDocument`를 포함해 +409 conflict 응답 경로로 더 넓게 새어 들어갈 수 있다 — Task 8/9(`getStudioSession`, +`listStudioCatalog`)는 이 union들을 쓰지 않아 막히지 않았을 뿐, 영향받는 operation의 정확한 +목록과 대응 전략(수동 Jackson `@JsonTypeInfo`/`@JsonSubTypes` 재작성, 계약 재구조화, 또는 별도 +수기 DTO)은 **Plan 02가 착수 전에 확정해야 한다.** + +--- + +## 4. 코드 배치 + +### 4.1 패키지 + +```text +:domain-core + dev.caskeleton.domain.techlog.content Document / CaseDetail / ReferenceDetail + dev.caskeleton.domain.techlog.inquiry OpenQuestion + dev.caskeleton.domain.techlog.project ProjectDecision + dev.caskeleton.domain.techlog.asset Asset + dev.caskeleton.domain.techlog.publication Publication / PublicationEvent + dev.caskeleton.domain.techlog..vo Value Object + +:application-core + dev.caskeleton.application.techlog..port.in Command · Query · *UseCase 인터페이스 + dev.caskeleton.application.techlog..port.out Repository · Renderer · Clock 포트 + dev.caskeleton.application.techlog..service *UseCase 구현 + dev.caskeleton.application.techlog.studio.facade WorkingCopy / Validation / Preview / Publication + dev.caskeleton.application.techlog.studio.query Dashboard / Document / Publication / Catalog + dev.caskeleton.application.techlog.studio.mapper API 용어 ↔ Domain 용어 + dev.caskeleton.application.techlog.studio.nextaction NextAction 계산 + dev.caskeleton.application.techlog.studio.port.out Studio 자신의 read 포트(예: CatalogQueryPort) — + §4.3 경계 규칙 2가 금지하는 것은 studio가 + *타* context의 port.out을 참조하는 것이지, + studio 자신의 port.out이 아니다 + +:adapter:outbound:persistence-jpa + dev.caskeleton.adapter.outbound.persistence.techlog..entity + dev.caskeleton.adapter.outbound.persistence.techlog..repository + dev.caskeleton.adapter.outbound.persistence.techlog..mapper + dev.caskeleton.adapter.outbound.persistence.techlog.query JdbcClient union query + +:adapter:inbound:web + dev.caskeleton.adapter.inbound.web.techlog.studio.controller + dev.caskeleton.adapter.inbound.web.techlog.studio.mapper + dev.caskeleton.adapter.inbound.web.techlog.studio.problem + +:app-bootstrap + dev.caskeleton.bootstrap.techlog 조립·설정만 +``` + +`modules.json`, `settings.gradle`, `verifyCleanArchitectureDependencies`는 변경하지 +않는다. + +### 4.2 상속되는 기존 가드레일 + +신규 코드에 자동으로 적용된다. + +- `..domain..` 순수성 (프레임워크·전송·DB 의존 금지) +- `..domain.vo..` / `@ValueObject`: public 무인자 생성자 금지 +- `@AggregateRoot`: `set*` 메서드 public 금지 +- `@DomainEvent`: record 필수 +- `..application..`: `@Transactional` 금지 → `TransactionPort` 사용 +- `..application..`: `ApplicationContext` 의존 금지 +- `CommandUseCase`/`QueryUseCase` 구현: 이름이 `UseCase`로 끝나야 하고 + `@UseCaseCapability` 필수 +- `verifyApplicationCoreDependencyPurity`: application-core 생산 의존은 project-only, + 클래스패스에 Spring/slf4j/logback/micrometer 금지 + +### 4.3 추가할 경계 규칙 — `TechLogBoundaryArchTest` + +`:app-bootstrap` 테스트에 추가한다. + +1. `techlog.content` / `techlog.inquiry` / `techlog.project` / `techlog.asset`은 + 서로 의존하지 않는다. +2. `application.techlog.studio`는 타 context의 `port.in`만 참조한다. + 타 context의 `domain`, `port.out`, `service` 직접 참조는 위반이다. (studio 자신의 + `application.techlog.studio.port.out`은 이 규칙의 대상이 아니다 — §4.1 참조.) +3. 타 context는 `application.techlog.studio`를 참조하지 않는다 (역방향 금지). +4. `domain.techlog.publication`을 제외한 어떤 domain 패키지도 `Publication`을 + 직접 변경하지 않는다. + +설계 08장의 "`studio`는 도메인 모듈이 아니다"를 빌드로 강제하는 장치다. + +--- + +## 5. 계약 → 코드 + +### 5.1 계약 원본 + +`studio-v1.yaml`을 `src/config/openapi/studio-v1.yaml`로 복사한다 +(`src/config/architecture`, `src/config/messaging`과 같은 authority 위치). +`src/config/openapi/MANIFEST.sha256`에 해시를 기록해 설계 패키지와의 드리프트를 +가시화한다. + +### 5.2 생성 + +`:adapter:inbound:web`에 `org.openapi.generator` 플러그인을 추가한다. 아래는 실제 구현 설정 +(`src/adapter/inbound/web/build.gradle`)이다 — 이 절이 원래 예정했던 `generateApis=false`/ +`generateModels=true`는 openapi-generator-gradle-plugin 7.18.0에 존재하지 않는 프로퍼티였다. +무엇이 왜 달라졌는지는 §3.1 참조. + +```text +generatorName spring +globalProperties ['models': ''] ← "models만" 생성 (CLI --global-property와 동치) +modelPackage dev.caskeleton.adapter.inbound.web.techlog.studio.api.model +useSpringBoot3 true +useJakartaEe true +openApiNullable true (jackson-databind-nullable 이미 선언됨) +useOneOfInterfaces false ← §3.1 — discriminator union 5종의 Jackson 배선이 깨지는 대가 +output build/generated/openapi +``` + +- 생성 소스는 `sourceSets.main.java.srcDir`가 아니라 **별도 `generatedOpenapi` sourceSet**에 + 있다. 생성 코드가 deprecated `org.springframework.lang.Nullable`을 쓰는데 저장소 루트의 + `-Werror`가 이를 빌드 실패로 승격하기 때문이다 — 자세한 배선은 §3.1과 + `build.gradle:1-38`의 주석 참조. `jar`/`test` 클래스패스에는 별도로 명시적으로 얹는다. +- spotless / checkstyle / spotbugs / errorprone 대상에서 제외한다. +- `adapter/inbound/web/gradle.lockfile`을 `--write-locks`로 재생성한다. + +#### 왜 API interface를 생성하지 않는가 + +D4로 응답 봉투를 유지하면 계약의 성공 응답 스키마가 봉투 wrapper가 된다. 그러면 +생성 interface의 signature가 `ResponseEntity`가 되는데, +`StudioSessionEnvelope`는 생성된 별개 클래스라 `dev.caskeleton.shared.response.Envelope`가 +아니다. `EnvelopeBodyAdvice.beforeBodyWrite`는 `Envelope`/`BulkEnvelope`만 통과시키므로 +이 본문을 **한 번 더 감싼다**. + +```text +controller → StudioSessionEnvelope +EnvelopeBodyAdvice → Envelope +wire → {"success":true,"data":{"success":true,"data":{...},"meta":{...}},"meta":{...}} +``` + +이걸 피하려면 `EnvelopeBodyAdvice`(템플릿 파일)를 고쳐야 하는데, 그건 D4가 피하려던 +바로 그 template sync 충돌이다. + +따라서 **model만 생성하고 controller는 손으로 쓴다.** + +```java +@RestController +final class StudioSessionController { + @GetMapping("/api/v1/studio/session") + StudioSession getStudioSession() { // 생성된 payload DTO를 그대로 반환 + return mapper.toApi(facade.currentSession()); + } +} +// EnvelopeBodyAdvice가 여기서 정확히 한 번 감싼다. +``` + +controller는 facade 호출과 매핑만 하고 비즈니스 로직을 두지 않는다. + +봉투 wrapper 스키마도 함께 생성되지만 백엔드는 쓰지 않는다. 계약의 wrapper는 +소비자(프론트·외부 도구)를 위한 기술이고, 백엔드에서는 advice가 그 역할을 한다. + +**선행 검증 결과:** openapi-generator 7.x × 이 저장소의 Spring Boot 조합은 슬라이스 0의 폐기용 +스파이크로 검증했다. 폴백(생성기 1회 실행 후 수기 유지)으로 넘어가지는 않았다 — 생성기는 지금도 +빌드마다 model을 생성한다. 대신 §5.2 상단에 적은 세 가지(`globalProperties`, 별도 sourceSet, +`useOneOfInterfaces=false`)가 스파이크에서 드러난 실제 조건이었다. §3.1이 그 정정 기록이다. + +### 5.3 wire format — 템플릿 봉투를 그대로 쓴다 + +성공과 실패가 한 모양을 공유한다. + +```jsonc +// 성공 (200 / 201) +{ "success": true, "data": { /* studio-v1 payload */ }, "meta": { "requestId": "...", "traceId": "...", "correlationId": "..." } } + +// 실패 (4xx / 5xx) — HTTP status는 그대로 의미를 갖는다 +{ "success": false, "error": { "code": "VERSION_CONFLICT", "category": "CONFLICT", + "message": "...", "retryable": false, + "details": { /* code별 polymorphic */ } }, + "meta": { "requestId": "...", "traceId": "...", "correlationId": "..." } } + +// 204 (deleteStudioAsset) — 본문 없음. EnvelopeBodyAdvice는 null body를 감싸지 않는다. +``` + +미디어 타입은 성공·실패 모두 `application/json`이다. `application/problem+json`은 쓰지 +않는다. + +#### 왜 이 방향인가 + +- 봉투는 이 템플릿의 **문서화된 결정**이다. `shared-contract/README.md:230` — + "RFC 7807 ProblemDetail 을 대체한다(boundary D5/D6)", "D5 가 RFC 7807 ProblemDetail 을 + 거부하고, D10 이 `category`를 1급 필드로 추가했다". +- **정보 손실이 없다.** §5.4의 매핑표 참조. `error.category`는 봉투 쪽이 추가로 준다. +- **적응 코드의 위치가 결정적이다.** 봉투를 벗기려면 `EnvelopeBodyAdvice.supports()`를 + 고쳐야 하는데 이는 템플릿 파일이고, 이 저장소는 `template.lock.json`의 + `"materialization": "tracked-snapshot"`이라 이후 모든 template sync의 충돌 지점이 된다. + 반대로 봉투를 유지하면 적응은 프론트 **제품 코드** + (`src/features/tech-log/contracts/tech-log-studio-contract-contribution.ts`) 안에서 + 끝나고 프론트 플랫폼(`src/adapters/http/http-execution-v3.ts`)도 무변경이다. + +#### 백엔드가 해야 할 일 + +**봉투 관련 신규 작업은 없다.** `EnvelopeBodyAdvice`, `ErrorResponseFactory`, +`GlobalExceptionHandler`, `EnvelopeAuthenticationEntryPoint`, +`EnvelopeAccessDeniedHandler`, `RateLimitInterceptor`를 그대로 쓴다. 필요한 것은 +§5.4의 오류 코드 등록과 Studio 전용 예외 → `ApiError` 매핑뿐이다. + +#### 계약과 프론트가 해야 할 일 + +이 결정은 세 저장소에 걸친다. 상세는 §5.6. + +```text +tech-log-design-package studio-v1.yaml을 봉투 형태로 재정의 (+ 06장 · ADR · MASTER_SPEC · 검증 스크립트) +tech-log-frontend 계약 재생성 + validator 2개를 봉투 언랩으로 교체 +tech-log-backend 오류 코드 등록 + 예외 매핑 (봉투 자체는 무변경) +``` + +### 5.4 오류 코드 + +계약의 오류 코드 23개를 `ApiError.code`에 그대로 싣는다. + +```text +AUTHENTICATION_REQUIRED STUDIO_ACCESS_DENIED DOCUMENT_NOT_FOUND +VERSION_CONFLICT REQUEST_VALIDATION_FAILED VALIDATION_FAILED +VALIDATION_STALE PREVIEW_NOT_FOUND PREVIEW_STALE +PREVIEW_EXPIRED PUBLICATION_NOT_FOUND PUBLICATION_CONFLICT +PUBLICATION_EVENT_NOT_FOUND PUBLICATION_SNAPSHOT_NOT_FOUND +WARNING_ACKNOWLEDGEMENT_REQUIRED IDEMPOTENCY_KEY_REUSED +ASSET_NOT_FOUND ASSET_NOT_READY ASSET_IN_USE +ASSET_QUARANTINED PAYLOAD_TOO_LARGE UNSUPPORTED_MEDIA_TYPE +STUDIO_UNAVAILABLE +``` + +#### 필드 매핑 — 정보 손실 없음 + +| 기존 계약 `ProblemDetails` | 봉투 | +| --- | --- | +| `code` | `error.code` | +| `retryable` | `error.retryable` (1급) | +| `detail` | `error.message` (client-safe. stack trace·내부 ID 금지) | +| `status` | HTTP status (봉투도 실제 status를 유지한다) | +| `traceId` | `meta.traceId` (D7 — 응답에서 절대 null이 아니다) | +| `fieldErrors` | `error.details` = `ValidationErrorDetails` | +| `latestDocument` | `error.details` = `VersionConflictDetails` | +| `latestPublication` | `error.details` = `PublicationConflictDetails` | +| `conflictingFields` | 위 두 details 안 | +| `type` / `title` | 버린다. 프론트가 이미 `code`에서 합성한다(`studio-error-mapping.ts`의 `synthetic()`) | +| — | `error.category` — 봉투가 추가로 준다 | + +`details`는 code별 polymorphic이므로 계약에서 `oneOf` 타입 변형으로 선언한다. +`Object` 자유형으로 두지 않는다. + +#### 레지스트리 등록 + +`docs/registries/error-codes.yaml`에 23개 row를 additive로 추가한다. row 스키마가 +요구하는 필드를 모두 채운다 — `category`(10-value `Category` enum), `http_status`, +`retryable`, `owner_layer`, `client_safe_message`, `log_level`, `runbook_link`, +`compatibility_impact: additive`, `required_test`. + +runbook 정책: `retryable=false`이고 category가 `AUTH`/`AUTHZ`/`RATE_LIMIT`/`INTERNAL`/ +`TRANSIENT_DEPENDENCY`/`PERMANENT_DEPENDENCY`면 `runbook_link`가 필수다. +`VALIDATION`/`NOT_FOUND`/`CONFLICT`/`DATA_INTEGRITY`는 client-error로 면제 가능하다. +따라서 `AUTHENTICATION_REQUIRED`(AUTH), `STUDIO_ACCESS_DENIED`(AUTHZ), +`STUDIO_UNAVAILABLE`(TRANSIENT_DEPENDENCY)는 runbook을 함께 작성한다. + +category 배정: + +```text +AUTH AUTHENTICATION_REQUIRED +AUTHZ STUDIO_ACCESS_DENIED +NOT_FOUND DOCUMENT_NOT_FOUND PREVIEW_NOT_FOUND PUBLICATION_NOT_FOUND + PUBLICATION_EVENT_NOT_FOUND PUBLICATION_SNAPSHOT_NOT_FOUND + ASSET_NOT_FOUND +CONFLICT VERSION_CONFLICT PUBLICATION_CONFLICT IDEMPOTENCY_KEY_REUSED + VALIDATION_STALE PREVIEW_STALE PREVIEW_EXPIRED + ASSET_IN_USE ASSET_NOT_READY +VALIDATION REQUEST_VALIDATION_FAILED VALIDATION_FAILED + WARNING_ACKNOWLEDGEMENT_REQUIRED + PAYLOAD_TOO_LARGE UNSUPPORTED_MEDIA_TYPE +DATA_INTEGRITY ASSET_QUARANTINED +TRANSIENT_DEPENDENCY STUDIO_UNAVAILABLE +``` + +### 5.5 계약 회귀 테스트 + +**실제 구현 범위 (Task 10, `StudioContractDriftTest`, `:app-bootstrap`의 functional test +소스셋):** springdoc이 노출하는 `/v3/api-docs`를 `src/config/openapi/studio-v1.yaml`과 대조해 +**operation id · method · path**가 어긋나면 실패시킨다 (`published ⊆ contract` 방향 — 아직 +구현하지 않은 operation이 있어도 green을 유지한다). `dev.caskeleton.adapter.inbound.web.techlog` +아래를 `@ComponentScan`하므로 그 패키지 트리 밖에 컨트롤러를 두면 이 게이트가 못 본다 (스캔 +범위를 벗어나는 즉시 유효성을 잃는다는 뜻이므로 §4.1의 패키지 배치를 반드시 따른다). 별도로 +`getStudioSession`/`listStudioCatalog` 각 1개 케이스에 대해 성공 응답이 +`{success:true, data, meta}` 모양이고 이중 래핑이 없는지도 고정한다 — **모든 operation**의 +봉투 래핑을 확인하는 것은 아니다. + +**아직 구현하지 않은 것 (Plan 02 몫):** + +- 응답 media type 대조 (`application/json` 고정 여부) +- 스키마 필수 필드 대조 (93개 schema·enum·required 필드가 model 생성으로 덮인다는 D3의 전제를 + 실제로 검증하는 회귀 테스트는 없다 — 지금은 생성이 컴파일에 성공하는 것으로 암묵 검증한다) +- 나머지 17개 operation에 대한 봉투 래핑 확인 +- `deleteStudioAsset` 204 본문 없음 확인 (해당 operation 미구현) + +**401/403/429 코드 주장 — 삭제.** 이 절은 원래 401/403/429가 각각 +`AUTHENTICATION_REQUIRED`/`STUDIO_ACCESS_DENIED`/rate-limit 코드로 난다고 적었으나 이는 +코드상 사실이 아니다. 기존(템플릿 소유, 무변경 재사용) 경로가 실제로 내는 코드는: + +- 401 — `EnvelopeAuthenticationEntryPoint` → `SecurityErrorClassifier.classifyAuthentication` + (`src/adapter/inbound/web/src/main/java/.../auth/SecurityErrorClassifier.java:21-35`)이 + `AUTH_TOKEN_MISSING`(`InsufficientAuthenticationException`) 또는 `AUTH_TOKEN_MALFORMED`(그 외 + 분류 불가 인증 실패)를 낸다. +- 403 — `EnvelopeAccessDeniedHandler` → `SecurityErrorClassifier.classifyAccessDenied` (같은 + 파일:39)가 `AUTHZ_INSUFFICIENT_PERMISSION`을 낸다. +- 429 — `RateLimitInterceptor:75`가 `RATE_LIMIT_EXCEEDED`를 낸다. + +이 세 코드 모두 계약(studio-v1.yaml) 23종 `ApiError.code` enum에 없다. 429는 계약에 +rate-limit 코드 자체가 존재하지 않는다. §5.3 "백엔드가 해야 할 일"이 "봉투 관련 신규 작업은 +없다"고 못 박았고 이 경로들은 템플릿 파일(`EnvelopeAuthenticationEntryPoint`, +`EnvelopeAccessDeniedHandler`, `RateLimitInterceptor`, `SecurityErrorClassifier`)만으로 +동작해 Studio가 손댈 수 없다 — 계약을 이 세 코드로 확장할지, 별도 Studio 매핑 계층을 둘지는 +이 spec이 결정하지 않은 채 남아 있다. **Plan 02가 착수 전에 결정해야 한다.** + +### 5.6 계약 재정의 명세 + +`studio-v1.yaml`을 다음과 같이 바꾼다. 이것이 세 저장소의 공유 SSOT가 된다. + +**추가 스키마** + +```text +ResponseMeta requestId, traceId, correlationId, page(nullable) +ApiError code(enum 23), category(enum 10), message, retryable, details(oneOf|null) +ErrorEnvelope success(const false), error(ApiError), meta(ResponseMeta) +ValidationErrorDetails fieldErrors[] +VersionConflictDetails latestDocument, conflictingFields[] +PublicationConflictDetails latestPublication +``` + +**성공 응답 래핑** — JSON 본문을 갖는 18개 operation(200 15개 / 201 3개)의 응답 +스키마를 `{success: const true, data: <기존 payload>, meta: ResponseMeta}` 래퍼로 +교체한다. OpenAPI 3.1에 제네릭이 없으므로 payload별 래퍼 스키마를 만든다. +기존 payload 스키마(`StudioSession`, `WorkingCopyDetail`, …)는 **그대로 남긴다** — +프론트가 `components["schemas"]["StudioSession"]`로 도메인 타입을 계속 뽑아 쓴다. + +**오류 응답 교체** — `components.responses`의 16개 항목 content를 +`application/problem+json` + `ProblemDetails`에서 `application/json` + `ErrorEnvelope`로 +바꾼다. `ProblemDetails` 스키마는 제거한다. + +`deleteStudioAsset`의 204는 변경 없다. + +**`public-v1.yaml` / `studio-management-v1.yaml`** — 두 계약도 `ProblemDetails`를 +쓰지만 이번 구현 범위 밖이고 소비자가 없다. 지금 변환하지 않고 각 파일 상단에 +"봉투 결정(ADR-006) 반영 대기" 배너를 붙인다. 설계 패키지가 `docs/plans/02`에 쓴 +STALE 배너와 같은 방식이다. 구현에 착수할 때 변환한다. + +**따라오는 파일** + +```text +tech-log-design-package + contracts/openapi/studio-v1.yaml 위 변경 + docs/specs/06-api-contract-design.md 7장 오류 계약 재작성 + decisions/ADR-006-response-envelope.md 신규 — 봉투 채택 근거와 RFC 7807 미채택 기록 + TECH_LOG_MASTER_SPEC.md scripts/build-master-spec.sh 재생성 + MANIFEST.sha256 scripts/update-manifest.sh 재생성 + scripts/check-contract-parity.py ProblemDetails.code → ApiError.code 참조 변경 + scripts/check-consistency.py 동일 + +tech-log-frontend + src/features/tech-log/contracts/studio/studio-api.openapi.yaml 동기화 + src/features/tech-log/contracts/studio/generated.ts 재생성 + src/features/tech-log/contracts/studio/canonical-source.json 재생성 + src/features/tech-log/contracts/studio/contract.ts 타입 export 확인 + src/features/tech-log/contracts/tech-log-studio-contract-contribution.ts + outputValidator passthrough → 봉투 data 언랩 + problemValidator PROBLEM(bare) → 봉투 error 언랩 + code enum 검증 유지 + src/features/tech-log/adapters/http/studio-error-mapping.ts ApiError → StudioGatewayError + 관련 테스트 · 픽스처의 HTTP 본문 +``` + +`mock-studio-gateway.ts` 등 `StudioGateway` 포트를 직접 구현하는 mock은 전송 경계 +아래가 아니라 위에 있으므로 **변경 대상이 아니다.** 앱·도메인·프레젠테이션 계층도 +언랩이 경계에서 끝나므로 무변경이다. + +--- + +## 6. 도메인 모델 + +### 6.1 Aggregate + +| Aggregate | 소유 테이블 | 비고 | +| --- | --- | --- | +| `Document` | `document`, `case_detail`, `reference_detail`, `document_tag`, `document_relation` | `RecordKind.CASE` / `REFERENCE` | +| `OpenQuestion` | `open_question`, `question_point`, `question_update`, `question_tag`, `question_document_link` | `RecordKind.QUESTION` | +| `ProjectDecision` | `project_decision` | `RecordKind.PROJECT_DECISION` | +| `Asset` | `asset`, `asset_reference` | | +| `Publication` | `publication`, `publication_event`, `publication_snapshot` | Event·Snapshot은 불변 | + +`WorkingCopy`는 Aggregate가 아니라 **API projection**이다. `working_copy` 범용 +테이블을 만들지 않는다. + +### 6.2 상태 기계 (설계 09장) + +```text +Document DRAFT → IN_REVIEW → PUBLISHED → ARCHIVED (+ unpublish: PUBLISHED → DRAFT) +OpenQuestion OPEN → INVESTIGATING → PAUSED → RESOLVED → ARCHIVED +Publication PUBLISHED → REPUBLISHED → UNPUBLISHED → REPUBLISHED +``` + +**API 용어와 Domain 용어를 분리한다.** + +```text +API questionStatus=OPEN ← Domain OPEN | INVESTIGATING | PAUSED +API questionStatus=RESOLVED ← Domain RESOLVED +``` + +`saveStudioDocument`는 편집 가능한 content field만 저장하며 **lifecycle 전이를 +유발하지 않는다.** OPEN 계열 안에서의 값 변화는 무시한다. 프론트가 축약 상태를 +보냈다는 이유로 `INVESTIGATING`을 `OPEN`으로 덮어쓰면 조사 이력이 소실된다. + +### 6.3 저장하지 않는 값 + +`nextAction`, `publicationStatus`, `hasUnpublishedChanges`, Preview state는 +**조회 시점 계산**이다. `workflow_status` 같은 domain 컬럼에 저장하지 않는다. + +--- + +## 7. 애플리케이션 계층 + +### 7.1 use case 규약 + +context별 use case는 `CommandUseCase` / `QueryUseCase`를 구현하고 +이름이 `UseCase`로 끝나며 `@UseCaseCapability`를 선언한다. + +```java +@UseCaseCapability( + transactionMode = TransactionMode.WRITE, + idempotency = Idempotency.KEYED, + repositoryAccess = RepositoryAccess.WRITE_REPOSITORY) +final class PublishCaseUseCase implements CommandUseCase { } +``` + +트랜잭션 경계는 `@Transactional`이 아니라 `TransactionPort`로 연다. +`TransactionMode`는 `WRITE` / `READ_ONLY` / `REQUIRES_NEW` 계열이고, +`Idempotency`는 `IDEMPOTENT` / `KEYED` / `NOT_IDEMPOTENT`, +`RepositoryAccess`는 `NONE` / `READ_REPOSITORY` / `WRITE_REPOSITORY`다. +`Idempotency-Key`를 쓰는 mutation은 `KEYED`로 선언한다. + +### 7.2 studio facade + +```text +StudioWorkingCopyFacade create / get / save / list → 유형별 port.in dispatch +StudioValidationFacade validate → validation artifact 영속 +StudioPreviewFacade create / get preview artifact + state 계산 +StudioPublicationFacade publish / unpublish / 이력 / snapshot 조회 +StudioDashboardQuery union query 기반 대시보드 +StudioDocumentQuery union query 기반 문서 목록 +StudioCatalogQuery TOPIC | PROJECT | RELATION | EVIDENCE +NextActionCalculator §7.4 +StudioDocumentLocator documentId(UUID) → (sourceKind, sourceId) +``` + +facade는 domain object를 직접 수정하지 않는다. 타 context의 `port.in`만 호출하고 +결과를 통합 DTO로 조립한다. + +`documentId`는 계약상 **source aggregate id를 그대로 쓴다**(별도 surrogate 없음). +`StudioDocumentLocator`가 `document` / `open_question` / `project_decision` union +query로 kind를 해소한다. + +### 7.3 Validation / Preview artifact + +```text +studio_validation validationId, sourceKind, sourceId, validatedVersion, + status(INVALID|WARNINGS|VALID), issues(jsonb), + dependencyRevision, validatedAt, validUntil, createdBy + +studio_preview previewId, sourceKind, sourceId, sourceVersion, + validationId(FK), dependencyRevision, + renderModel(jsonb), createdAt, expiresAt, createdBy +``` + +`dependencyRevision`은 검증에 쓴 외부 의존 상태(topic/project publishability, +relation target 상태, asset READY/QUARANTINED, slug/route ownership, catalog +revision, renderer/content-format version)의 identity+version을 정규화해 만든 +해시다. 전역 카운터가 아니다. + +상태는 저장하지 않고 조회 시 계산한다. + +```text +Validation 유효 validatedVersion == 현재 working version + AND dependencyRevision == 현재 계산값 + AND now() < validUntil + +Preview CURRENT sourceVersion == 현재 working version + AND dependencyRevision == 현재 계산값 + AND now() < expiresAt + STALE version 또는 dependencyRevision 불일치 + EXPIRED now() >= expiresAt +``` + +`VALIDATION_FAILED`(지금 검증하면 실패)와 `VALIDATION_STALE`(통과했으나 전제가 +바뀜)은 다른 사건이며 코드도 다르다. + +### 7.4 nextAction 계산 + +```text +저장 가능한 형태조차 미달 → CONTINUE_EDITING +현재 version에 대한 Validation 없음 → VALIDATE +현재 Validation = INVALID → FIX_VALIDATION +유효 Validation + 현재 version Preview 없음 → CREATE_PREVIEW +Preview가 STALE 또는 EXPIRED → CREATE_PREVIEW +현재 Validation + 현재 Preview + + Publication version 불일치 → PUBLISH +Publication version == working version → NONE +``` + +### 7.5 Publish 트랜잭션 (설계 07장 §14, 20단계) + +```text +0. idempotency 선검사 — 동일 key + 동일 fingerprint면 최초 응답 재생, 이하 미실행 +1. Document SELECT FOR UPDATE +2. expectedVersion 검증 불일치 → VERSION_CONFLICT +3. validationId 조회 + dependencyRevision 재계산 비교 불일치 → VALIDATION_STALE +4. previewId 조회 + sourceVersion / dependencyRevision 비교 + 불일치 → PREVIEW_STALE, 만료 → PREVIEW_EXPIRED +5. acknowledgedWarningCodes가 WARNING 집합을 덮는지 + 미달 → WARNING_ACKNOWLEDGEMENT_REQUIRED +6. 유형별 publication validation (게시 필수 필드 / slug 충돌) +7. Markdown 분석 +8. Asset READY 및 사용 위치 alt/decorative 검증 +9. 공개 payload 생성 +10. Publication Event 생성 (PUBLISHED | REPUBLISHED) +11. Publication Snapshot 생성 (immutable) +12. public_resource_projection upsert +13. public_route canonical/alias 변경 +14. public_resource_tag / project link 교체 +15. PUBLISHED asset_reference 교체 +16. asset.first_published_at 갱신 +17. Publication aggregate 갱신 (latest_event_id, publication_revision) +18. Document publish metadata 갱신 +19. 선택적 ProjectActivity 생성 +20. Commit +``` + +전 단계가 하나의 트랜잭션이다. + +**Snapshot의 render model은 게시 시점에 다시 렌더링하지 않고 사용자가 확인한 +Preview의 render model을 그대로 쓴다.** 재렌더링하면 승인한 화면과 공개된 화면이 +달라질 수 있다. + +Unpublish는 `expectedPublicationRevision` 검증 → `UNPUBLISHED` Event(반드시 +`sourcePublishedEventId` 보유) → Publication 상태 전환 및 revision 증가 → +Projection `ACTIVE → WITHDRAWN` → Working `workflow_status → DRAFT` → route 유지 → +commit. Snapshot은 삭제하지 않는다. + +--- + +## 8. 영속화 + +### 8.1 마이그레이션 + +`PostgreSqlPersistenceConfig.java:57`이 Flyway location을 +`classpath:db/migration/postgresql`로 고정한다. 기존 최대 버전이 V6이고 +`out-of-order: false`이므로 **`V7__techlog_core.sql`부터** 추가한다. + +이번 범위에 필요한 테이블: + +```text +topic tag document case_detail reference_detail document_tag document_relation +open_question question_point question_update question_tag question_document_link +project project_decision project_document_link project_question_link project_activity +asset asset_reference +studio_validation studio_preview +publication publication_event publication_snapshot +public_resource_projection public_route public_resource_tag public_resource_project_link +``` + +`publication.latest_event_id` ↔ `publication_event.publication_id` 순환 FK는 +`DEFERRABLE INITIALLY DEFERRED`로 선언한다. 즉시 검사로 두면 첫 게시가 불가능하다. + +`publication_event` / `publication_snapshot`은 어떤 cleanup job도 삭제하지 않는다. + +### 8.2 멱등 — 기존 자산 재사용 (D5) + +설계의 `studio_idempotency` 테이블을 만들지 않는다. + +| 설계 `studio_idempotency` | 기존 `idempotency_record` | +| --- | --- | +| `idempotency_key` | `idempotency_key` | +| `operation_id` | `use_case_name` | +| `request_fingerprint` | `request_hash` (char(64) sha256) | +| `response_status` / `response_body` | `response_payload` | +| `created_at` / `expires_at` | `created_at` / `expires_at` | +| `created_by` | `principal` (+ `tenant`) | + +기존 것이 상위집합이고 `IdempotencyExecutorV2`, `IdempotencyStorePortV2`, +`IdempotencyKeySupport`(header 추출 · scope · sha256 fingerprint · JSON codec)까지 +있다. 설계 대비 결손은 두 가지뿐이며 web 계층에서 채운다. + +1. 재생 시 `Idempotency-Replayed: true` 응답 헤더 +2. fingerprint 불일치 시 `IDEMPOTENCY_KEY_REUSED` ProblemDetails (409) + +적용 대상: create / save / validate / create preview / publish / unpublish / +asset upload · update · delete. + +### 8.3 읽기·쓰기 분리 + +- 쓰기: JPA aggregate + `@Version` 낙관적 잠금 +- Studio 목록 / 대시보드 / 카탈로그: `JdbcClient` union query (도메인 repository 우회) +- 공통 CRUD repository를 만들지 않는다 + +--- + +## 9. 재사용 매핑 (신규 구현 금지) + +| 설계 요구 | 재사용 대상 | +| --- | --- | +| `Idempotency-Key` | `application/idempotency/v2`, `web/idempotency/IdempotencyKeySupport`, `idempotency_record` | +| `expectedVersion` / 409 | JPA `@Version`, `web/conditional/ETags`, `PreconditionFailedException` | +| Studio cursor 페이지네이션 | `web/cursor/CursorCodec`, `web/pagination/PageParams` | +| 오류 응답 골격 | `shared/response/{Envelope,ApiError,ResponseMeta}`, `web/error/ErrorResponseFactory`, `GlobalExceptionHandler` — **무변경 재사용** | +| 401 / 403 / 429 | `EnvelopeAuthenticationEntryPoint`, `EnvelopeAccessDeniedHandler`, `RateLimitInterceptor` — **무변경 재사용** | +| Keycloak 세션 · CSRF | `web/auth/SecurityConfig`, `JwtDecoderConfig`, `PrimitiveSessionSecurityContextRepository`, `RedisSessionWebConfig` | +| 권한 | `application/security/RequiresPermission`, `web/authz/RolePermissionPolicy`, `RequiresPermissionAuthorizationManager` | +| Asset 업로드 · 저장 | `adapter:outbound:fileserver`, `adapter:outbound:objectstorage` | +| ID 생성 | `adapter:outbound:identifier` | +| 캐시 | `application/cache`, `adapter:outbound:cache-redis` (필요 입증 시에만) | +| 관측 | `web/observability`, `docs/registries/{metrics,mdc-keys,headers}.yaml` | +| 트랜잭션 | `application/transaction/TransactionPort` | + +**Asset은 새 저장 계층을 만들지 않는다.** `asset` 테이블은 metadata·`asset_key`· +lifecycle만 소유하고 바이너리 저장·전송은 fileserver/objectstorage 어댑터에 위임한다. +`asset_key`(안정 참조)와 `object_key`(저장 위치)를 분리한다 — 본문에 object storage +URL을 저장하지 않는다. + +--- + +## 10. 렌더러 + +`PublicRenderModel`은 Markdown을 의미 블록으로 변환한 결과다. 계약이 정의하는 +블록·인라인 타입: + +```text +블록 HeadingBlock ParagraphBlock CodeBlock BlockquoteBlock CalloutBlock + OrderedListBlock UnorderedListBlock DataTableBlock EvidenceFigureBlock +인라인 InlineText InlineStrong InlineEmphasis InlineCode InlineLink InlineStatus +``` + +제약: + +- Public과 Studio Preview가 **같은 의미의 렌더 결과**를 써야 한다 (ADR-005). +- Snapshot은 `contentFormatVersion`, `rendererContractVersion`, asset manifest를 함께 + 보존한다. 이후 Asset이 교체되어도 과거 Snapshot의 표현이 변하지 않는다. +- Asset은 `assetKey`로 참조하고 렌더 시 `ResolvedAsset`으로 해소한다. + +**이 항목이 이번 구현의 최대 리스크다.** 슬라이스 3 착수 전에 프론트의 기존 렌더 +모델 구현(`src/features/tech-log/adapters/mock/project-public-render-model.ts` 및 +static content 경로)을 기준선으로 대조해 블록 의미가 일치하는지 확인한다. + +--- + +## 11. 테스트 전략 + +leaf별 템플릿 정책을 그대로 따른다. + +| 대상 | 방식 | +| --- | --- | +| `domain-core` | 순수 JUnit. 상태 전이·불변 조건 | +| `application-core` | 손수 만든 fake 포트. 웹·영속 컨텍스트 금지 | +| `adapter:inbound:web` | 전송 slice 테스트 + §5.5 봉투 래핑·오류 코드 회귀 테스트 | +| `adapter:outbound:persistence-jpa` | 매핑·포트 계약 테스트. 벤더 의미가 필요한 것(deferrable FK, `SELECT FOR UPDATE`)은 `postgresqlIntegrationTest` 소스셋 | +| `app-bootstrap` | 배선·`TechLogBoundaryArchTest`·계약 회귀 테스트 | + +필수 시나리오: + +- publish 재시도가 중복 `publication_event`를 만들지 않는다 +- `VALIDATION_STALE`과 `VALIDATION_FAILED`가 구분된다 +- `saveStudioDocument`가 `INVESTIGATING`을 `OPEN`으로 되돌리지 않는다 +- unpublish 후 과거 Snapshot이 그대로 조회된다 +- 성공 응답이 `{success:true, data, meta}`이고 `meta.traceId`가 non-null이다 +- 오류 응답이 `{success:false, error, meta}`이고 `error.code`가 계약의 23개 중 하나다 +- 409가 `error.details`로 `latestDocument` / `latestPublication`을 싣는다 + +검증 명령 (`src/`에서): + +```bash +./gradlew :domain-core:test --console=plain +./gradlew :application-core:test --console=plain +./gradlew :adapter:inbound:web:test --console=plain +./gradlew :adapter:outbound:persistence-jpa:test --console=plain +./gradlew verifyCleanArchitectureDependencies --console=plain +./gradlew :app-bootstrap:test --tests '*CleanArchitectureTest' --console=plain +./gradlew :app-bootstrap:test --tests '*TechLogBoundaryArchTest' --console=plain +``` + +--- + +## 12. 슬라이스 + +| # | 내용 | 완료 판정 | +| --- | --- | --- | +| 0 | **계약 봉투 재정의(§5.6) → 프론트 재생성·validator 교체**, 생성기 스파이크 → 배선, 계약 복사, `TechLogBoundaryArchTest`, 오류코드 레지스트리 23개, `V7__techlog_core.sql` | 빌드·아키텍처 검증 통과 + 3 저장소 계약 parity 통과 | +| 1 | `getStudioSession`, `listStudioCatalog` | 프론트 로그인·카탈로그 실동작 | +| 2 | `listStudioDocuments`, `createStudioDocument`, `getStudioDocument`, `saveStudioDocument` (+ 낙관적 잠금, 멱등) | 프론트 편집 실동작 | +| 3 | `validateStudioDocument`, `createStudioPreview`, `getCurrentStudioPreview`, `dependencyRevision`, `nextAction`, 렌더러 | 프론트 검증·미리보기 실동작 | +| 4 | `publishStudioDocument`, `unpublishStudioPublication`, `listStudioPublications`, `getStudioPublicationSnapshot`, `getStudioDashboard` | 프론트 게시 실동작 | +| 5 | asset 5종 | 프론트 자산 실동작 | + +`getStudioDashboard`는 publication·validation 데이터에 의존하므로 슬라이스 4에서 +완성한다. + +--- + +## 13. 리스크 + +| 리스크 | 완화 | +| --- | --- | +| openapi-generator × Spring Boot 4.0.0 미검증 | 슬라이스 0 첫 스텝을 폐기용 스파이크로. 실패 시 §5.2 폴백 | +| 3 저장소(계약·프론트·백엔드) 동기화 실패 | 슬라이스 0에서 계약을 먼저 확정하고 `check-contract-parity.py`를 게이트로 삼는다. 백엔드는 §5.5 계약 회귀 테스트로 고정 | +| 프론트 언랩 누락 시 조용한 실패 (`outputValidator`가 passthrough라 전송 계층이 잡지 못한다) | 언랩 validator에 `success`/`data` 존재 검증을 넣어 하드 실패로 바꾼다 | +| 렌더 모델 의미 불일치 | 슬라이스 3 착수 전 프론트 구현 대조 | +| publish 20단계 트랜잭션 복잡도 | 단계별 실패 코드를 먼저 테스트로 고정한 뒤 구현 | +| 설계 DDL과 템플릿 스키마 충돌 | `studio_idempotency` 제거(D5) 외에는 이름 충돌 없음을 V7 작성 시 재확인 | + +--- + +## 14. 운영 제약 + +- **커밋 금지** — `AGENTS.md:64` commit 정책 `human-only`. 브랜치 생성과 파일 작성까지 + 수행하고 stage/commit/push는 사용자가 한다. 이 제약은 `tech-log-backend`의 규약이며 + `tech-log-design-package` / `tech-log-frontend`에는 적용되지 않는다. +- 이 설계는 **세 저장소**를 건드린다(§5.6). 계약이 SSOT이므로 순서는 + `tech-log-design-package` → `tech-log-frontend` → `tech-log-backend`다. +- `git flow init`은 워킹트리에 미커밋 삭제분(`*-superpowers-package/`, + `scripts/verify-httpclient-docs.py`)이 있어 중단되었다. gitflow 설정은 기록했고 + `develop` / `feature/techlog-studio-backend` 브랜치는 수동 생성했다. 미커밋 삭제분은 + 손대지 않았다. + +--- + +## 15. 참조 + +- 설계: `tech-log-design-package/docs/specs/{06,07,08,09}`, `decisions/ADR-00{1..5}` +- 계약: `tech-log-design-package/contracts/openapi/studio-v1.yaml` +- 프론트 계약: `tech-log-frontend/src/features/tech-log/contracts/studio/studio-api.openapi.yaml` +- 프론트 실행 경로: `tech-log-frontend/src/adapters/http/http-execution-v3.ts` +- 저장소 권위: `src/config/architecture/modules.json`, `src/settings.gradle`, `AGENTS.md` diff --git a/src/adapter/inbound/web/build.gradle b/src/adapter/inbound/web/build.gradle index 6533cb8..fc6bf52 100644 --- a/src/adapter/inbound/web/build.gradle +++ b/src/adapter/inbound/web/build.gradle @@ -1,3 +1,51 @@ +plugins { id 'org.openapi.generator' } + +// --------------------------------------------------------------------------- +// Studio 계약 DTO 생성 — sourceSet 정의와 jar/test classpath 배선 (ADR-004/ADR-006). +// 이 블록은 파일 맨 위, 아래 커스텀 Test 태스크 등록(jpaPersistenceRedactionContractTest, +// webSecurityBoundaryTest)보다 반드시 먼저 와야 한다. 그 둘은 +// `classpath = sourceSets.test.runtimeClasspath`로 **eager** 대입한다(Gradle 9.0.0의 +// DefaultSourceSet을 디컴파일해 확인 — lazy ConventionMapping이 아니라 단순 +// getfield/putfield). 이 배선이 그보다 아래 있으면, 커스텀 태스크는 아직 생성 DTO가 +// 안 얹힌 옛 FileCollection 참조를 이미 붙잡은 뒤라 나중에 sourceSet 필드를 새 +// composite로 갈아 끼워도 못 본다 — 컴파일이 아니라 테스트 런타임에 NoClassDefFoundError로 +// 터진다(발견 당시 실측). 표준 `test` 태스크는 JvmTestSuitePlugin이 lazy +// ConventionMapping Callable로 배선해 괜찮지만(이것도 디컴파일로 확인), register()로 만든 +// 이 두 커스텀 Test 태스크는 lazy 배선을 안 타서 못 본다. +// +// openApiGenerate 확장 자체(무엇을 어떤 옵션으로 생성하는지)는 이 파일 아래쪽, 같은 제목의 +// 주석 섹션에 그대로 있다 — 여기는 sourceSet 정의와 그 소비자(jar, test classpath)만 +// 옮겼다. generatedOpenapiImplementation의 의존성 상속(configurations 블록)과 +// compileGeneratedOpenapiJava/checkstyle/spotbugs/spotless 배선도 원래 자리에 남아있다 — +// 전부 lazy(tasks.named/tasks.matching)라 순서 문제가 없다. +// --------------------------------------------------------------------------- +sourceSets { + generatedOpenapi { + java.srcDir(layout.buildDirectory.dir('generated/openapi/src/main/java')) + } + // main이 생성 DTO를 참조할 수 있어야 한다(Task 8/9 controller). implementation + // Configuration으로 연결하면(즉 main의 implementation에 generatedOpenapi.output을 + // 넣으면) 아래 generatedOpenapiImplementation.extendsFrom(implementation)과 맞물려 + // "컴파일하려면 자기 자신의 산출물이 먼저 있어야 한다"는 순환 태스크 의존성이 생긴다 + // (직접 겪음). 그래서 Configuration이 아니라 SourceSet의 compile/runtime classpath + // FileCollection에 직접 이어 붙인다 — 태스크 의존성은 그대로 따라가면서 순환은 없다. + main { + compileClasspath += generatedOpenapi.output + runtimeClasspath += generatedOpenapi.output + } +} + +// main.runtimeClasspath로는 부족하다 — 그건 "실행 시 클래스를 찾을 수 있다"는 뜻일 뿐, +// consumer(app-bootstrap 등)가 보는 web 모듈의 runtimeElements(= jar 산출물)에는 여전히 +// 생성 DTO가 없다. app-bootstrap은 web을 project dependency로만 물고 web의 build/classes를 +// 직접 보지 않으므로, jar 안에 없으면 부팅/요청 시 NoClassDefFoundError로 터진다. +// 같은 이유로 test sourceSet도 main.output만 물려받지 generatedOpenapi.output까지 +// 자동으로 따라오지 않는다 — controller 테스트가 컴파일조차 안 된다. 셋 다 명시적으로 +// 채워야 한다. +tasks.named('jar') { from sourceSets.generatedOpenapi.output } +sourceSets.test.compileClasspath += sourceSets.generatedOpenapi.output +sourceSets.test.runtimeClasspath += sourceSets.generatedOpenapi.output + // HTTP / web adapters. Depends on application and shared operational contracts. dependencies { implementation project(':application-core') @@ -72,3 +120,95 @@ tasks.register('webSecurityBoundaryTest', Test) { tasks.named('check') { dependsOn tasks.named('webSecurityBoundaryTest') } + +// --------------------------------------------------------------------------- +// Studio 계약 DTO 생성 (ADR-004 / ADR-006). +// +// generateApis에 해당하는 효과: 계약이 봉투를 기술하므로 API interface까지 생성하면 +// 봉투 wrapper 타입을 반환하게 되고, 그 타입은 dev.caskeleton.shared.response.Envelope가 +// 아니라서 EnvelopeBodyAdvice가 한 번 더 감싼다(이중 래핑). controller는 손으로 쓴다. +// +// globalProperties.set(['models': '']): openapi-generator-gradle-plugin 7.18.0의 +// openApiGenerate 확장에는 generateApis/generateModels/generateSupportingFiles 프로퍼티가 +// 존재하지 않는다(디컴파일로 확인, task-4-report.md 참고) — 대신 CLI --global-property와 +// 같은 의미인 globalProperties로 "models만" 생성하게 제한한다. +// +// useOneOfInterfaces=false: discriminator(oneOf) union을 부모 Java interface로 생성하면 +// 하위 타입이 그 인터페이스를 구현하는데, 판별 필드가 enum이면(narrowing 여부와 무관하게) +// 인터페이스의 getter는 무조건 String을 반환하고 하위 타입의 getter는 그 프로퍼티의 실제 +// 타입(nested enum이든 공유 named enum이든)을 반환해 컴파일이 깨진다. 판별 필드를 하위 +// 타입에서 narrowing하지 않고 base의 공유 enum(RecordKind 등)을 그대로 상속하게 계약을 +// 고쳐도 동일하게 깨진다는 것까지 스크래치에서 직접 검증했다 — SpringCodegen이 +// useOneOfInterfaces=true일 때 discriminator getter를 String으로 고정하는 게 근본 +// 원인이라 계약 쪽에서 우회할 수 없다(3개 설정 조합 + 이 검증 전부 task-4-report.md 참고). +// +// useOneOfInterfaces=false는 컴파일은 통과시키지만 대가가 있다: 각 하위 타입이 독립 +// 클래스로 생성되고(WorkingCopyInput 등 union 타입과 CaseInput 등 하위 타입 사이에 +// Java의 implements 관계가 전혀 없다), 그리고 실측 결과 Jackson 배선도 계약대로 동작하지 +// 않는다 — 역직렬화는 InvalidTypeIdException으로 실패하고("Class CaseInput not subtype +// of WorkingCopyInput"), 직렬화는 @JsonIgnoreProperties(value="kind", allowSetters=true) +// 때문에 실제 kind 값 대신 클래스 simple name이 나간다. 즉 생성된 union 클래스는 Jackson +// 양방향 모두 계약을 위반한다. union을 필드 타입으로 쓰는 5개 union(WorkingCopyInput, +// WorkingCopy, Inline, CaseRenderBlock, PublicRenderModel)을 실제로 쓰는 operation은 +// Plan 02에서 전략을 정한 뒤 구현한다 — 이번 Task 8/9(getStudioSession, +// listStudioCatalog)는 이 union들을 쓰지 않으므로 막히지 않는다. +// --------------------------------------------------------------------------- +openApiGenerate { + generatorName = 'spring' + inputSpec = "${rootDir}/config/openapi/studio-v1.yaml".toString() + outputDir = layout.buildDirectory.dir('generated/openapi').get().asFile.path + modelPackage = 'dev.caskeleton.adapter.inbound.web.techlog.studio.api.model' + globalProperties.set(['models': '']) + generateModelTests = false + generateModelDocumentation = false + configOptions = [ + useSpringBoot3: 'true', + useJakartaEe: 'true', + openApiNullable: 'true', + useOneOfInterfaces: 'false', + ] +} + +// sourceSets.generatedOpenapi 정의와 그걸 소비하는 jar/test classpath 배선은 이 파일 +// 맨 위로 옮겼다(리뷰 라운드 2 fix) — 이유는 그 자리의 주석 참고. 여기 남은 건 +// generatedOpenapiImplementation의 의존성 상속뿐이다. +configurations { + // 생성 DTO 컴파일에 필요한 의존성(jakarta.validation, swagger-annotations, + // jackson-databind-nullable, spring-web의 @Nullable/@DateTimeFormat 등)은 이미 main의 + // implementation에 다 있다 — 따로 중복 선언하지 않고 그대로 물려받는다. main의 + // implementation은 generatedOpenapi.output을 포함하지 않으므로(위 참고) 이 확장은 + // 순환을 만들지 않는다. + generatedOpenapiImplementation.extendsFrom(implementation) +} + +tasks.named('compileGeneratedOpenapiJava', JavaCompile) { + dependsOn 'openApiGenerate' + options.errorprone.enabled = false + // 루트 build.gradle의 tasks.withType(JavaCompile).configureEach가 -Werror와 + // -Xlint:deprecation을 이미 넣어 놓은 뒤에 이 설정이 평가되므로(subprojects 블록이 + // 먼저, 이 파일이 나중) 여기서 빼는 게 마지막 값으로 남는다. + doFirst { + options.compilerArgs.removeAll(['-Werror', '-Xlint:deprecation']) + } +} + +// checkstyle/spotbugs는 sourceSet마다 별도 태스크(checkstyleGeneratedOpenapi, +// spotbugsGeneratedOpenapi)를 만든다 — 그 태스크만 끈다. checkstyleMain/spotbugsMain은 +// 생성 코드가 더 이상 main sourceSet에 없으므로 애초에 이 파일들을 보지 않는다. +tasks.matching { it.name == 'checkstyleGeneratedOpenapi' }.configureEach { + dependsOn 'openApiGenerate' + enabled = false +} +tasks.matching { it.name == 'spotbugsGeneratedOpenapi' }.configureEach { + dependsOn 'openApiGenerate' + enabled = false +} + +// spotless는 sourceSet과 무관하게 글롭으로 java 파일을 찾으므로 생성 경로를 명시적으로 +// 뺀다. spotlessJava가 openApiGenerate보다 먼저 돌면 아직 없는 디렉터리를 글롭 검사하다 +// 있으나 마나 한 차이라 dependsOn은 필요 없지만, 생성 전 상태에서 우연히 이전 빌드의 생성물이 +// 남아 채점되는 걸 막기 위해 순서를 맞춘다. +tasks.matching { it.name.startsWith('spotless') }.configureEach { + dependsOn 'openApiGenerate' +} +spotless { java { targetExclude('build/generated/**') } } diff --git a/src/adapter/inbound/web/gradle.lockfile b/src/adapter/inbound/web/gradle.lockfile index b8bb42d..c0429f4 100644 --- a/src/adapter/inbound/web/gradle.lockfile +++ b/src/adapter/inbound/web/gradle.lockfile @@ -1,65 +1,65 @@ # This is a Gradle generated file for dependency locking. # Manual edits can break the build and are not advised. # This file is expected to be part of source control. -biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=compileClasspath,testCompileClasspath -ch.qos.logback:logback-classic:1.5.21=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -ch.qos.logback:logback-core:1.5.21=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -com.fasterxml.jackson.core:jackson-annotations:2.20=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -com.fasterxml.jackson.core:jackson-core:2.20.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -com.fasterxml.jackson.core:jackson-databind:2.20.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.20.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.20.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -com.fasterxml.jackson:jackson-bom:2.20.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -com.fasterxml:classmate:1.7.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor -com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor +biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=compileClasspath,generatedOpenapiCompileClasspath,testCompileClasspath +ch.qos.logback:logback-classic:1.5.21=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +ch.qos.logback:logback-core:1.5.21=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.fasterxml.jackson.core:jackson-annotations:2.20=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.fasterxml.jackson.core:jackson-core:2.20.1=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.fasterxml.jackson.core:jackson-databind:2.20.1=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.20.1=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.20.1=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.fasterxml.jackson:jackson-bom:2.20.1=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.fasterxml:classmate:1.7.1=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,generatedOpenapiAnnotationProcessor,testAnnotationProcessor +com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,generatedOpenapiAnnotationProcessor,testAnnotationProcessor com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs -com.github.spotbugs:spotbugs-annotations:4.8.6=compileClasspath,testCompileClasspath +com.github.spotbugs:spotbugs-annotations:4.8.6=compileClasspath,generatedOpenapiCompileClasspath,testCompileClasspath com.github.spotbugs:spotbugs:4.10.2=spotbugs com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs -com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,testAnnotationProcessor -com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,testAnnotationProcessor -com.google.auto:auto-common:1.2.2=annotationProcessor,testAnnotationProcessor -com.google.code.findbugs:jsr305:3.0.2=checkstyle,compileClasspath,spotbugs,testCompileClasspath +com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,generatedOpenapiAnnotationProcessor,testAnnotationProcessor +com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,generatedOpenapiAnnotationProcessor,testAnnotationProcessor +com.google.auto:auto-common:1.2.2=annotationProcessor,generatedOpenapiAnnotationProcessor,testAnnotationProcessor +com.google.code.findbugs:jsr305:3.0.2=checkstyle,compileClasspath,generatedOpenapiCompileClasspath,spotbugs,testCompileClasspath com.google.code.gson:gson:2.13.2=spotbugs -com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,testAnnotationProcessor -com.google.errorprone:error_prone_annotations:2.38.0=compileClasspath,testCompileClasspath +com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,generatedOpenapiAnnotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_annotations:2.38.0=compileClasspath,generatedOpenapiCompileClasspath,testCompileClasspath com.google.errorprone:error_prone_annotations:2.41.0=spotbugs com.google.errorprone:error_prone_annotations:2.47.0=checkstyle -com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,testAnnotationProcessor -com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,testAnnotationProcessor -com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,testAnnotationProcessor -com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,testAnnotationProcessor -com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,testAnnotationProcessor -com.google.guava:guava:33.5.0-jre=annotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,generatedOpenapiAnnotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,generatedOpenapiAnnotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,generatedOpenapiAnnotationProcessor,testAnnotationProcessor +com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,generatedOpenapiAnnotationProcessor,testAnnotationProcessor +com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,generatedOpenapiAnnotationProcessor,testAnnotationProcessor +com.google.guava:guava:33.5.0-jre=annotationProcessor,generatedOpenapiAnnotationProcessor,testAnnotationProcessor com.google.guava:guava:33.6.0-jre=checkstyle -com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,testAnnotationProcessor -com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,testAnnotationProcessor -com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,testAnnotationProcessor +com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,generatedOpenapiAnnotationProcessor,testAnnotationProcessor +com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,generatedOpenapiAnnotationProcessor,testAnnotationProcessor +com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,generatedOpenapiAnnotationProcessor,testAnnotationProcessor com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins com.jayway.jsonpath:json-path:2.9.0=testCompileClasspath,testRuntimeClasspath -com.nimbusds:nimbus-jose-jwt:10.4=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.nimbusds:nimbus-jose-jwt:10.4=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath com.puppycrawl.tools:checkstyle:13.5.0=checkstyle com.vaadin.external.google:android-json:0.0.20131108.vaadin1=testCompileClasspath,testRuntimeClasspath commons-beanutils:commons-beanutils:1.11.0=checkstyle commons-collections:commons-collections:3.2.2=checkstyle commons-io:commons-io:2.21.0=spotbugs -commons-logging:commons-logging:1.3.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +commons-logging:commons-logging:1.3.5=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath info.picocli:picocli:4.7.7=checkstyle -io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor -io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor -io.micrometer:micrometer-commons:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.micrometer:micrometer-observation:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.projectreactor:reactor-core:3.8.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,generatedOpenapiAnnotationProcessor,testAnnotationProcessor +io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,generatedOpenapiAnnotationProcessor,testAnnotationProcessor +io.micrometer:micrometer-commons:1.16.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-observation:1.16.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.projectreactor:reactor-core:3.8.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath io.projectreactor:reactor-test:3.8.0=testCompileClasspath,testRuntimeClasspath -io.swagger.core.v3:swagger-annotations-jakarta:2.2.38=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.swagger.core.v3:swagger-core-jakarta:2.2.38=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.swagger.core.v3:swagger-models-jakarta:2.2.38=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -jakarta.activation:jakarta.activation-api:2.1.4=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -jakarta.annotation:jakarta.annotation-api:3.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -jakarta.validation:jakarta.validation-api:3.1.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor +io.swagger.core.v3:swagger-annotations-jakarta:2.2.38=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.swagger.core.v3:swagger-core-jakarta:2.2.38=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.swagger.core.v3:swagger-models-jakarta:2.2.38=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +jakarta.activation:jakarta.activation-api:2.1.4=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +jakarta.annotation:jakarta.annotation-api:3.0.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +jakarta.validation:jakarta.validation-api:3.1.1=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +javax.inject:javax.inject:1=annotationProcessor,generatedOpenapiAnnotationProcessor,testAnnotationProcessor jaxen:jaxen:2.0.0=spotbugs net.bytebuddy:byte-buddy-agent:1.17.8=testCompileClasspath,testRuntimeClasspath net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath @@ -68,21 +68,21 @@ net.minidev:json-smart:2.6.0=testCompileClasspath,testRuntimeClasspath net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs org.antlr:antlr4-runtime:4.13.2=checkstyle org.apache.bcel:bcel:6.12.0=spotbugs -org.apache.commons:commons-lang3:3.20.0=checkstyle,compileClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath +org.apache.commons:commons-lang3:3.20.0=checkstyle,compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath org.apache.commons:commons-text:1.15.0=spotbugs org.apache.commons:commons-text:1.3=checkstyle org.apache.httpcomponents:httpclient:4.5.13=checkstyle org.apache.httpcomponents:httpcore:4.4.16=checkstyle -org.apache.logging.log4j:log4j-api:2.25.2=compileClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath +org.apache.logging.log4j:log4j-api:2.25.2=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath org.apache.logging.log4j:log4j-core:2.25.2=spotbugs -org.apache.logging.log4j:log4j-to-slf4j:2.25.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.logging.log4j:log4j-to-slf4j:2.25.2=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.apache.maven.doxia:doxia-core:1.12.0=checkstyle org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle -org.apache.tomcat.embed:tomcat-embed-core:11.0.14=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.apache.tomcat.embed:tomcat-embed-el:11.0.14=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.tomcat.embed:tomcat-embed-core:11.0.14=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.tomcat.embed:tomcat-embed-el:11.0.14=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.apache.xbean:xbean-reflect:3.7=checkstyle org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath org.assertj:assertj-core:3.27.6=testCompileClasspath,testRuntimeClasspath @@ -93,10 +93,10 @@ org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle org.codehaus.plexus:plexus-utils:3.3.0=checkstyle org.dom4j:dom4j:2.2.0=spotbugs org.hamcrest:hamcrest:3.0=testCompileClasspath,testRuntimeClasspath -org.hibernate.validator:hibernate-validator:9.0.1.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.hibernate.validator:hibernate-validator:9.0.1.Final=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.javassist:javassist:3.28.0-GA=checkstyle -org.jboss.logging:jboss-logging:3.6.1.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath +org.jboss.logging:jboss-logging:3.6.1.Final=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,generatedOpenapiAnnotationProcessor,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath org.junit.jupiter:junit-jupiter-api:6.0.1=testCompileClasspath,testRuntimeClasspath org.junit.jupiter:junit-jupiter-engine:6.0.1=testRuntimeClasspath org.junit.jupiter:junit-jupiter-params:6.0.1=testCompileClasspath,testRuntimeClasspath @@ -109,80 +109,80 @@ org.junit:junit-bom:6.1.0=spotbugs org.mockito:mockito-core:5.20.0=mockitoAgent,testCompileClasspath,testRuntimeClasspath org.mockito:mockito-junit-jupiter:5.20.0=testCompileClasspath,testRuntimeClasspath org.objenesis:objenesis:3.3=testRuntimeClasspath -org.openapitools:jackson-databind-nullable:0.2.6=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.openapitools:jackson-databind-nullable:0.2.6=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath -org.osgi:org.osgi.annotation.bundle:2.0.0=compileClasspath,testCompileClasspath -org.osgi:org.osgi.annotation.versioning:1.1.2=compileClasspath,testCompileClasspath -org.osgi:org.osgi.resource:1.0.0=compileClasspath,testCompileClasspath -org.osgi:org.osgi.service.serviceloader:1.0.0=compileClasspath,testCompileClasspath +org.osgi:org.osgi.annotation.bundle:2.0.0=compileClasspath,generatedOpenapiCompileClasspath,testCompileClasspath +org.osgi:org.osgi.annotation.versioning:1.1.2=compileClasspath,generatedOpenapiCompileClasspath,testCompileClasspath +org.osgi:org.osgi.resource:1.0.0=compileClasspath,generatedOpenapiCompileClasspath,testCompileClasspath +org.osgi:org.osgi.service.serviceloader:1.0.0=compileClasspath,generatedOpenapiCompileClasspath,testCompileClasspath org.ow2.asm:asm-analysis:9.10.1=spotbugs org.ow2.asm:asm-commons:9.10.1=spotbugs org.ow2.asm:asm-tree:9.10.1=spotbugs org.ow2.asm:asm-util:9.10.1=spotbugs org.ow2.asm:asm:9.10.1=spotbugs org.ow2.asm:asm:9.7.1=testCompileClasspath,testRuntimeClasspath -org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor -org.reactivestreams:reactive-streams:1.0.4=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.pcollections:pcollections:4.0.1=annotationProcessor,generatedOpenapiAnnotationProcessor,testAnnotationProcessor +org.reactivestreams:reactive-streams:1.0.4=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.reflections:reflections:0.10.2=checkstyle org.skyscreamer:jsonassert:1.5.3=testCompileClasspath,testRuntimeClasspath -org.slf4j:jul-to-slf4j:2.0.17=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.slf4j:slf4j-api:2.0.17=compileClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath +org.slf4j:jul-to-slf4j:2.0.17=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.slf4j:slf4j-api:2.0.17=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j -org.springdoc:springdoc-openapi-starter-common:3.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springdoc:springdoc-openapi-starter-webmvc-api:3.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springdoc:springdoc-openapi-starter-common:3.0.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springdoc:springdoc-openapi-starter-webmvc-api:3.0.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-configuration-processor:4.0.0=annotationProcessor org.springframework.boot:spring-boot-http-client:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-http-converter:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-jackson:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-http-converter:4.0.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-jackson:4.0.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-restclient:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-resttestclient:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-security-oauth2-resource-server:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-security:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-servlet:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-security-oauth2-resource-server:4.0.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-security:4.0.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-servlet:4.0.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-jackson:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-logging:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-oauth2-resource-server:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-security:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-jackson:4.0.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-logging:4.0.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-oauth2-resource-server:4.0.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-security:4.0.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-test:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-tomcat:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-validation:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-web:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-tomcat:4.0.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-validation:4.0.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-web:4.0.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter:4.0.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-test:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-tomcat:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-validation:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-web-server:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-tomcat:4.0.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-validation:4.0.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-web-server:4.0.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-webmvc:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.security:spring-security-config:7.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.security:spring-security-core:7.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.security:spring-security-crypto:7.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.security:spring-security-oauth2-core:7.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.security:spring-security-oauth2-jose:7.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.security:spring-security-oauth2-resource-server:7.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-webmvc:4.0.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot:4.0.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.security:spring-security-config:7.0.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.security:spring-security-core:7.0.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.security:spring-security-crypto:7.0.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.security:spring-security-oauth2-core:7.0.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.security:spring-security-oauth2-jose:7.0.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.security:spring-security-oauth2-resource-server:7.0.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.security:spring-security-test:7.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.security:spring-security-web:7.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.session:spring-session-core:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-aop:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-beans:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-context:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-core:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-expression:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.security:spring-security-web:7.0.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.session:spring-session-core:4.0.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-aop:7.0.1=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-beans:7.0.1=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-context:7.0.1=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-core:7.0.1=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-expression:7.0.1=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework:spring-test:7.0.1=testCompileClasspath,testRuntimeClasspath -org.springframework:spring-web:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-webflux:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-webmvc:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-web:7.0.1=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-webflux:7.0.1=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-webmvc:7.0.1=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs org.xmlunit:xmlunit-core:2.10.4=testCompileClasspath,testRuntimeClasspath -org.yaml:snakeyaml:2.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -tools.jackson.core:jackson-core:3.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -tools.jackson.core:jackson-databind:3.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -tools.jackson:jackson-bom:3.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.yaml:snakeyaml:2.5=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +tools.jackson.core:jackson-core:3.0.2=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +tools.jackson.core:jackson-databind:3.0.2=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +tools.jackson:jackson-bom:3.0.2=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath empty= diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/StudioClientSafeMessages.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/StudioClientSafeMessages.java new file mode 100644 index 0000000..7fad700 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/StudioClientSafeMessages.java @@ -0,0 +1,52 @@ +package dev.caskeleton.adapter.inbound.web.techlog; + +import dev.caskeleton.application.techlog.error.StudioError; + +/** + * Studio 실패의 client-safe {@code error.message} 단일 출처. + * + *

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

문구는 {@code docs/registries/error-codes.yaml}의 각 row {@code client_safe_message}와 정확히 같아야 한다 — + * {@code StudioErrorRegistryTest}가 그 일치를 고정한다. {@code PAYLOAD_TOO_LARGE}/{@code + * UNSUPPORTED_MEDIA_TYPE}은 Studio 전용 row가 없고 {@code feature-api-contract-baseline}이 이미 등록한 row를 + * 재사용하므로(Task 5 report 5절) 그 row의 영문 문구를 그대로 따른다 — 나머지는 Studio 전용 한국어 문구다. + * + *

{@link StudioError}를 exhaustive switch로 매핑하므로(default 없음) 새 상수를 추가하면 이 파일도 컴파일 타임에 고쳐야 한다 — 문구 + * 누락이 생길 수 없다. + */ +public final class StudioClientSafeMessages { + + private StudioClientSafeMessages() {} + + public static String forError(StudioError error) { + return switch (error) { + case AUTHENTICATION_REQUIRED -> "Studio 인증이 필요합니다"; + case STUDIO_ACCESS_DENIED -> "이 Studio 리소스에 접근할 권한이 없습니다"; + case DOCUMENT_NOT_FOUND -> "요청한 문서를 찾을 수 없습니다"; + case VERSION_CONFLICT -> "저장된 version이 더 최신입니다"; + case REQUEST_VALIDATION_FAILED -> "요청 형식이 올바르지 않습니다"; + case DOCUMENT_VALIDATION_FAILED -> "문서 검증에 실패했습니다"; + case VALIDATION_STALE -> "검증 결과가 최신 문서 기준이 아닙니다. 다시 검증해 주세요"; + case PREVIEW_NOT_FOUND -> "요청한 미리보기를 찾을 수 없습니다"; + case PREVIEW_STALE -> "미리보기가 최신 문서 기준이 아닙니다. 다시 생성해 주세요"; + case PREVIEW_EXPIRED -> "미리보기가 만료되었습니다. 다시 생성해 주세요"; + case PUBLICATION_NOT_FOUND -> "요청한 게시물을 찾을 수 없습니다"; + case PUBLICATION_CONFLICT -> "게시 작업이 다른 변경과 충돌했습니다"; + case PUBLICATION_EVENT_NOT_FOUND -> "요청한 게시 이벤트를 찾을 수 없습니다"; + case PUBLICATION_SNAPSHOT_NOT_FOUND -> "요청한 게시 스냅샷을 찾을 수 없습니다"; + case WARNING_ACKNOWLEDGEMENT_REQUIRED -> "경고 확인이 필요합니다. 확인 후 다시 시도해 주세요"; + case IDEMPOTENCY_KEY_REUSED -> "Idempotency 키가 다른 요청에 재사용되었습니다"; + case ASSET_NOT_FOUND -> "요청한 자산을 찾을 수 없습니다"; + case ASSET_NOT_READY -> "자산 처리가 아직 완료되지 않았습니다"; + case ASSET_IN_USE -> "자산이 사용 중이라 이 작업을 수행할 수 없습니다"; + case ASSET_QUARANTINED -> "자산이 격리 처리되어 사용할 수 없습니다"; + case PAYLOAD_TOO_LARGE -> "Request payload is too large"; + case UNSUPPORTED_MEDIA_TYPE -> "Request Content-Type is not supported"; + case STUDIO_UNAVAILABLE -> "Studio 서비스를 일시적으로 사용할 수 없습니다. 잠시 후 다시 시도해 주세요"; + }; + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/StudioExceptionHandler.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/StudioExceptionHandler.java new file mode 100644 index 0000000..bd05a70 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/StudioExceptionHandler.java @@ -0,0 +1,92 @@ +package dev.caskeleton.adapter.inbound.web.techlog; + +import dev.caskeleton.adapter.inbound.web.error.ErrorResponseFactory; +import dev.caskeleton.application.techlog.error.StudioError; +import dev.caskeleton.application.techlog.error.StudioException; +import dev.caskeleton.shared.response.Envelope; +import java.util.List; +import java.util.Map; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.MissingServletRequestParameterException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; + +/** + * Studio 실패를 스켈레톤 봉투로 옮긴다. 템플릿의 {@code GlobalExceptionHandler}를 수정하지 않기 위해 별도 advice로 둔다 — 그 파일은 + * template sync 대상이다. + * + *

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

{@code basePackages} 스코프 (final whole-branch review B4). 이 advice는 {@code + * dev.caskeleton.adapter.inbound.web.techlog} 아래의 컨트롤러(현재 studio 컨트롤러 전부가 여기 산다, {@code + * studio.controller})에만 적용된다. {@link #handleMissingParameter}/{@link #handleTypeMismatch}는 Spring + * MVC 표준 바인딩 예외를 계약 코드로 옮기는데, 스코프 없이 전역으로 두면 fileserver·healthcheck 같은 studio 밖 컨트롤러의 같은 예외까지 가로채 그 + * 기능들의 기존 오류 응답 모양(바로 이 advice가 없었을 때의 {@code GlobalExceptionHandler} 동작)을 바꿔버린다 — 이 브랜치가 건드릴 권한이 + * 없는 기능이다. {@code StudioException} 처리는 애초에 studio 코드만 이 예외를 던지므로 스코프를 좁혀도 동작이 바뀌지 않는다. + */ +@Order(Ordered.HIGHEST_PRECEDENCE) +@RestControllerAdvice(basePackages = "dev.caskeleton.adapter.inbound.web.techlog") +public class StudioExceptionHandler { + + private static final Logger log = LoggerFactory.getLogger(StudioExceptionHandler.class); + + @ExceptionHandler(StudioException.class) + public ResponseEntity> handleStudio(StudioException ex) { + StudioError error = ex.studioError(); + log.error( + "studio failure classified as {} (category={}, retryable={}): {}", + error.code(), + error.category(), + error.retryable(), + ex.getMessage(), + ex); + return ErrorResponseFactory.envelope( + error, StudioClientSafeMessages.forError(error), ex.details()); + } + + /** + * 필수 쿼리 파라미터 누락(예: {@code GET /api/v1/studio/catalog}의 {@code type}). {@code + * GlobalExceptionHandler}는 이 예외를 오버라이드하지 않으므로 부모 {@code ResponseEntityExceptionHandler}가 그대로 bare + * {@code ProblemDetail}(content-type {@code application/problem+json})을 만들고, {@code + * EnvelopeBodyAdvice}의 JSON 미디어타입 검사에 걸려 봉투를 못 씌운다 — ADR-006이 쓰지 않기로 한 RFC 7807이 그대로 나간다(final + * whole-branch review B4). studio 스코프에서 계약 코드 {@link StudioError#REQUEST_VALIDATION_FAILED}(422)로 + * 옮긴다. + */ + @ExceptionHandler(MissingServletRequestParameterException.class) + public ResponseEntity> handleMissingParameter( + MissingServletRequestParameterException ex) { + return requestValidationFailed(ex.getParameterName(), "Required parameter is missing"); + } + + /** + * 쿼리 파라미터 타입 불일치(예: {@code type=BOGUS}, {@code limit=abc}). {@code GlobalExceptionHandler}도 이 예외를 + * 처리하지만 {@code OperationalError.BAD_PARAMETER}를 낸다 — Studio 계약 23종에 없는 코드다. studio 스코프에서 계약 코드로 + * 옮긴다. + */ + @ExceptionHandler(MethodArgumentTypeMismatchException.class) + public ResponseEntity> handleTypeMismatch(MethodArgumentTypeMismatchException ex) { + return requestValidationFailed(ex.getName(), "Parameter value is invalid"); + } + + /** + * {@code details}를 계약의 {@code ValidationErrorDetails}({@code fieldErrors: [{path, message}]}) 모양에 + * 맞춰 싣는다 — 자유형 {@code Object}로 아무 모양이나 실으면 계약의 {@code oneOf} 제약을 위반한다. + */ + private static ResponseEntity> requestValidationFailed( + String parameterName, String message) { + Map fieldError = Map.of("path", "/" + parameterName, "message", message); + Map details = Map.of("fieldErrors", List.of(fieldError)); + return ErrorResponseFactory.envelope( + StudioError.REQUEST_VALIDATION_FAILED, + StudioClientSafeMessages.forError(StudioError.REQUEST_VALIDATION_FAILED), + details); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/controller/StudioCatalogController.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/controller/StudioCatalogController.java new file mode 100644 index 0000000..6490190 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/controller/StudioCatalogController.java @@ -0,0 +1,59 @@ +package dev.caskeleton.adapter.inbound.web.techlog.studio.controller; + +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.CatalogEntry; +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.CatalogPage; +import dev.caskeleton.application.techlog.studio.query.CatalogEntryType; +import dev.caskeleton.application.techlog.studio.query.CatalogEntryView; +import dev.caskeleton.application.techlog.studio.query.CatalogPageView; +import dev.caskeleton.application.techlog.studio.query.ListCatalogQuery; +import dev.caskeleton.application.techlog.studio.service.ListCatalogUseCase; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** + * 반환값을 Envelope로 감싸지 않는다 — EnvelopeBodyAdvice가 감싼다. + * + *

{@code CatalogEntryType}은 application({@code + * dev.caskeleton.application.techlog.studio.query})과 웹 계약 생성 DTO({@code + * dev.caskeleton.adapter.inbound.web.techlog.studio.api.model})에 같은 이름으로 각각 존재한다 — 한 파일에서 둘 다 단일 타입 + * import로 쓰면 컴파일이 깨진다. 이 컨트롤러는 application 쪽을 단일 import로 쓰고(요청 파라미터·use case 입력), 웹 계약 쪽은 {@link + * #toApi}에서 FQN으로만 참조한다(응답 DTO 조립). + */ +@RestController +public class StudioCatalogController { + + private final ListCatalogUseCase listCatalog; + + public StudioCatalogController(ListCatalogUseCase listCatalog) { + this.listCatalog = listCatalog; + } + + @GetMapping("/api/v1/studio/catalog") + public CatalogPage listStudioCatalog( + @RequestParam("type") CatalogEntryType type, + @RequestParam(value = "q", required = false) String q, + @RequestParam(value = "cursor", required = false) String cursor, + @RequestParam(value = "limit", defaultValue = "20") int limit) { + CatalogPageView page = listCatalog.handle(new ListCatalogQuery(type, q, cursor, limit)); + CatalogPage body = new CatalogPage(); + body.setItems(page.items().stream().map(StudioCatalogController::toApi).toList()); + body.setNextCursor(page.nextCursor()); + return body; + } + + private static CatalogEntry toApi(CatalogEntryView view) { + CatalogEntry entry = new CatalogEntry(); + entry.setId(view.id()); + entry.setType( + dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.CatalogEntryType.fromValue( + view.type().name())); + entry.setLabel(view.label()); + entry.setDependencyRevision(view.dependencyRevision()); + if (view.kind() != null) { + entry.setKind(CatalogEntry.KindEnum.fromValue(view.kind())); + } + entry.setPublicPath(view.publicPath()); + return entry; + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/controller/StudioSessionController.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/controller/StudioSessionController.java new file mode 100644 index 0000000..2068679 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/controller/StudioSessionController.java @@ -0,0 +1,105 @@ +package dev.caskeleton.adapter.inbound.web.techlog.studio.controller; + +import dev.caskeleton.adapter.inbound.web.auth.AuthenticatedPrincipal; +import dev.caskeleton.adapter.inbound.web.settings.SecuritySettings; +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.StudioSession; +import dev.caskeleton.application.techlog.error.StudioError; +import dev.caskeleton.application.techlog.error.StudioException; +import java.util.Set; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.web.csrf.CsrfToken; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * 세션은 순수 전송 상태다 — principal과 CSRF 토큰뿐이라 도메인 규칙이 없다. application use case를 끼우지 않는 이유이고, + * application-core는 Spring을 볼 수 없어 SecurityContext에 접근할 수도 없다. + * + *

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

CSRF가 꺼진 auth-mode. {@code SecurityConfig.filterChain}의 JWT 분기는 {@code csrf(csrf -> + * csrf.disable())}로 {@code CsrfConfigurer} 자체를 제거한다 — {@code CsrfFilter}가 돌지 않고 request attribute를 + * 아무도 채우지 않는다. 그런데 Spring Security 7.0.0의 {@code CsrfTokenArgumentResolver}는 + * {@code @EnableWebSecurity}만 있으면 무조건 등록되고, {@code resolveArgument}는 request attribute를 캐스팅만 할 뿐 + * null 체크가 없다 — 그래서 CSRF가 꺼진 모드에서는 {@code csrfToken} 파라미터가 항상 {@code null}이다. Studio는 CSRF 없이 실제로 + * 동작할 수 없으므로, 가짜 토큰을 지어내 200을 돌려주는 대신 {@link StudioError#STUDIO_UNAVAILABLE}(503)로 그 사실을 정직하게 보고한다 + * — 세션 인프라가 갖춰지면(redis-session + 완전한 CSRF 배선) 코드 변경 없이 200으로 바뀐다. ({@code + * StudioSessionCsrfDisabledTest}가 이 경로를 고정한다.) + */ +@RestController +public class StudioSessionController { + + /** studio-v1.yaml {@code StudioSession.csrfHeaderName}은 {@code const} — 이 값만 유효하다. */ + private static final String CONTRACT_CSRF_HEADER_NAME = "X-CSRF-TOKEN"; + + /** studio-v1.yaml {@code StudioSession.roles.maxItems}. */ + private static final int CONTRACT_MAX_ROLES = 20; + + private final String csrfHeaderName; + + /** + * {@code csrf-header-name}을 하드코드하지 않고 설정에서 읽되, 계약이 고정한 값과 다르면 부팅 시점에 즉시 실패한다 — 계약의 {@code const}와 + * 설정이 갈라지는 건 배포 오류이지 런타임에 조용히 넘어갈 문제가 아니다. + */ + public StudioSessionController(SecuritySettings securitySettings) { + String configured = securitySettings.session().csrfHeaderName(); + if (!CONTRACT_CSRF_HEADER_NAME.equals(configured)) { + throw new IllegalStateException( + "ca-skeleton.security.session.csrf-header-name must be \"" + + CONTRACT_CSRF_HEADER_NAME + + "\" (studio-v1.yaml StudioSession.csrfHeaderName is a contract const) but was" + + " configured as \"" + + configured + + "\""); + } + this.csrfHeaderName = configured; + } + + @GetMapping("/api/v1/studio/session") + public StudioSession getStudioSession( + @AuthenticationPrincipal AuthenticatedPrincipal principal, CsrfToken csrfToken) { + if (csrfToken == null) { + throw StudioException.of( + StudioError.STUDIO_UNAVAILABLE, + "CSRF token unavailable: CSRF protection is disabled for the active auth-mode"); + } + + Set roles = Set.copyOf(principal.roles()); + if (roles.size() > CONTRACT_MAX_ROLES) { + // 조용히 잘라내면 클라이언트가 실제 권한과 다른 role 집합을 받는다 — IdP 쪽 role 매핑이 잘못됐다는 + // 신호를 숨기는 셈이라, 잘라내는 대신 실패시켜 드러낸다. + throw StudioException.of( + StudioError.STUDIO_UNAVAILABLE, + "principal role count " + + roles.size() + + " exceeds contract max " + + CONTRACT_MAX_ROLES + + " (studio-v1.yaml StudioSession.roles.maxItems)"); + } + + String displayName = displayNameOf(principal); + if (displayName == null || displayName.isBlank()) { + throw StudioException.of( + StudioError.STUDIO_UNAVAILABLE, + "principal has neither a usable email nor idpUserId; cannot satisfy" + + " StudioSession.displayName minLength 1"); + } + + StudioSession session = new StudioSession(); + session.setAuthenticated(true); + session.setDisplayName(displayName); + session.setRoles(roles); + session.setCsrfToken(csrfToken.getToken()); + session.setCsrfHeaderName(csrfHeaderName); + return session; + } + + /** + * `displayName`은 계약상 1자 이상이다. profile capability(identity 모듈)가 들어오기 전까지 email을 쓰고, 없으면 IdP + * subject로 대체한다. 둘 다 비어 있으면 {@code getStudioSession}이 실패시킨다(위 참조). + */ + private static String displayNameOf(AuthenticatedPrincipal principal) { + String email = principal.email(); + return (email == null || email.isBlank()) ? principal.idpUserId() : email; + } +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/error/StudioExceptionHandlerScopeTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/error/StudioExceptionHandlerScopeTest.java new file mode 100644 index 0000000..c7c6a2b --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/error/StudioExceptionHandlerScopeTest.java @@ -0,0 +1,72 @@ +package dev.caskeleton.adapter.inbound.web.error; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import dev.caskeleton.adapter.inbound.web.envelope.EnvelopeBodyAdvice; +import dev.caskeleton.adapter.inbound.web.techlog.StudioExceptionHandler; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.security.autoconfigure.SecurityAutoConfiguration; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** + * final whole-branch review B4: proves the negative for {@link StudioExceptionHandler}'s + * {@code @RestControllerAdvice(basePackages = "dev.caskeleton.adapter.inbound.web.techlog")} + * scoping. + * + *

{@link Probe} lives in {@code dev.caskeleton.adapter.inbound.web.error} — outside the {@code + * ...web.techlog} tree the advice is scoped to, standing in for a non-Studio feature (fileserver, + * healthcheck). A missing required parameter on it must still fall through to the inherited {@code + * ResponseEntityExceptionHandler} behaviour (bare {@code ProblemDetail}, {@code + * application/problem+json}) exactly as it did before {@code StudioExceptionHandler} grew {@code + * MissingServletRequestParameterException}/{@code MethodArgumentTypeMismatchException} handlers — + * this branch has no mandate to change fileserver/healthcheck's error shape. If the {@code + * basePackages} scope were ever dropped or widened to cover this package, this probe would start + * seeing {@code REQUEST_VALIDATION_FAILED} at 422 instead and this test would fail. + */ +@WebMvcTest( + controllers = StudioExceptionHandlerScopeTest.Probe.class, + excludeAutoConfiguration = SecurityAutoConfiguration.class) +@AutoConfigureMockMvc(addFilters = false) +@Import({ + StudioExceptionHandlerScopeTest.Probe.class, + StudioExceptionHandler.class, + GlobalExceptionHandler.class, + EnvelopeBodyAdvice.class +}) +class StudioExceptionHandlerScopeTest { + + @Autowired private MockMvc mvc; + + @Test + void missingParameterOnANonStudioControllerKeepsTheUnenvelopedGlobalHandlerBehaviour() + throws Exception { + mvc.perform(get("/probe/non-studio")) + .andExpect(status().is(400)) + .andExpect( + content().contentTypeCompatibleWith(MediaType.valueOf("application/problem+json"))); + } + + @RestController + static class Probe { + @GetMapping("/probe/non-studio") + String probe(@RequestParam("required") String required) { + return required; + } + } + + @SpringBootConfiguration + @EnableAutoConfiguration(exclude = SecurityAutoConfiguration.class) + static class TestBootstrap {} +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/techlog/StudioExceptionHandlerTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/techlog/StudioExceptionHandlerTest.java new file mode 100644 index 0000000..49b99f3 --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/techlog/StudioExceptionHandlerTest.java @@ -0,0 +1,62 @@ +package dev.caskeleton.adapter.inbound.web.techlog; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.application.techlog.error.StudioError; +import dev.caskeleton.application.techlog.error.StudioException; +import dev.caskeleton.shared.response.Envelope; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.springframework.http.ResponseEntity; + +class StudioExceptionHandlerTest { + + private final StudioExceptionHandler handler = new StudioExceptionHandler(); + + @Test + void mapsStudioExceptionToFailureEnvelopeWithContractCode() { + ResponseEntity> response = + handler.handleStudio(StudioException.of(StudioError.DOCUMENT_NOT_FOUND, "없음")); + + assertThat(response.getStatusCode().value()).isEqualTo(404); + Envelope body = response.getBody(); + assertThat(body).isNotNull(); + assertThat(body.success()).isFalse(); + assertThat(body.error().code()).isEqualTo("DOCUMENT_NOT_FOUND"); + assertThat(body.error().category()).isEqualTo("NOT_FOUND"); + assertThat(body.error().retryable()).isFalse(); + } + + /** + * Regression test for the client-safe message leak: the handler must never surface {@link + * StudioException#getMessage()} (diagnostic-only, may carry SQLState/upstream detail) — only + * {@link StudioClientSafeMessages#forError(StudioError)}'s fixed, per-code text. + */ + @Test + void neverLeaksTheRawExceptionMessageAndUsesTheClientSafeTextInstead() { + String rawDiagnosticMessage = "pg constraint fk_document_project violated for id=42"; + + ResponseEntity> response = + handler.handleStudio( + StudioException.of(StudioError.DOCUMENT_NOT_FOUND, rawDiagnosticMessage)); + + String message = response.getBody().error().message(); + assertThat(message).isNotEqualTo(rawDiagnosticMessage); + assertThat(message) + .isEqualTo(StudioClientSafeMessages.forError(StudioError.DOCUMENT_NOT_FOUND)); + } + + @Test + void carriesDetailsForConflicts() { + ResponseEntity> response = + handler.handleStudio( + StudioException.withDetails( + StudioError.VERSION_CONFLICT, + "충돌", + Map.of("latestDocument", Map.of("version", 8)))); + + assertThat(response.getStatusCode().value()).isEqualTo(409); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().error().details()).isNotNull(); + } +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/techlog/studio/controller/StudioCatalogBindingErrorEnvelopeTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/techlog/studio/controller/StudioCatalogBindingErrorEnvelopeTest.java new file mode 100644 index 0000000..ecc264e --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/techlog/studio/controller/StudioCatalogBindingErrorEnvelopeTest.java @@ -0,0 +1,138 @@ +package dev.caskeleton.adapter.inbound.web.techlog.studio.controller; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import dev.caskeleton.adapter.inbound.web.envelope.EnvelopeBodyAdvice; +import dev.caskeleton.adapter.inbound.web.error.GlobalExceptionHandler; +import dev.caskeleton.adapter.inbound.web.techlog.StudioExceptionHandler; +import dev.caskeleton.application.techlog.studio.port.out.CatalogQueryPort; +import dev.caskeleton.application.techlog.studio.service.ListCatalogUseCase; +import dev.caskeleton.application.transaction.TransactionPort; +import java.util.function.Supplier; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.security.autoconfigure.SecurityAutoConfiguration; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +/** + * final whole-branch review B4: {@code GET /api/v1/studio/catalog} without its required {@code + * type} query parameter throws {@code MissingServletRequestParameterException}. Neither {@code + * GlobalExceptionHandler} (which we cannot modify — template file) nor the old {@code + * StudioExceptionHandler} handled it, so it fell through to the inherited {@code + * ResponseEntityExceptionHandler} behaviour: a bare {@code ProblemDetail} body with content-type + * {@code application/problem+json}. {@code EnvelopeBodyAdvice#beforeBodyWrite}'s {@code + * MediaType.APPLICATION_JSON.includes(...)} guard then skips wrapping, so a bare RFC 7807 body + * leaks past the envelope — exactly the wire shape ADR-006 says this backend does not use. + * + *

{@code ?type=BOGUS} and {@code ?limit=abc} throw {@code MethodArgumentTypeMismatchException} + * instead — {@code GlobalExceptionHandler} does handle that one directly (so the body stays + * enveloped), but with {@code OperationalError.BAD_PARAMETER}, a code outside the 23-code Studio + * contract. + * + *

This test pins the fix: a Studio-scoped {@code StudioExceptionHandler} handler moves both + * exceptions to the contract's {@code REQUEST_VALIDATION_FAILED} at 422 (the status the {@code + * StudioError} enum and {@code docs/registries/error-codes.yaml} agree on), enveloped like every + * other Studio failure. + * + *

Follows the {@code @WebMvcTest} + hand-built {@code TestBootstrap} slice pattern documented in + * {@link StudioSessionEnvelopeTest} — this module's test source set has no {@code + * CaSkeletonApplication} for {@code @WebMvcTest} to bootstrap from. Security is fully excluded + * (like {@code NoResourceFoundErrorHandlingTest}) since none of these scenarios are + * authentication/authorization-related. {@link ListCatalogUseCase} is real, not mocked (it is a + * {@code final} class and this module's Mockito is not configured with the inline mock maker) — + * built from hand-written fake ports, and its port never runs because request binding fails before + * the controller method body is entered. + */ +@WebMvcTest( + controllers = StudioCatalogController.class, + excludeAutoConfiguration = SecurityAutoConfiguration.class) +@AutoConfigureMockMvc(addFilters = false) +@Import({ + StudioCatalogController.class, + StudioExceptionHandler.class, + GlobalExceptionHandler.class, + EnvelopeBodyAdvice.class, + StudioCatalogBindingErrorEnvelopeTest.TestBeans.class +}) +class StudioCatalogBindingErrorEnvelopeTest { + + @Autowired private MockMvc mvc; + + @Test + void missingRequiredTypeParameterIsEnvelopedAsRequestValidationFailed() throws Exception { + mvc.perform(get("/api/v1/studio/catalog")) + .andExpect(status().is(422)) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.error.code").value("REQUEST_VALIDATION_FAILED")) + .andExpect(jsonPath("$.error.category").value("VALIDATION")); + } + + @Test + void unknownTypeEnumValueIsEnvelopedAsRequestValidationFailed() throws Exception { + mvc.perform(get("/api/v1/studio/catalog").param("type", "BOGUS")) + .andExpect(status().is(422)) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.error.code").value("REQUEST_VALIDATION_FAILED")); + } + + @Test + void nonNumericLimitIsEnvelopedAsRequestValidationFailed() throws Exception { + mvc.perform(get("/api/v1/studio/catalog").param("type", "TOPIC").param("limit", "abc")) + .andExpect(status().is(422)) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.error.code").value("REQUEST_VALIDATION_FAILED")); + } + + static class TestBeans { + + @Bean + ListCatalogUseCase listCatalogUseCase() { + CatalogQueryPort neverInvoked = + (type, query, cursor, limit) -> { + throw new AssertionError( + "ListCatalogUseCase must not run when request binding already failed"); + }; + return new ListCatalogUseCase(neverInvoked, new PassthroughTransactionPort()); + } + } + + /** Runs the action synchronously with no real transactional semantics — a slice test fake. */ + private static final class PassthroughTransactionPort implements TransactionPort { + @Override + public T inWrite(Supplier action) { + return action.get(); + } + + @Override + public T inRootWrite(Supplier action) { + return action.get(); + } + + @Override + public T inRead(Supplier action) { + return action.get(); + } + + @Override + public T inNew(Supplier action) { + return action.get(); + } + } + + @SpringBootConfiguration + @EnableAutoConfiguration(exclude = SecurityAutoConfiguration.class) + static class TestBootstrap {} +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/techlog/studio/controller/StudioSessionControllerTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/techlog/studio/controller/StudioSessionControllerTest.java new file mode 100644 index 0000000..5ca2243 --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/techlog/studio/controller/StudioSessionControllerTest.java @@ -0,0 +1,106 @@ +package dev.caskeleton.adapter.inbound.web.techlog.studio.controller; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.inbound.web.auth.AuthenticatedPrincipal; +import dev.caskeleton.adapter.inbound.web.settings.SecuritySettings; +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.StudioSession; +import dev.caskeleton.application.techlog.error.StudioError; +import dev.caskeleton.application.techlog.error.StudioException; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.IntStream; +import org.junit.jupiter.api.Test; +import org.springframework.security.web.csrf.DefaultCsrfToken; + +class StudioSessionControllerTest { + + private final StudioSessionController controller = + new StudioSessionController(securitySettingsWithCsrfHeaderName("X-CSRF-TOKEN")); + + @Test + void reportsAuthenticatedPrincipalAndCsrfToken() { + StudioSession session = + controller.getStudioSession( + new AuthenticatedPrincipal("sub-1", "donghyeon@example.com", Set.of("STUDIO_EDITOR")), + new DefaultCsrfToken("X-CSRF-TOKEN", "_csrf", "token-value")); + + assertThat(session.getAuthenticated()).isTrue(); + assertThat(session.getDisplayName()).isEqualTo("donghyeon@example.com"); + assertThat(session.getRoles()).containsExactly("STUDIO_EDITOR"); + assertThat(session.getCsrfToken()).isEqualTo("token-value"); + assertThat(session.getCsrfHeaderName()).isEqualTo("X-CSRF-TOKEN"); + } + + @Test + void fallsBackToIdpUserIdWhenEmailIsAbsent() { + StudioSession session = + controller.getStudioSession( + new AuthenticatedPrincipal("sub-1", null, Set.of()), + new DefaultCsrfToken("X-CSRF-TOKEN", "_csrf", "t")); + + assertThat(session.getDisplayName()).isEqualTo("sub-1"); + } + + @Test + void rejectsAMisconfiguredCsrfHeaderNameAtConstructionRatherThanServingTheWrongHeader() { + assertThatThrownBy(() -> new StudioSessionController(securitySettingsWithCsrfHeaderName(null))) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("X-CSRF-TOKEN") + .hasMessageContaining("X-XSRF-TOKEN"); + } + + @Test + void reportsStudioUnavailableRatherThanNpeWhenCsrfTokenIsNull() { + assertThatThrownBy( + () -> + controller.getStudioSession( + new AuthenticatedPrincipal( + "sub-1", "donghyeon@example.com", Set.of("STUDIO_EDITOR")), + null)) + .isInstanceOf(StudioException.class) + .extracting(ex -> ((StudioException) ex).studioError()) + .isEqualTo(StudioError.STUDIO_UNAVAILABLE); + } + + @Test + void reportsStudioUnavailableRatherThanTruncatingRolesBeyondTheContractMax() { + Set tooManyRoles = + IntStream.range(0, 21).mapToObj(i -> "ROLE_" + i).collect(Collectors.toUnmodifiableSet()); + + assertThatThrownBy( + () -> + controller.getStudioSession( + new AuthenticatedPrincipal("sub-1", "donghyeon@example.com", tooManyRoles), + new DefaultCsrfToken("X-CSRF-TOKEN", "_csrf", "t"))) + .isInstanceOf(StudioException.class) + .extracting(ex -> ((StudioException) ex).studioError()) + .isEqualTo(StudioError.STUDIO_UNAVAILABLE); + } + + @Test + void reportsStudioUnavailableRatherThanAnEmptyDisplayNameWhenEmailAndIdpUserIdAreBothBlank() { + assertThatThrownBy( + () -> + controller.getStudioSession( + new AuthenticatedPrincipal(" ", " ", Set.of()), + new DefaultCsrfToken("X-CSRF-TOKEN", "_csrf", "t"))) + .isInstanceOf(StudioException.class) + .extracting(ex -> ((StudioException) ex).studioError()) + .isEqualTo(StudioError.STUDIO_UNAVAILABLE); + } + + private static SecuritySettings securitySettingsWithCsrfHeaderName(String csrfHeaderName) { + SecuritySettings.SessionCookieSettings session = + new SecuritySettings.SessionCookieSettings( + null, null, null, null, null, null, csrfHeaderName); + return new SecuritySettings( + SecuritySettings.AuthenticationMode.JWT, + "https://issuer.example", + null, + List.of(), + session); + } +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/techlog/studio/controller/StudioSessionCsrfDisabledTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/techlog/studio/controller/StudioSessionCsrfDisabledTest.java new file mode 100644 index 0000000..aff5c33 --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/techlog/studio/controller/StudioSessionCsrfDisabledTest.java @@ -0,0 +1,116 @@ +package dev.caskeleton.adapter.inbound.web.techlog.studio.controller; + +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import dev.caskeleton.adapter.inbound.web.auth.AuthenticatedPrincipal; +import dev.caskeleton.adapter.inbound.web.envelope.EnvelopeBodyAdvice; +import dev.caskeleton.adapter.inbound.web.error.GlobalExceptionHandler; +import dev.caskeleton.adapter.inbound.web.observability.MdcKeys; +import dev.caskeleton.adapter.inbound.web.settings.SecuritySettings; +import dev.caskeleton.adapter.inbound.web.techlog.StudioExceptionHandler; +import java.util.List; +import java.util.Set; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.slf4j.MDC; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.test.web.servlet.MockMvc; + +/** + * 프로덕션 JWT auth-mode를 재현한다: {@code SecurityConfig.filterChain}의 JWT 분기는 {@code csrf(csrf -> + * csrf.disable())}로 {@code CsrfConfigurer} 자체를 제거한다 — {@code CsrfFilter}가 돌지 않고 {@code CsrfToken} + * request attribute를 아무도 채우지 않는다. + * + *

그런데 {@code CsrfTokenArgumentResolver}(Spring Security 7.0.0, {@code + * WebMvcSecurityConfiguration.addArgumentResolvers}가 {@code @EnableWebSecurity}만 있으면 무조건 등록한다)의 + * {@code resolveArgument}는 request attribute를 캐스팅만 할 뿐 null 체크가 없다 — attribute가 없으면 그냥 {@code + * null}을 돌려준다. 그래서 JWT 모드에서는 컨트롤러의 {@code CsrfToken csrfToken} 파라미터가 **항상 null**이다. + * + *

이 테스트는 그 사실을 슬라이스 필터체인으로 직접 재현한다 — {@link StudioSessionEnvelopeTest}의 {@code + * SecurityTestConfig}(CSRF 켜짐, Spring 기본값)와 정확히 반대다. 일부러 {@code + * SecurityMockMvcRequestPostProcessors.csrf()}를 쓰지 않는다 — 그 포스트 프로세서는 실제 필터체인 여부와 무관하게 request + * attribute를 직접 채워버려서, 쓰면 이 재현이 무력화된다(CSRF가 꺼져 있어도 토큰이 채워진 것처럼 보이게 된다). + */ +@WebMvcTest(controllers = StudioSessionController.class) +@Import({ + StudioSessionController.class, + EnvelopeBodyAdvice.class, + StudioExceptionHandler.class, + GlobalExceptionHandler.class, + StudioSessionCsrfDisabledTest.SecurityTestConfig.class +}) +class StudioSessionCsrfDisabledTest { + + @Autowired private MockMvc mvc; + + @AfterEach + void clearMdc() { + MDC.remove(MdcKeys.TRACE_ID); + } + + @Test + void reportsStudioUnavailableRatherThanCrashingWhenCsrfIsDisabled() throws Exception { + MDC.put(MdcKeys.TRACE_ID, "test-trace-id"); + + mvc.perform( + get("/api/v1/studio/session") + .with( + authentication( + UsernamePasswordAuthenticationToken.authenticated( + new AuthenticatedPrincipal( + "sub-1", "donghyeon@example.com", Set.of("STUDIO_EDITOR")), + null, + Set.of(new SimpleGrantedAuthority("ROLE_STUDIO_EDITOR")))))) + .andExpect(status().isServiceUnavailable()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.error.code").value("STUDIO_UNAVAILABLE")) + .andExpect(jsonPath("$.error.retryable").value(true)) + .andExpect(jsonPath("$.meta.traceId").isNotEmpty()); + } + + /** + * {@code SecurityConfig.filterChain}의 JWT 분기와 같은 모양 — {@code csrf().disable()} + {@code + * anyRequest().authenticated()}뿐. 실제 앱의 {@code SecurityConfig} 전체(CORS, JWT-vs-redis-session 분기, + * entry point 등)는 가져오지 않는다. + */ + @EnableWebSecurity + static class SecurityTestConfig { + + @Bean + SecurityFilterChain csrfDisabledFilterChain(HttpSecurity http) throws Exception { + http.csrf(csrf -> csrf.disable()) + .authorizeHttpRequests(auth -> auth.anyRequest().authenticated()); + return http.build(); + } + + @Bean + SecuritySettings securitySettings() { + SecuritySettings.SessionCookieSettings session = + new SecuritySettings.SessionCookieSettings( + null, null, null, null, null, null, "X-CSRF-TOKEN"); + return new SecuritySettings( + SecuritySettings.AuthenticationMode.JWT, + "https://issuer.example", + null, + List.of(), + session); + } + } + + @SpringBootConfiguration + @EnableAutoConfiguration + static class TestBootstrap {} +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/techlog/studio/controller/StudioSessionEnvelopeTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/techlog/studio/controller/StudioSessionEnvelopeTest.java new file mode 100644 index 0000000..f42b732 --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/techlog/studio/controller/StudioSessionEnvelopeTest.java @@ -0,0 +1,141 @@ +package dev.caskeleton.adapter.inbound.web.techlog.studio.controller; + +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import dev.caskeleton.adapter.inbound.web.auth.AuthenticatedPrincipal; +import dev.caskeleton.adapter.inbound.web.envelope.EnvelopeBodyAdvice; +import dev.caskeleton.adapter.inbound.web.observability.MdcKeys; +import dev.caskeleton.adapter.inbound.web.settings.SecuritySettings; +import java.util.List; +import java.util.Set; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.slf4j.MDC; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.test.web.servlet.MockMvc; + +/** + * 응답이 봉투로 정확히 한 번 감싸이는지 고정한다. 두 번 감싸이면 프론트가 조용히 깨진다. CSRF가 **켜진** 필터체인(Spring 기본값)에서의 해피 패스만 다룬다 — + * CSRF가 **꺼진**(프로덕션 JWT 모드와 같은 모양) 경로는 {@link StudioSessionCsrfDisabledTest}가 별도로 고정한다. 두 필터체인을 한 + * 테스트 클래스에 같이 둘 수 없다({@code @WebMvcTest}는 클래스당 Spring 컨텍스트 하나뿐이라 {@code SecurityFilterChain} 빈도 + * 하나뿐이다). + * + *

이 모듈(adapter:inbound:web)의 테스트 소스셋에는 {@code CaSkeletonApplication}이 없다 — 그건 app-bootstrap 모듈 + * 소유다. 그래서 {@code @WebMvcTest}가 컨텍스트를 부트스트랩할 {@code @SpringBootConfiguration}을 못 찾는다. {@link + * dev.caskeleton.adapter.inbound.web.envelope.EnvelopeBodyAdviceTest}와 {@link + * dev.caskeleton.adapter.inbound.web.error.NoResourceFoundErrorHandlingTest}가 쓰는 것과 같은 방식으로 — 테스트 + * 전용 nested {@code TestBootstrap}을 두고 필요한 빈을 명시적으로 {@code @Import}한다. + * + *

보안: 이 슬라이스는 실제 {@code SecurityConfig}를 끌어오지 않는다(그건 {@code CorsSettings}, {@code + * JwtToAuthenticatedPrincipalConverter} 등 앱 전체 배선이 필요해서 슬라이스 테스트에 과하다). 이 저장소에 이 조합 (WebMvcTest + + * 실제 보안 필터 체인 + AuthenticatedPrincipal)의 선례가 없다 — {@code SecurityModeWebContractTest}는 {@code + * WebApplicationContextRunner}+standalone MockMvc를 쓰지 slice가 아니고, {@code + * EnvelopeBodyAdviceTest}/{@code NoResourceFoundErrorHandlingTest}는 반대로 Security를 통째로 배제한다 ({@code + * excludeAutoConfiguration = SecurityAutoConfiguration.class} + {@code addFilters = false}). 그래서 + * 브리프가 제시한 fallback을 따른다 — {@code @WithMockUser} 대신 {@code + * SecurityMockMvcRequestPostProcessors.csrf()}와 커스텀 {@code authentication(...)}을 쓴다. + * {@code @WithMockUser}는 principal을 {@code org.springframework.security.core.userdetails.User}로 + * 채우는데 컨트롤러가 기대하는 타입은 {@code AuthenticatedPrincipal}이라 타입 불일치로 {@code @AuthenticationPrincipal}이 + * null을 주입하고 컨트롤러가 NPE를 던진다 — 실측으로 확인했다(task-8 report 참조). + * + *

{@code @WebMvcTest}는 표준 {@code @EnableAutoConfiguration}을 + * {@code @OverrideAutoConfiguration(enabled = false)}로 끄고 test-slice 전용의 제한된 auto-configuration 목록만 + * 적용한다 — 그 목록은 Boot의 {@code ServletWebSecurityAutoConfiguration}(기본 {@code SecurityFilterChain} + + * {@code @EnableWebSecurity})을 포함하지 않는다. 그래서 {@code TestBootstrap}이 + * {@code @EnableAutoConfiguration}을 달고 있어도 {@code CsrfToken}/{@code @AuthenticationPrincipal} 인자 + * 리졸버가 등록되지 않는다 — 실제로 시도했더니 {@code CsrfToken}이 인자 리졸버 없이 {@code @ModelAttribute} 데이터바인딩 경로로 떨어져 "No + * primary or single unique constructor found for interface CsrfToken" {@code + * IllegalStateException}으로 500이 났다. 그래서 {@code @EnableWebSecurity}를 이 테스트가 직접 명시적으로 붙인다({@code + * SecurityTestConfig}) — 그래야 그 인자 리졸버들이 등록된다. + * + *

{@code meta.traceId}는 프로덕션에서 {@code RequestLoggingFilter}가 MDC에 채운다. 그 필터는 {@code + * UserPrincipalPseudonymizerPort} 빈이 필요하고 자체 테스트({@code RequestLoggingFilterTest})가 이미 있으므로 여기서는 + * 재현하지 않는다 — MockMvc가 테스트 스레드에서 동기 실행되는 점을 이용해 MDC를 직접 채운다. + */ +@WebMvcTest(controllers = StudioSessionController.class) +@Import({ + StudioSessionController.class, + EnvelopeBodyAdvice.class, + StudioSessionEnvelopeTest.SecurityTestConfig.class +}) +class StudioSessionEnvelopeTest { + + @Autowired private MockMvc mvc; + + @AfterEach + void clearMdc() { + MDC.remove(MdcKeys.TRACE_ID); + } + + @Test + void wrapsTheSessionPayloadExactlyOnce() throws Exception { + MDC.put(MdcKeys.TRACE_ID, "test-trace-id"); + + mvc.perform( + get("/api/v1/studio/session") + .with(csrf()) + .with( + authentication( + UsernamePasswordAuthenticationToken.authenticated( + new AuthenticatedPrincipal( + "sub-1", "donghyeon@example.com", Set.of("STUDIO_EDITOR")), + null, + Set.of(new SimpleGrantedAuthority("ROLE_STUDIO_EDITOR")))))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.csrfHeaderName").value("X-CSRF-TOKEN")) + .andExpect(jsonPath("$.data.data").doesNotExist()) + .andExpect(jsonPath("$.meta.traceId").isNotEmpty()); + } + + /** + * {@code CsrfToken}/{@code @AuthenticationPrincipal} 인자 리졸버를 등록하는 최소 보안 구성. 실제 앱의 {@code + * SecurityConfig}(CORS, JWT/redis-session 분기, entry point 등)는 가져오지 않고 이 슬라이스가 필요로 하는 것 — 인증된 요청만 + * 통과, CSRF는 Spring 기본값(켜짐) — 만 남긴다. {@link StudioSessionCsrfDisabledTest}의 {@code + * SecurityTestConfig}가 정확히 반대(CSRF 꺼짐)를 재현한다. + */ + @EnableWebSecurity + static class SecurityTestConfig { + + @Bean + SecurityFilterChain testSecurityFilterChain(HttpSecurity http) throws Exception { + http.authorizeHttpRequests(auth -> auth.anyRequest().authenticated()); + return http.build(); + } + + /** + * 컨트롤러가 이제 {@code csrfHeaderName}을 이 설정에서 읽는다(하드코드하지 않음) — 계약값 {@code X-CSRF-TOKEN}과 일치해야 생성자가 + * 통과한다. + */ + @Bean + SecuritySettings securitySettings() { + SecuritySettings.SessionCookieSettings session = + new SecuritySettings.SessionCookieSettings( + null, null, null, null, null, null, "X-CSRF-TOKEN"); + return new SecuritySettings( + SecuritySettings.AuthenticationMode.JWT, + "https://issuer.example", + null, + List.of(), + session); + } + } + + @SpringBootConfiguration + @EnableAutoConfiguration + static class TestBootstrap {} +} diff --git a/src/adapter/outbound/persistence-jpa/build.gradle b/src/adapter/outbound/persistence-jpa/build.gradle index 7bbb230..151578b 100644 --- a/src/adapter/outbound/persistence-jpa/build.gradle +++ b/src/adapter/outbound/persistence-jpa/build.gradle @@ -107,6 +107,15 @@ def postgresqlFileserverMetadataIntegrationTest = registerPostgreSqlReadinessTes def postgresqlFileserverReclamationIntegrationTest = registerPostgreSqlReadinessTest( 'postgresqlFileserverReclamationIntegrationTest', 'dev.caskeleton.adapter.outbound.persistence.readiness.PostgreSqlFileserverReclamationIntegrationTest') +def postgresqlTechLogSchemaMigrationTest = registerPostgreSqlReadinessTest( + 'postgresqlTechLogSchemaMigrationTest', + 'dev.caskeleton.adapter.outbound.persistence.techlog.TechLogSchemaMigrationTest') +// Task 9 (listStudioCatalog): JdbcCatalogQueryAdapterTest has no @SpringBootConfiguration to hang a +// @SpringBootTest off in this module (same reason as postgresqlTechLogSchemaMigrationTest above), so it +// needs its own opt-in Testcontainers task rather than reusing an existing one. +def postgresqlTechLogCatalogQueryIntegrationTest = registerPostgreSqlReadinessTest( + 'postgresqlTechLogCatalogQueryIntegrationTest', + 'dev.caskeleton.adapter.outbound.persistence.techlog.query.JdbcCatalogQueryAdapterTest') def verifyJpaSqlConstructionSafety = tasks.register('verifyJpaSqlConstructionSafety') { group = 'verification' diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/query/JdbcCatalogQueryAdapter.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/query/JdbcCatalogQueryAdapter.java new file mode 100644 index 0000000..38e32bc --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/query/JdbcCatalogQueryAdapter.java @@ -0,0 +1,78 @@ +package dev.caskeleton.adapter.outbound.persistence.techlog.query; + +import dev.caskeleton.application.techlog.studio.port.out.CatalogQueryPort; +import dev.caskeleton.application.techlog.studio.query.CatalogEntryType; +import dev.caskeleton.application.techlog.studio.query.CatalogEntryView; +import dev.caskeleton.application.techlog.studio.query.CatalogPageView; +import java.util.List; +import java.util.Locale; +import java.util.UUID; +import org.springframework.jdbc.core.simple.JdbcClient; +import org.springframework.stereotype.Repository; + +/** + * catalog는 도메인 repository를 거치지 않고 전용 union query를 쓴다 (설계 08장 §4). + * + *

RELATION / EVIDENCE는 슬라이스 2·5에서 채운다. 그때까지 빈 페이지를 반환하며 이는 계약상 유효한 응답이다. + */ +@Repository +public class JdbcCatalogQueryAdapter implements CatalogQueryPort { + + private final JdbcClient jdbcClient; + + public JdbcCatalogQueryAdapter(JdbcClient jdbcClient) { + this.jdbcClient = jdbcClient; + } + + @Override + public CatalogPageView search(CatalogEntryType type, String query, String cursor, int limit) { + String pattern = + (query == null || query.isBlank()) ? "%" : "%" + query.toLowerCase(Locale.ROOT) + "%"; + List items = + switch (type) { + case TOPIC -> searchTopics(pattern, limit); + case PROJECT -> searchProjects(pattern, limit); + case RELATION, EVIDENCE -> List.of(); + }; + return new CatalogPageView(items, null); + } + + private List searchTopics(String pattern, int limit) { + return jdbcClient + .sql( + "SELECT id, name, updated_at FROM topic " + + "WHERE status = 'ACTIVE' AND lower(name) LIKE :pattern " + + "ORDER BY name LIMIT :limit") + .param("pattern", pattern) + .param("limit", limit) + .query( + (rs, rowNum) -> + new CatalogEntryView( + UUID.fromString(rs.getString("id")), + CatalogEntryType.TOPIC, + rs.getString("name"), + null, + null, + "topic:" + rs.getTimestamp("updated_at").toInstant())) + .list(); + } + + private List searchProjects(String pattern, int limit) { + return jdbcClient + .sql( + "SELECT id, name, updated_at FROM project " + + "WHERE lower(name) LIKE :pattern ORDER BY name LIMIT :limit") + .param("pattern", pattern) + .param("limit", limit) + .query( + (rs, rowNum) -> + new CatalogEntryView( + UUID.fromString(rs.getString("id")), + CatalogEntryType.PROJECT, + rs.getString("name"), + "PROJECT", + null, + "project:" + rs.getTimestamp("updated_at").toInstant())) + .list(); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/resources/db/migration/postgresql/V7__techlog_core.sql b/src/adapter/outbound/persistence-jpa/src/main/resources/db/migration/postgresql/V7__techlog_core.sql new file mode 100644 index 0000000..d60cd56 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/resources/db/migration/postgresql/V7__techlog_core.sql @@ -0,0 +1,779 @@ +-- Tech Log 코어 스키마. +-- 원본: tech-log-design-package/database/V1__init.sql +-- (branch feature/response-envelope-adr-006, HEAD b20d7a2 — 이 파일은 그 이후 바뀌지 않았다) +-- +-- 원본 DDL과의 차이: +-- 1. `CREATE SCHEMA IF NOT EXISTS tech_log;` / `SET search_path TO tech_log, public;` 제거. +-- 이 저장소의 기존 마이그레이션(V1/V3/V4/V5/V6)과 JPA 설정(@EntityScan, @EnableJpaRepositories, +-- PostgreSqlPersistenceConfig의 FlywayConfigurationCustomizer)은 모두 기본 public 스키마를 +-- 전제한다. tech_log 전용 스키마로 옮기면 Task 9 이후의 JPA 엔티티가 이 테이블들을 찾지 +-- 못한다. 그래서 테이블은 이 저장소의 다른 모든 테이블과 마찬가지로 public 스키마에 만든다. +-- 2. `studio_idempotency` 테이블과 전용 인덱스(`idx_studio_idempotency_expiry`)를 제외한다. +-- 기존 `idempotency_record`를 재사용한다 (spec D5). +-- 3. `release` / `site_config` / `profile_page` / `home_focus_config` / +-- `topic_featured_document` / `project_topic` 테이블과 전용 인덱스(`uq_topic_start_here`)를 +-- 제외한다. 이번 범위 밖이다 (spec §2.2). site_config/profile_page/home_focus_config를 +-- 시딩하던 마지막 INSERT 구문도 대상 테이블이 없으므로 함께 제외했다. +-- 4. 그 밖의 테이블·컬럼·CHECK·UNIQUE·인덱스·주석·순서는 원본을 그대로 보존한다. 순환 FK +-- `publication.latest_event_id` -> `publication_event.publication_id`의 +-- `DEFERRABLE INITIALLY DEFERRED`도 그대로 유지한다 — 즉시 검사로 바꾸면 첫 게시가 +-- 구조적으로 불가능해진다. + +-- Tech Log initial PostgreSQL schema +-- Target: PostgreSQL 16+ + +CREATE EXTENSION IF NOT EXISTS pg_trgm; +CREATE EXTENSION IF NOT EXISTS pgcrypto; + +-- --------------------------------------------------------------------------- +-- Taxonomy and assets +-- --------------------------------------------------------------------------- + +CREATE TABLE topic ( + id uuid PRIMARY KEY, + name varchar(80) NOT NULL, + normalized_name varchar(80) NOT NULL, + slug varchar(100) NOT NULL, + description varchar(600), + scope text, + status varchar(20) NOT NULL DEFAULT 'ACTIVE' + CHECK (status IN ('ACTIVE', 'ARCHIVED')), + version bigint NOT NULL DEFAULT 0, + created_at timestamptz NOT NULL DEFAULT now(), + created_by varchar(255) NOT NULL, + updated_at timestamptz NOT NULL DEFAULT now(), + updated_by varchar(255) NOT NULL, + CONSTRAINT uq_topic_normalized_name UNIQUE (normalized_name), + CONSTRAINT uq_topic_slug UNIQUE (slug) +); + +CREATE TABLE tag ( + id uuid PRIMARY KEY, + name varchar(40) NOT NULL, + normalized_name varchar(40) NOT NULL, + slug varchar(60) NOT NULL, + version bigint NOT NULL DEFAULT 0, + created_at timestamptz NOT NULL DEFAULT now(), + created_by varchar(255) NOT NULL, + updated_at timestamptz NOT NULL DEFAULT now(), + updated_by varchar(255) NOT NULL, + CONSTRAINT uq_tag_normalized_name UNIQUE (normalized_name), + CONSTRAINT uq_tag_slug UNIQUE (slug) +); + +-- asset_key는 Public content가 참조하는 안정적인 key다. +-- object_key(스토리지 경로)와 분리되며 immutable이다. +-- +-- asset_key -> Asset lookup -> current approved delivery path +-- +-- 콘텐츠 원문에 object storage URL을 직접 영속하지 않는다. +-- 공개 이력이 있는 asset_key의 재사용 금지는 application rule로 강제한다. +-- (DB는 현재 행의 유일성만 보장한다.) +CREATE TABLE asset ( + id uuid PRIMARY KEY, + asset_key varchar(200) NOT NULL, + asset_kind varchar(20) NOT NULL + CHECK (asset_kind IN ('IMAGE', 'DIAGRAM', 'ATTACHMENT')), + management_status varchar(20) NOT NULL + CHECK (management_status IN ('READY', 'ARCHIVED', 'REJECTED', 'QUARANTINED')), + object_key varchar(500) NOT NULL, + original_name varchar(255) NOT NULL, + display_name varchar(255), + content_type varchar(150) NOT NULL, + size_bytes bigint NOT NULL CHECK (size_bytes >= 0), + width integer CHECK (width IS NULL OR width > 0), + height integer CHECK (height IS NULL OR height > 0), + checksum_sha256 char(64) NOT NULL, + alt_text varchar(300), + decorative boolean NOT NULL DEFAULT false, + first_published_at timestamptz, + version bigint NOT NULL DEFAULT 0, + created_at timestamptz NOT NULL DEFAULT now(), + created_by varchar(255) NOT NULL, + updated_at timestamptz NOT NULL DEFAULT now(), + updated_by varchar(255) NOT NULL, + CONSTRAINT uq_asset_key UNIQUE (asset_key), + CONSTRAINT uq_asset_object_key UNIQUE (object_key) +); + +-- alt/decorative는 DB CHECK로 강제하지 않는다. +-- +-- 업로드 시점에는 alt를 아직 정하지 않을 수 있어야 하고(Asset Picker에서 이후 수정), +-- 최종 판단은 syntax parser가 아니라 Publication Validation이 한다. +-- +-- Asset decorative=false + 사용 위치 alt 비어 있음 -> PublishValidationFailed +-- Asset decorative=true -> alt="" 허용 +-- +-- 즉 판단 대상은 asset.alt_text 자체가 아니라 "해당 사용 위치의 alt"다. +-- 같은 Asset이 문서마다 다른 alt로 쓰일 수 있으므로 행 단위 CHECK로 표현할 수 없다. + +-- --------------------------------------------------------------------------- +-- Knowledge documents +-- --------------------------------------------------------------------------- + +CREATE TABLE document ( + id uuid PRIMARY KEY, + document_type varchar(20) NOT NULL + CHECK (document_type IN ('CASE', 'REFERENCE')), + slug varchar(180), + title varchar(180) NOT NULL, + body_markdown text NOT NULL DEFAULT '', + content_format varchar(20) NOT NULL DEFAULT 'MARKDOWN' + CHECK (content_format IN ('MARKDOWN')), + content_format_version smallint NOT NULL DEFAULT 1 + CHECK (content_format_version > 0), + workflow_status varchar(20) NOT NULL DEFAULT 'DRAFT' + CHECK (workflow_status IN ('DRAFT', 'IN_REVIEW', 'PUBLISHED', 'ARCHIVED')), + target_visibility varchar(20) NOT NULL DEFAULT 'PRIVATE' + CHECK (target_visibility IN ('PRIVATE', 'UNLISTED', 'PUBLIC')), + primary_topic_id uuid REFERENCES topic(id), + cover_asset_id uuid REFERENCES asset(id), + last_verified_at timestamptz, + first_published_at timestamptz, + last_published_at timestamptz, + version bigint NOT NULL DEFAULT 0, + created_at timestamptz NOT NULL DEFAULT now(), + created_by varchar(255) NOT NULL, + updated_at timestamptz NOT NULL DEFAULT now(), + updated_by varchar(255) NOT NULL, + CONSTRAINT uq_document_id_type UNIQUE (id, document_type), + CONSTRAINT uq_document_type_slug UNIQUE (document_type, slug), + CONSTRAINT ck_document_slug_non_blank CHECK (slug IS NULL OR length(trim(slug)) > 0), + CONSTRAINT ck_document_publish_time_order CHECK ( + first_published_at IS NULL + OR last_published_at IS NULL + OR first_published_at <= last_published_at + ) +); + +CREATE TABLE case_detail ( + document_id uuid PRIMARY KEY, + document_type varchar(20) NOT NULL DEFAULT 'CASE' + CHECK (document_type = 'CASE'), + problem_summary varchar(600) NOT NULL DEFAULT '', + conclusion_summary varchar(600) NOT NULL DEFAULT '', + environment_items jsonb NOT NULL DEFAULT '[]'::jsonb + CHECK (jsonb_typeof(environment_items) = 'array'), + CONSTRAINT fk_case_detail_document + FOREIGN KEY (document_id, document_type) + REFERENCES document(id, document_type) + ON DELETE CASCADE +); + +CREATE TABLE reference_detail ( + document_id uuid PRIMARY KEY, + document_type varchar(20) NOT NULL DEFAULT 'REFERENCE' + CHECK (document_type = 'REFERENCE'), + scope_summary varchar(600) NOT NULL DEFAULT '', + applies_to jsonb NOT NULL DEFAULT '[]'::jsonb + CHECK (jsonb_typeof(applies_to) = 'array'), + excluded_scope jsonb NOT NULL DEFAULT '[]'::jsonb + CHECK (jsonb_typeof(excluded_scope) = 'array'), + freshness_status varchar(20) NOT NULL DEFAULT 'CURRENT' + CHECK (freshness_status IN ('CURRENT', 'REVIEW_DUE', 'HISTORICAL')), + CONSTRAINT fk_reference_detail_document + FOREIGN KEY (document_id, document_type) + REFERENCES document(id, document_type) + ON DELETE CASCADE +); + +CREATE TABLE document_tag ( + document_id uuid NOT NULL REFERENCES document(id) ON DELETE CASCADE, + tag_id uuid NOT NULL REFERENCES tag(id), + display_order integer NOT NULL CHECK (display_order >= 0), + PRIMARY KEY (document_id, tag_id), + CONSTRAINT uq_document_tag_order UNIQUE (document_id, display_order) +); + +CREATE TABLE document_relation ( + source_document_id uuid NOT NULL REFERENCES document(id) ON DELETE CASCADE, + target_document_id uuid NOT NULL REFERENCES document(id), + relation_type varchar(30) NOT NULL + CHECK (relation_type IN ('RELATED', 'DERIVED_FROM', 'SUPERSEDES')), + display_order integer NOT NULL DEFAULT 0 CHECK (display_order >= 0), + created_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (source_document_id, target_document_id, relation_type), + CONSTRAINT ck_document_relation_not_self CHECK (source_document_id <> target_document_id) +); + +-- --------------------------------------------------------------------------- +-- Open questions +-- --------------------------------------------------------------------------- + +CREATE TABLE open_question ( + id uuid PRIMARY KEY, + slug varchar(180), + question varchar(300) NOT NULL, + summary varchar(600), + context_markdown text NOT NULL DEFAULT '', + importance_markdown text NOT NULL DEFAULT '', + next_verification text, + question_status varchar(20) NOT NULL DEFAULT 'OPEN' + CHECK (question_status IN ('OPEN', 'INVESTIGATING', 'PAUSED', 'RESOLVED', 'ARCHIVED')), + target_visibility varchar(20) NOT NULL DEFAULT 'PRIVATE' + CHECK (target_visibility IN ('PRIVATE', 'UNLISTED', 'PUBLIC')), + primary_topic_id uuid REFERENCES topic(id), + resolution_type varchar(30) + CHECK (resolution_type IS NULL OR resolution_type IN ( + 'DECISION_MADE', + 'ASSUMPTION_REJECTED', + 'QUESTION_REFRAMED', + 'NO_LONGER_RELEVANT' + )), + resolution_summary text, + opened_at timestamptz NOT NULL DEFAULT now(), + resolved_at timestamptz, + first_published_at timestamptz, + last_published_at timestamptz, + version bigint NOT NULL DEFAULT 0, + created_at timestamptz NOT NULL DEFAULT now(), + created_by varchar(255) NOT NULL, + updated_at timestamptz NOT NULL DEFAULT now(), + updated_by varchar(255) NOT NULL, + CONSTRAINT uq_open_question_slug UNIQUE (slug), + CONSTRAINT ck_question_resolution_consistency CHECK ( + (question_status = 'RESOLVED' + AND resolution_type IS NOT NULL + AND resolution_summary IS NOT NULL + AND resolved_at IS NOT NULL) + OR + (question_status <> 'RESOLVED') + ) +); + +CREATE TABLE question_point ( + id uuid PRIMARY KEY, + question_id uuid NOT NULL REFERENCES open_question(id) ON DELETE CASCADE, + point_kind varchar(20) NOT NULL + CHECK (point_kind IN ('FACT', 'ASSUMPTION', 'UNKNOWN', 'CONSTRAINT')), + content text NOT NULL CHECK (length(trim(content)) > 0), + display_order integer NOT NULL CHECK (display_order >= 0), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT uq_question_point_order UNIQUE (question_id, point_kind, display_order) +); + +CREATE TABLE question_update ( + id uuid PRIMARY KEY, + question_id uuid NOT NULL REFERENCES open_question(id) ON DELETE CASCADE, + update_type varchar(30) NOT NULL + CHECK (update_type IN ( + 'OBSERVATION', + 'EVIDENCE', + 'SCOPE_CHANGE', + 'BLOCKER', + 'NEXT_STEP', + 'RESOLUTION', + 'RESOLUTION_REOPENED' + )), + title varchar(180) NOT NULL, + body_markdown text NOT NULL DEFAULT '', + update_visibility varchar(20) NOT NULL DEFAULT 'PRIVATE' + CHECK (update_visibility IN ('PRIVATE', 'PUBLIC')), + sequence_no integer NOT NULL CHECK (sequence_no > 0), + occurred_at timestamptz NOT NULL, + version bigint NOT NULL DEFAULT 0, + created_at timestamptz NOT NULL DEFAULT now(), + created_by varchar(255) NOT NULL, + updated_at timestamptz NOT NULL DEFAULT now(), + updated_by varchar(255) NOT NULL, + CONSTRAINT uq_question_update_sequence UNIQUE (question_id, sequence_no) +); + +CREATE TABLE question_tag ( + question_id uuid NOT NULL REFERENCES open_question(id) ON DELETE CASCADE, + tag_id uuid NOT NULL REFERENCES tag(id), + display_order integer NOT NULL CHECK (display_order >= 0), + PRIMARY KEY (question_id, tag_id), + CONSTRAINT uq_question_tag_order UNIQUE (question_id, display_order) +); + +CREATE TABLE question_document_link ( + question_id uuid NOT NULL REFERENCES open_question(id) ON DELETE CASCADE, + document_id uuid NOT NULL REFERENCES document(id), + relation_type varchar(30) NOT NULL + CHECK (relation_type IN ('RESULT_CASE', 'DERIVED_REFERENCE', 'RELATED')), + display_order integer NOT NULL DEFAULT 0 CHECK (display_order >= 0), + PRIMARY KEY (question_id, document_id, relation_type) +); + +CREATE UNIQUE INDEX uq_question_result_case + ON question_document_link(question_id) + WHERE relation_type = 'RESULT_CASE'; + +-- --------------------------------------------------------------------------- +-- Projects, decisions, activities +-- --------------------------------------------------------------------------- + +CREATE TABLE project ( + id uuid PRIMARY KEY, + slug varchar(180), + name varchar(180) NOT NULL, + one_line_purpose varchar(600) NOT NULL DEFAULT '', + purpose_markdown text NOT NULL DEFAULT '', + boundary_markdown text NOT NULL DEFAULT '', + system_overview_markdown text NOT NULL DEFAULT '', + phase varchar(30) NOT NULL DEFAULT 'RESEARCH' + CHECK (phase IN ( + 'RESEARCH', + 'DESIGN', + 'IMPLEMENTATION', + 'VERIFICATION', + 'MAINTENANCE', + 'PAUSED', + 'COMPLETED' + )), + current_objective text, + next_step text, + technology_labels jsonb NOT NULL DEFAULT '[]'::jsonb + CHECK (jsonb_typeof(technology_labels) = 'array'), + workflow_status varchar(20) NOT NULL DEFAULT 'DRAFT' + CHECK (workflow_status IN ('DRAFT', 'PUBLISHED', 'ARCHIVED')), + target_visibility varchar(20) NOT NULL DEFAULT 'PRIVATE' + CHECK (target_visibility IN ('PRIVATE', 'UNLISTED', 'PUBLIC')), + featured_order integer, + first_published_at timestamptz, + last_published_at timestamptz, + version bigint NOT NULL DEFAULT 0, + created_at timestamptz NOT NULL DEFAULT now(), + created_by varchar(255) NOT NULL, + updated_at timestamptz NOT NULL DEFAULT now(), + updated_by varchar(255) NOT NULL, + CONSTRAINT uq_project_slug UNIQUE (slug), + CONSTRAINT ck_project_featured_order CHECK (featured_order IS NULL OR featured_order >= 0) +); + +CREATE TABLE project_document_link ( + project_id uuid NOT NULL REFERENCES project(id) ON DELETE CASCADE, + document_id uuid NOT NULL REFERENCES document(id), + relation_type varchar(20) NOT NULL + CHECK (relation_type IN ('PRIMARY', 'RELATED')), + featured_order integer, + PRIMARY KEY (project_id, document_id), + CONSTRAINT ck_project_document_featured_order CHECK (featured_order IS NULL OR featured_order >= 0) +); + +CREATE UNIQUE INDEX uq_document_primary_project + ON project_document_link(document_id) + WHERE relation_type = 'PRIMARY'; + +CREATE TABLE project_question_link ( + project_id uuid NOT NULL REFERENCES project(id) ON DELETE CASCADE, + question_id uuid NOT NULL REFERENCES open_question(id), + relation_type varchar(20) NOT NULL + CHECK (relation_type IN ('PRIMARY', 'RELATED')), + featured_order integer, + PRIMARY KEY (project_id, question_id), + CONSTRAINT ck_project_question_featured_order CHECK (featured_order IS NULL OR featured_order >= 0) +); + +CREATE UNIQUE INDEX uq_question_primary_project + ON project_question_link(question_id) + WHERE relation_type = 'PRIMARY'; + +CREATE TABLE project_decision ( + id uuid PRIMARY KEY, + project_id uuid NOT NULL REFERENCES project(id) ON DELETE CASCADE, + statement varchar(1000) NOT NULL, + rationale_markdown text NOT NULL DEFAULT '', + consequences jsonb NOT NULL DEFAULT '[]'::jsonb + CHECK (jsonb_typeof(consequences) = 'array'), + alternatives_markdown text NOT NULL DEFAULT '', + decision_status varchar(20) NOT NULL DEFAULT 'PROPOSED' + CHECK (decision_status IN ('PROPOSED', 'ACCEPTED', 'SUPERSEDED', 'REJECTED')), + target_visibility varchar(20) NOT NULL DEFAULT 'PRIVATE' + CHECK (target_visibility IN ('PRIVATE', 'PUBLIC')), + source_question_id uuid REFERENCES open_question(id), + source_case_id uuid REFERENCES document(id), + superseded_by_id uuid, + is_featured boolean NOT NULL DEFAULT false, + decided_at timestamptz, + version bigint NOT NULL DEFAULT 0, + created_at timestamptz NOT NULL DEFAULT now(), + created_by varchar(255) NOT NULL, + updated_at timestamptz NOT NULL DEFAULT now(), + updated_by varchar(255) NOT NULL, + CONSTRAINT fk_project_decision_superseded_by + FOREIGN KEY (superseded_by_id) + REFERENCES project_decision(id), + CONSTRAINT ck_project_decision_not_self_supersede + CHECK (superseded_by_id IS NULL OR superseded_by_id <> id), + CONSTRAINT ck_project_decision_status_fields CHECK ( + decision_status NOT IN ('ACCEPTED', 'SUPERSEDED') + OR decided_at IS NOT NULL + ), + CONSTRAINT ck_project_decision_supersede_target CHECK ( + decision_status <> 'SUPERSEDED' + OR superseded_by_id IS NOT NULL + ) +); + +CREATE UNIQUE INDEX uq_project_featured_decision + ON project_decision(project_id) + WHERE is_featured = true; + +CREATE TABLE project_activity ( + id uuid PRIMARY KEY, + project_id uuid NOT NULL REFERENCES project(id) ON DELETE CASCADE, + activity_type varchar(40) NOT NULL + CHECK (activity_type IN ( + 'PHASE_CHANGED', + 'QUESTION_OPENED', + 'QUESTION_RESOLVED', + 'DECISION_ACCEPTED', + 'CASE_PUBLISHED', + 'REFERENCE_PUBLISHED', + 'MILESTONE_REACHED', + 'PROJECT_PAUSED', + 'PROJECT_RESUMED' + )), + title varchar(180) NOT NULL, + summary varchar(600), + visibility varchar(20) NOT NULL DEFAULT 'PRIVATE' + CHECK (visibility IN ('PRIVATE', 'PUBLIC')), + origin varchar(20) NOT NULL + CHECK (origin IN ('AUTO', 'MANUAL')), + related_resource_type varchar(30), + related_resource_id uuid, + occurred_at timestamptz NOT NULL, + operation_key varchar(180), + version bigint NOT NULL DEFAULT 0, + created_at timestamptz NOT NULL DEFAULT now(), + created_by varchar(255) NOT NULL, + updated_at timestamptz NOT NULL DEFAULT now(), + updated_by varchar(255) NOT NULL, + CONSTRAINT uq_project_activity_operation_key UNIQUE (project_id, operation_key) +); + +-- --------------------------------------------------------------------------- +-- Publication and public read model +-- --------------------------------------------------------------------------- + +CREATE TABLE public_resource_projection ( + resource_type varchar(30) NOT NULL + CHECK (resource_type IN ( + 'CASE', + 'REFERENCE', + 'QUESTION', + 'PROJECT', + 'PROJECT_DECISION', + 'PROJECT_ACTIVITY', + 'RELEASE', + 'PROFILE' + )), + resource_id uuid NOT NULL, + source_version bigint NOT NULL CHECK (source_version >= 0), + publication_state varchar(20) NOT NULL + CHECK (publication_state IN ('ACTIVE', 'WITHDRAWN')), + visibility varchar(20) NOT NULL + CHECK (visibility IN ('PUBLIC', 'UNLISTED')), + title varchar(300) NOT NULL, + summary varchar(600), + state_code varchar(30), + primary_topic_id uuid REFERENCES topic(id), + payload_schema_version smallint NOT NULL CHECK (payload_schema_version > 0), + payload jsonb NOT NULL CHECK (jsonb_typeof(payload) = 'object'), + body_plain_text text NOT NULL DEFAULT '', + search_text text NOT NULL DEFAULT '', + content_hash char(64) NOT NULL, + published_at timestamptz NOT NULL, + updated_at timestamptz NOT NULL, + last_verified_at timestamptz, + latest_index_at timestamptz, + navigation_path varchar(500) NOT NULL, + PRIMARY KEY (resource_type, resource_id) +); + +CREATE TABLE public_route ( + resource_type varchar(30) NOT NULL, + slug varchar(180) NOT NULL, + resource_id uuid NOT NULL, + route_role varchar(20) NOT NULL + CHECK (route_role IN ('CANONICAL', 'ALIAS')), + created_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (resource_type, slug), + CONSTRAINT fk_public_route_projection + FOREIGN KEY (resource_type, resource_id) + REFERENCES public_resource_projection(resource_type, resource_id) + ON DELETE CASCADE +); + +CREATE UNIQUE INDEX uq_public_route_canonical + ON public_route(resource_type, resource_id) + WHERE route_role = 'CANONICAL'; + +CREATE TABLE public_resource_tag ( + resource_type varchar(30) NOT NULL, + resource_id uuid NOT NULL, + tag_id uuid NOT NULL REFERENCES tag(id), + display_order integer NOT NULL DEFAULT 0 CHECK (display_order >= 0), + PRIMARY KEY (resource_type, resource_id, tag_id), + CONSTRAINT fk_public_resource_tag_projection + FOREIGN KEY (resource_type, resource_id) + REFERENCES public_resource_projection(resource_type, resource_id) + ON DELETE CASCADE, + CONSTRAINT uq_public_resource_tag_order + UNIQUE (resource_type, resource_id, display_order) +); + +CREATE TABLE public_resource_project_link ( + resource_type varchar(30) NOT NULL, + resource_id uuid NOT NULL, + project_id uuid NOT NULL REFERENCES project(id), + relation_type varchar(20) NOT NULL + CHECK (relation_type IN ('PRIMARY', 'RELATED')), + featured_order integer, + PRIMARY KEY (resource_type, resource_id, project_id), + CONSTRAINT fk_public_resource_project_projection + FOREIGN KEY (resource_type, resource_id) + REFERENCES public_resource_projection(resource_type, resource_id) + ON DELETE CASCADE, + CONSTRAINT ck_public_project_featured_order CHECK ( + featured_order IS NULL OR featured_order >= 0 + ) +); + +CREATE UNIQUE INDEX uq_public_primary_project + ON public_resource_project_link(resource_type, resource_id) + WHERE relation_type = 'PRIMARY'; + +CREATE TABLE asset_reference ( + asset_id uuid NOT NULL REFERENCES asset(id), + owner_type varchar(30) NOT NULL + CHECK (owner_type IN ( + 'DOCUMENT', + 'QUESTION', + 'QUESTION_UPDATE', + 'PROJECT', + 'DECISION', + 'RELEASE', + 'PROFILE', + 'SITE' + )), + owner_id uuid NOT NULL, + reference_scope varchar(20) NOT NULL + CHECK (reference_scope IN ('WORKING', 'PUBLISHED')), + reference_role varchar(20) NOT NULL + CHECK (reference_role IN ('BODY', 'COVER', 'AVATAR', 'ATTACHMENT')), + created_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (asset_id, owner_type, owner_id, reference_scope, reference_role) +); + +-- preview_token 테이블은 제거되었다. +-- Capability Token 기반 익명 Preview는 인증된 Preview Artifact(studio_preview)로 +-- 대체되었다. contracts/openapi/preview-v1.deprecated.md 참고. + +-- --------------------------------------------------------------------------- +-- Studio workflow artifacts +-- +-- WorkingCopy는 API projection이므로 범용 working_copy 테이블을 만들지 않는다. +-- 아래 테이블은 편집 대상 자체가 아니라 "편집 흐름이 만들어내는 산출물"을 저장한다. +-- +-- source_kind + source_id는 Studio API의 documentId를 가리킨다. +-- documentId는 source aggregate id를 그대로 사용하므로 별도 surrogate id가 없다. +-- --------------------------------------------------------------------------- + +-- Validation은 일급 artifact다. 실행하고 버리는 결과가 아니라 특정 version을 +-- 검증한 사실을 validation_id로 참조할 수 있어야 한다. +CREATE TABLE studio_validation ( + validation_id uuid PRIMARY KEY, + source_kind varchar(30) NOT NULL + CHECK (source_kind IN ('CASE', 'REFERENCE', 'QUESTION', 'PROJECT_DECISION')), + source_id uuid NOT NULL, + validated_version bigint NOT NULL CHECK (validated_version >= 0), + status varchar(20) NOT NULL + CHECK (status IN ('INVALID', 'WARNINGS', 'VALID')), + issues jsonb NOT NULL DEFAULT '[]'::jsonb + CHECK (jsonb_typeof(issues) = 'array'), + -- 검증에 사용한 외부 의존 상태(Topic/Project publishability, relation target, + -- Asset READY/QUARANTINED, slug/route ownership, catalog revision, + -- renderer/content-format version)를 정규화한 hash. + -- Publish 시 다시 계산해 값이 다르면 VALIDATION_STALE로 거절한다. + dependency_revision varchar(200) NOT NULL, + validated_at timestamptz NOT NULL DEFAULT now(), + valid_until timestamptz NOT NULL, + created_by varchar(255) NOT NULL, + CONSTRAINT ck_studio_validation_window CHECK (valid_until > validated_at) +); + +-- Preview는 저장된 version + validation + dependency revision을 묶어 만든 +-- PublicRenderModel snapshot이다. 인증된 Studio API로만 조회한다. +-- CURRENT/STALE/EXPIRED 상태는 저장하지 않고 조회 시점에 서버가 계산한다. +CREATE TABLE studio_preview ( + preview_id uuid PRIMARY KEY, + source_kind varchar(30) NOT NULL + CHECK (source_kind IN ('CASE', 'REFERENCE', 'QUESTION', 'PROJECT_DECISION')), + source_id uuid NOT NULL, + source_version bigint NOT NULL CHECK (source_version >= 0), + validation_id uuid NOT NULL REFERENCES studio_validation(validation_id), + dependency_revision varchar(200) NOT NULL, + render_model jsonb NOT NULL CHECK (jsonb_typeof(render_model) = 'object'), + created_at timestamptz NOT NULL DEFAULT now(), + expires_at timestamptz NOT NULL, + created_by varchar(255) NOT NULL, + CONSTRAINT ck_studio_preview_expiry CHECK (expires_at > created_at) +); + +-- --------------------------------------------------------------------------- +-- Publication aggregate, immutable history, immutable snapshot +-- +-- 세 개념을 분리한다. +-- +-- publication 현재 게시 상태 +-- publication_event 게시/재게시/게시 취소 불변 이력 +-- publication_snapshot PUBLISHED/REPUBLISHED 시점의 불변 PublicRenderModel +-- +-- public_resource_projection은 여전히 "현재 공개 상태"를 담당한다. +-- 과거 Snapshot을 현재 source나 현재 projection에서 재계산하지 않는다. +-- --------------------------------------------------------------------------- + +CREATE TABLE publication ( + publication_id uuid PRIMARY KEY, + source_kind varchar(30) NOT NULL + CHECK (source_kind IN ('CASE', 'REFERENCE', 'QUESTION', 'PROJECT_DECISION')), + source_id uuid NOT NULL, + status varchar(20) NOT NULL + CHECK (status IN ('PUBLISHED', 'UNPUBLISHED')), + published_version bigint NOT NULL CHECK (published_version >= 0), + -- Publication 자체의 optimistic concurrency 토큰. + -- unpublish는 expectedPublicationRevision으로 이 값을 검증한다. + publication_revision bigint NOT NULL DEFAULT 1 CHECK (publication_revision >= 1), + latest_event_id uuid NOT NULL, + public_path varchar(500) NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT uq_publication_source UNIQUE (source_kind, source_id) +); + +CREATE TABLE publication_event ( + publication_event_id uuid PRIMARY KEY, + publication_id uuid NOT NULL REFERENCES publication(publication_id), + source_kind varchar(30) NOT NULL + CHECK (source_kind IN ('CASE', 'REFERENCE', 'QUESTION', 'PROJECT_DECISION')), + source_id uuid NOT NULL, + event_type varchar(20) NOT NULL + CHECK (event_type IN ('PUBLISHED', 'REPUBLISHED', 'UNPUBLISHED')), + published_version bigint NOT NULL CHECK (published_version >= 0), + -- UNPUBLISHED Event는 자체 snapshot을 만들지 않고 마지막 공개 Snapshot을 참조한다. + source_published_event_id uuid REFERENCES publication_event(publication_event_id), + occurred_at timestamptz NOT NULL DEFAULT now(), + -- Publish 재시도가 중복 Event를 만들지 않도록 최초 요청의 idempotency key를 남긴다. + idempotency_key varchar(200), + created_by varchar(255) NOT NULL, + CONSTRAINT ck_publication_event_source_ref CHECK ( + (event_type = 'UNPUBLISHED' AND source_published_event_id IS NOT NULL) + OR (event_type <> 'UNPUBLISHED' AND source_published_event_id IS NULL) + ) +); + +-- Event row는 생성 후 수정하지 않는다. UPDATE/DELETE 차단은 권한과 application +-- rule로 강제하며, 필요하면 운영에서 REVOKE UPDATE, DELETE로 보강한다. + +-- 첫 게시는 publication(latest_event_id) -> publication_event -> publication UPDATE +-- 순서로 한 transaction 안에서 처리된다. 순환 참조를 허용하기 위해 지연 검사한다. +ALTER TABLE publication + ADD CONSTRAINT fk_publication_latest_event + FOREIGN KEY (latest_event_id) + REFERENCES publication_event(publication_event_id) + DEFERRABLE INITIALLY DEFERRED; + +CREATE TABLE publication_snapshot ( + publication_event_id uuid PRIMARY KEY + REFERENCES publication_event(publication_event_id), + render_model jsonb NOT NULL CHECK (jsonb_typeof(render_model) = 'object'), + content_format_version varchar(50) NOT NULL, + renderer_contract_version varchar(50) NOT NULL, + -- 게시 시점에 사용된 Asset의 assetKey/delivery path/치수를 고정한다. + -- 이후 Asset이 교체되어도 과거 Snapshot의 표현은 변하지 않는다. + asset_manifest jsonb NOT NULL DEFAULT '[]'::jsonb + CHECK (jsonb_typeof(asset_manifest) = 'array'), + created_at timestamptz NOT NULL DEFAULT now() +); + +-- --------------------------------------------------------------------------- +-- Indexes +-- --------------------------------------------------------------------------- + +CREATE INDEX idx_document_management + ON document(document_type, workflow_status, updated_at DESC); + +CREATE INDEX idx_document_topic + ON document(primary_topic_id, document_type, updated_at DESC); + +CREATE INDEX idx_question_status + ON open_question(question_status, updated_at DESC); + +CREATE INDEX idx_question_topic + ON open_question(primary_topic_id, question_status, updated_at DESC); + +CREATE INDEX idx_question_update_timeline + ON question_update(question_id, occurred_at ASC, sequence_no ASC); + +CREATE INDEX idx_project_phase + ON project(phase, updated_at DESC); + +CREATE INDEX idx_project_decision + ON project_decision(project_id, decision_status, decided_at DESC); + +CREATE INDEX idx_project_activity + ON project_activity(project_id, visibility, occurred_at DESC); + +CREATE INDEX idx_asset_status + ON asset(management_status, created_at DESC); + +CREATE INDEX idx_asset_checksum + ON asset(checksum_sha256); + +CREATE INDEX idx_asset_reference_owner + ON asset_reference(owner_type, owner_id, reference_scope); + +CREATE INDEX idx_asset_reference_asset + ON asset_reference(asset_id, reference_scope); + +CREATE INDEX idx_public_latest + ON public_resource_projection(latest_index_at DESC, resource_type, resource_id) + WHERE publication_state = 'ACTIVE' + AND visibility = 'PUBLIC' + AND latest_index_at IS NOT NULL; + +CREATE INDEX idx_public_topic + ON public_resource_projection(primary_topic_id, resource_type, published_at DESC) + WHERE publication_state = 'ACTIVE' + AND visibility = 'PUBLIC'; + +CREATE INDEX idx_public_type + ON public_resource_projection(resource_type, visibility, published_at DESC) + WHERE publication_state = 'ACTIVE'; + +CREATE INDEX idx_public_projection_search_trgm + ON public_resource_projection + USING gin (search_text gin_trgm_ops) + WHERE publication_state = 'ACTIVE' + AND visibility = 'PUBLIC'; + +CREATE INDEX idx_public_project_link_lookup + ON public_resource_project_link(project_id, relation_type, resource_type); + +-- Studio workflow artifacts ------------------------------------------------- + +-- 특정 version에 대한 최신 Validation 조회 (nextAction 계산의 핵심 경로) +CREATE INDEX idx_studio_validation_source + ON studio_validation(source_kind, source_id, validated_version, validated_at DESC); + +-- 특정 version에 대한 최신 Preview 조회 +CREATE INDEX idx_studio_preview_source + ON studio_preview(source_kind, source_id, source_version, created_at DESC); + +-- 만료 Preview 정리 배치 +CREATE INDEX idx_studio_preview_expiry + ON studio_preview(expires_at); + +-- Publication history ------------------------------------------------------- + +-- 한 문서의 게시 이력 (occurredAt DESC, publicationEventId DESC 정렬 계약과 일치) +CREATE INDEX idx_publication_event_publication + ON publication_event(publication_id, occurred_at DESC, publication_event_id DESC); + +-- 전체 게시 기록 화면과 source 기준 조회 +CREATE INDEX idx_publication_event_source + ON publication_event(source_kind, source_id, occurred_at DESC); diff --git a/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/readiness/PostgreSqlMigrationIntegrationTest.java b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/readiness/PostgreSqlMigrationIntegrationTest.java index 0697bcb..96451ce 100644 --- a/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/readiness/PostgreSqlMigrationIntegrationTest.java +++ b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/readiness/PostgreSqlMigrationIntegrationTest.java @@ -44,7 +44,7 @@ class PostgreSqlMigrationIntegrationTest { .migrate(); assertThat(appliedVersions(postgres, "flyway_schema_history")) - .containsExactly("1", "3", "4", "5", "6"); + .containsExactly("1", "3", "4", "5", "6", "7"); Flyway coreStream = Flyway.configure() diff --git a/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/techlog/TechLogSchemaMigrationTest.java b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/techlog/TechLogSchemaMigrationTest.java new file mode 100644 index 0000000..4e6b07a --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/techlog/TechLogSchemaMigrationTest.java @@ -0,0 +1,157 @@ +package dev.caskeleton.adapter.outbound.persistence.techlog; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.zaxxer.hikari.HikariConfig; +import com.zaxxer.hikari.HikariDataSource; +import java.sql.Connection; +import java.sql.ResultSet; +import java.util.ArrayList; +import java.util.List; +import javax.sql.DataSource; +import org.flywaydb.core.Flyway; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.testcontainers.DockerClientFactory; +import org.testcontainers.postgresql.PostgreSQLContainer; + +/** + * V7이 실제 PostgreSQL에 적용되는지, 그리고 순환 FK가 deferrable로 선언됐는지 본다. H2로는 검증할 수 없다 — deferrable 제약이 벤더 의미이기 + * 때문이다. + * + *

이 모듈(persistence-jpa)에는 {@code @SpringBootConfiguration}이 없다 — Boot 메인 클래스는 app-bootstrap 모듈에 + * 있고, 이 모듈의 postgresqlIntegrationTest 소스셋 classpath에는 포함되지 않는다. 그래서 {@code @SpringBootTest}로 컨텍스트를 + * 띄울 수 없고, 이 패키지의 형제인 {@code readiness.PostgreSqlMigrationIntegrationTest}와 같은 방식 — Testcontainers + * 위에서 순수 Flyway API를 직접 구동 — 을 쓴다. 컨테이너는 매번 완전히 빈 상태로 시작하므로, 기존 V1/V3/V4/V5/V6 다음에 V7이 얹히는 전체 체인이 + * 클린 DB에 처음부터 적용되는 경로를 그대로 검증한다. + */ +class TechLogSchemaMigrationTest { + + private static final String IMAGE = + System.getProperty("jpa.evidence.postgresql.image", "postgres:16-alpine"); + + private static PostgreSQLContainer postgres; + private static HikariDataSource dataSource; + + @BeforeAll + static void migrateFreshDatabase() { + if (!DockerClientFactory.instance().isDockerAvailable()) { + throw new IllegalStateException( + "Docker is required for the Tech Log schema migration test; skipping is forbidden"); + } + postgres = new PostgreSQLContainer(IMAGE).withReuse(false); + postgres.start(); + + HikariConfig config = new HikariConfig(); + config.setJdbcUrl(postgres.getJdbcUrl()); + config.setUsername(postgres.getUsername()); + config.setPassword(postgres.getPassword()); + config.setMaximumPoolSize(5); + config.setMinimumIdle(1); + dataSource = new HikariDataSource(config); + + // classpath:db/migration/postgresql only — the same location + // PostgreSqlPersistenceConfig's FlywayConfigurationCustomizer pins the application to. Using + // the full "classpath:db/migration" tree here would also pick up the unrelated jpa/* streams + // (each starting their own V1) and collide. + Flyway.configure() + .dataSource(dataSource) + .locations("classpath:db/migration/postgresql") + .table("flyway_schema_history") + .baselineOnMigrate(false) + .outOfOrder(false) + .load() + .migrate(); + } + + @AfterAll + static void stopPostgreSql() { + if (dataSource != null) { + dataSource.close(); + } + if (postgres != null) { + postgres.stop(); + } + } + + @Test + void createsEveryTechLogTable() throws Exception { + List expected = + List.of( + "topic", + "tag", + "document", + "case_detail", + "reference_detail", + "document_tag", + "document_relation", + "open_question", + "question_point", + "question_update", + "question_tag", + "question_document_link", + "project", + "project_decision", + "project_document_link", + "project_question_link", + "project_activity", + "asset", + "asset_reference", + "studio_validation", + "studio_preview", + "publication", + "publication_event", + "publication_snapshot", + "public_resource_projection", + "public_route", + "public_resource_tag", + "public_resource_project_link"); + + List actual = new ArrayList<>(); + try (Connection connection = dataSource().getConnection(); + ResultSet rs = + connection + .createStatement() + .executeQuery( + "SELECT table_name FROM information_schema.tables WHERE table_schema = 'public'")) { + while (rs.next()) { + actual.add(rs.getString(1)); + } + } + assertThat(actual).containsAll(expected); + } + + @Test + void doesNotCreateAStudioIdempotencyTable() throws Exception { + try (Connection connection = dataSource().getConnection(); + ResultSet rs = + connection + .createStatement() + .executeQuery( + "SELECT count(*) FROM information_schema.tables " + + "WHERE table_schema = 'public' AND table_name = 'studio_idempotency'")) { + rs.next(); + assertThat(rs.getInt(1)).isZero(); + } + } + + @Test + void publicationLatestEventForeignKeyIsDeferrable() throws Exception { + try (Connection connection = dataSource().getConnection(); + ResultSet rs = + connection + .createStatement() + .executeQuery( + "SELECT condeferrable, condeferred FROM pg_constraint " + + "WHERE conname = 'fk_publication_latest_event'")) { + assertThat(rs.next()).as("fk_publication_latest_event 제약이 있어야 한다").isTrue(); + assertThat(rs.getBoolean(1)).as("deferrable").isTrue(); + assertThat(rs.getBoolean(2)).as("initially deferred").isTrue(); + } + } + + private static DataSource dataSource() { + return dataSource; + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/techlog/query/JdbcCatalogQueryAdapterTest.java b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/techlog/query/JdbcCatalogQueryAdapterTest.java new file mode 100644 index 0000000..a916c0c --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/techlog/query/JdbcCatalogQueryAdapterTest.java @@ -0,0 +1,121 @@ +package dev.caskeleton.adapter.outbound.persistence.techlog.query; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.zaxxer.hikari.HikariConfig; +import com.zaxxer.hikari.HikariDataSource; +import dev.caskeleton.application.techlog.studio.query.CatalogEntryType; +import dev.caskeleton.application.techlog.studio.query.CatalogPageView; +import org.flywaydb.core.Flyway; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.simple.JdbcClient; +import org.testcontainers.DockerClientFactory; +import org.testcontainers.postgresql.PostgreSQLContainer; + +/** + * 이 모듈(persistence-jpa)에는 {@code @SpringBootConfiguration}이 없다 — Boot 메인 클래스는 app-bootstrap 모듈에 있고, + * 이 모듈의 postgresqlIntegrationTest 소스셋 classpath에는 포함되지 않는다. 그래서 브리프의 {@code @SpringBootTest}로는 + * 컨텍스트를 띄울 수 없다({@code TechLogSchemaMigrationTest}가 같은 문제를 겪었다). 이 테스트도 같은 형제 패턴 — Testcontainers + * 위에서 순수 Flyway로 V7까지 적용한 뒤, {@code JdbcClient}와 어댑터를 직접 조립 — 을 쓴다. Spring 컨테이너가 없어도 {@code + * JdbcCatalogQueryAdapter}는 생성자 인자로 {@code JdbcClient} 하나만 받으므로 문제가 없다. + */ +class JdbcCatalogQueryAdapterTest { + + private static final String IMAGE = + System.getProperty("jpa.evidence.postgresql.image", "postgres:16-alpine"); + + private static PostgreSQLContainer postgres; + private static HikariDataSource dataSource; + private static JdbcClient jdbcClient; + private static JdbcCatalogQueryAdapter adapter; + + @BeforeAll + static void migrateFreshDatabase() { + if (!DockerClientFactory.instance().isDockerAvailable()) { + throw new IllegalStateException( + "Docker is required for the catalog query adapter test; skipping is forbidden"); + } + postgres = new PostgreSQLContainer(IMAGE).withReuse(false); + postgres.start(); + + HikariConfig config = new HikariConfig(); + config.setJdbcUrl(postgres.getJdbcUrl()); + config.setUsername(postgres.getUsername()); + config.setPassword(postgres.getPassword()); + config.setMaximumPoolSize(5); + config.setMinimumIdle(1); + dataSource = new HikariDataSource(config); + + Flyway.configure() + .dataSource(dataSource) + .locations("classpath:db/migration/postgresql") + .table("flyway_schema_history") + .baselineOnMigrate(false) + .outOfOrder(false) + .load() + .migrate(); + + jdbcClient = JdbcClient.create(dataSource); + adapter = new JdbcCatalogQueryAdapter(jdbcClient); + } + + @AfterAll + static void stopPostgreSql() { + if (dataSource != null) { + dataSource.close(); + } + if (postgres != null) { + postgres.stop(); + } + } + + @Test + void findsTopicsByPrefix() { + jdbcClient + .sql( + "INSERT INTO topic (id, name, normalized_name, slug, created_by, updated_by) " + + "VALUES (gen_random_uuid(), 'Kafka', 'kafka', 'kafka', 'test', 'test')") + .update(); + + CatalogPageView page = adapter.search(CatalogEntryType.TOPIC, "kaf", null, 20); + + assertThat(page.items()).hasSize(1); + assertThat(page.items().get(0).label()).isEqualTo("Kafka"); + assertThat(page.items().get(0).dependencyRevision()).isNotBlank(); + } + + @Test + void returnsAnEmptyPageWhenNothingMatches() { + CatalogPageView page = adapter.search(CatalogEntryType.TOPIC, "zzzz-none", null, 20); + + assertThat(page.items()).isEmpty(); + assertThat(page.nextCursor()).isNull(); + } + + /** + * Review Important 1: {@code searchProjects} (JdbcCatalogQueryAdapter) had never run against a + * real database — {@code project} has a different column shape than {@code topic} (slug/name/ + * workflow_status vs. name/normalized_name/slug/status), so a column typo or bad bind would only + * have surfaced in production. {@code project}'s NOT-NULL-without-default columns are {@code id}, + * {@code name}, {@code created_by}, {@code updated_by} (V7__techlog_core.sql CREATE TABLE + * project) — everything else has a DEFAULT or is nullable, so the minimal INSERT below is valid. + */ + @Test + void findsProjectsByPrefix() { + jdbcClient + .sql( + "INSERT INTO project (id, name, created_by, updated_by) " + + "VALUES (gen_random_uuid(), 'Payments Platform', 'test', 'test')") + .update(); + + CatalogPageView page = adapter.search(CatalogEntryType.PROJECT, "pay", null, 20); + + assertThat(page.items()).hasSize(1); + assertThat(page.items().get(0).id()).isNotNull(); + assertThat(page.items().get(0).label()).isEqualTo("Payments Platform"); + assertThat(page.items().get(0).kind()).isEqualTo("PROJECT"); + assertThat(page.items().get(0).dependencyRevision()).isNotBlank(); + } +} diff --git a/src/app-bootstrap/build.gradle b/src/app-bootstrap/build.gradle index c96b1be..5d45c8b 100644 --- a/src/app-bootstrap/build.gradle +++ b/src/app-bootstrap/build.gradle @@ -15,6 +15,12 @@ sourceSets { functionalTest { java.srcDir 'src/functionalTest/java' resources.srcDir 'src/functionalTest/resources' + // feature-techlog-studio-backend Task 10 — StudioContractDriftTest reuses + // RepositoryContractResources (test-sourceSet-owned, see + // dev.caskeleton.bootstrap.contract.support) for fail-closed repo-root resolution instead + // of a hand-rolled relative Path.of(..), matching the sibling contract tests' convention. + compileClasspath += sourceSets.test.output + runtimeClasspath += sourceSets.test.output } conditionalTransportTest { java.srcDir 'src/conditionalTransportTest/java' @@ -121,6 +127,33 @@ dependencies { functionalTestImplementation 'org.junit.jupiter:junit-jupiter' functionalTestImplementation 'org.assertj:assertj-core' functionalTestRuntimeOnly 'org.junit.platform:junit-platform-launcher' + // feature-techlog-studio-backend Task 10 — StudioContractDriftTest boots a minimal Studio web + // slice (real StudioSessionController/StudioCatalogController, no persistence/messaging/cache) + // to diff springdoc's published /api/v1/studio/** surface against studio-v1.yaml. Only the two + // modules the slice actually needs — deliberately not the full app-bootstrap runtime graph, so + // no DataSource/Flyway/Redis auto-configuration is even on this classpath to exclude. + functionalTestImplementation project(':adapter:inbound:web') + functionalTestImplementation project(':application-core') + // test-only: @SpringBootTest/MockMvc/@AutoConfigureMockMvc — mirrors the root build.gradle + // subprojects{} pair every non-core module already gets on its ordinary `test` sourceSet. + functionalTestImplementation 'org.springframework.boot:spring-boot-starter-test' + functionalTestImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test' + // test-only: classic Jackson (2.x) ObjectMapper/JsonNode to read /v3/api-docs and convert the + // SnakeYaml-parsed contract into a comparable tree. adapter-inbound-web/application-core pull + // this in only as a project-dependency `implementation` (hidden from a consumer's + // compileClasspath by Gradle's api/implementation split), so it must be declared directly here + // — mirrors the existing `testImplementation 'org.springframework.boot:spring-boot-starter-json'` + // pattern below for app-bootstrap's own `test` sourceSet. + functionalTestImplementation 'com.fasterxml.jackson.core:jackson-databind' + // test-only: org.springframework.boot.security.autoconfigure.SecurityAutoConfiguration (excluded + // below) is part of spring-boot-security, only pulled in transitively by spring-boot-starter-security + // — same api/implementation-hiding reason as jackson-databind above. app-bootstrap declares this + // on `implementation` for its own main/test sourceSets, which functionalTest does not extend. + functionalTestImplementation 'org.springframework.boot:spring-boot-starter-security' + // test-only: parses config/openapi/studio-v1.yaml with the same library StudioErrorRegistryTest + // already uses for docs/registries/error-codes.yaml — avoids adding jackson-dataformat-yaml + // (present only on runtimeClasspath repo-wide, transitively via springdoc, not compileClasspath). + functionalTestImplementation 'org.yaml:snakeyaml' // Explicit qualification-only composition. These projects remain absent from main // api/implementation/compileOnly/runtimeOnly and therefore from both shipped runtime graphs. conditionalTransportTestImplementation project(':adapter:inbound:graphql') @@ -166,13 +199,22 @@ sampleOffQualification.configure { tasks.register('functionalTest', Test) { group = 'verification' - description = 'Runs isolated Gradle TestKit contracts for repository build behavior.' + description = 'Runs isolated Gradle TestKit contracts for repository build behavior, plus the ' + + 'feature-techlog-studio-backend Studio contract drift gate.' testClassesDirs = sourceSets.functionalTest.output.classesDirs classpath = sourceSets.functionalTest.runtimeClasspath useJUnitPlatform() failOnNoDiscoveredTests = true shouldRunAfter tasks.named('test') jvmArgs '-Duser.timezone=UTC' + // feature-techlog-studio-backend Task 10 — StudioContractDriftTest boots a real (minimal) + // Spring Boot context that needs Logback, on the same classpath as gradleTestKit() (whose own + // SLF4J provider — org.gradle.internal.logging.slf4j.OutputEventListenerBackedLoggerContext — + // wins classpath scanning over the real one). Spring Boot's LogbackLoggingSystem then finds + // Logback's jar present but the bound ILoggerFactory is Gradle's fake context, and fails fast + // with IllegalStateException before the context even starts. LoggingSystem=none skips Boot's + // logging bootstrap entirely — this task doesn't assert on log output, so there is nothing lost. + systemProperty 'org.springframework.boot.logging.LoggingSystem', 'none' } def conditionalTransportCompositionQualification = registerStrictQualificationTest( diff --git a/src/app-bootstrap/gradle.lockfile b/src/app-bootstrap/gradle.lockfile index a4d058c..ccddc13 100644 --- a/src/app-bootstrap/gradle.lockfile +++ b/src/app-bootstrap/gradle.lockfile @@ -2,26 +2,26 @@ # Manual edits can break the build and are not advised. # This file is expected to be part of source control. aopalliance:aopalliance:1.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=compileClasspath,sampleOffTestCompileClasspath,testCompileClasspath -ch.qos.logback:logback-classic:1.5.21=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -ch.qos.logback:logback-core:1.5.21=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=compileClasspath,functionalTestCompileClasspath,sampleOffTestCompileClasspath,testCompileClasspath +ch.qos.logback:logback-classic:1.5.21=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +ch.qos.logback:logback-core:1.5.21=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath com.approvaltests:approvaltests-util:31.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath com.approvaltests:approvaltests:31.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath com.ethlo.time:itu:1.14.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -com.fasterxml.jackson.core:jackson-annotations:2.20=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -com.fasterxml.jackson.core:jackson-core:2.20.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -com.fasterxml.jackson.core:jackson-databind:2.20.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.20.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.20.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -com.fasterxml.jackson:jackson-bom:2.20.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -com.fasterxml:classmate:1.7.1=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +com.fasterxml.jackson.core:jackson-annotations:2.20=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +com.fasterxml.jackson.core:jackson-core:2.20.1=compileClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +com.fasterxml.jackson.core:jackson-databind:2.20.1=compileClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.20.1=functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.20.1=functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +com.fasterxml.jackson:jackson-bom:2.20.1=compileClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +com.fasterxml:classmate:1.7.1=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,conditionalTransportTestAnnotationProcessor,functionalTestAnnotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor com.github.docker-java:docker-java-api:3.7.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath com.github.docker-java:docker-java-transport-zerodep:3.7.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath com.github.docker-java:docker-java-transport:3.7.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,conditionalTransportTestAnnotationProcessor,functionalTestAnnotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs -com.github.spotbugs:spotbugs-annotations:4.8.6=compileClasspath,sampleOffTestCompileClasspath,testCompileClasspath +com.github.spotbugs:spotbugs-annotations:4.8.6=compileClasspath,functionalTestCompileClasspath,sampleOffTestCompileClasspath,testCompileClasspath com.github.spotbugs:spotbugs:4.10.2=spotbugs com.github.stephenc.jcip:jcip-annotations:1.0-1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath com.google.android:annotations:4.1.1.4=conditionalTransportTestRuntimeClasspath @@ -29,10 +29,10 @@ com.google.api.grpc:proto-google-common-protos:2.41.0=conditionalTransportTestRu com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,conditionalTransportTestAnnotationProcessor,functionalTestAnnotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,conditionalTransportTestAnnotationProcessor,functionalTestAnnotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor com.google.auto:auto-common:1.2.2=annotationProcessor,conditionalTransportTestAnnotationProcessor,functionalTestAnnotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor -com.google.code.findbugs:jsr305:3.0.2=checkstyle,compileClasspath,conditionalTransportTestRuntimeClasspath,sampleOffTestCompileClasspath,spotbugs,testCompileClasspath +com.google.code.findbugs:jsr305:3.0.2=checkstyle,compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,sampleOffTestCompileClasspath,spotbugs,testCompileClasspath com.google.code.gson:gson:2.13.2=conditionalTransportTestRuntimeClasspath,spotbugs com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,conditionalTransportTestAnnotationProcessor,functionalTestAnnotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor -com.google.errorprone:error_prone_annotations:2.38.0=compileClasspath,sampleOffTestCompileClasspath,testCompileClasspath +com.google.errorprone:error_prone_annotations:2.38.0=compileClasspath,functionalTestCompileClasspath,sampleOffTestCompileClasspath,testCompileClasspath com.google.errorprone:error_prone_annotations:2.41.0=conditionalTransportTestRuntimeClasspath,spotbugs com.google.errorprone:error_prone_annotations:2.47.0=checkstyle com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,conditionalTransportTestAnnotationProcessor,functionalTestAnnotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor @@ -54,11 +54,11 @@ com.graphql-java:graphql-java:25.0=conditionalTransportTestRuntimeClasspath com.graphql-java:java-dataloader:6.0.0=conditionalTransportTestRuntimeClasspath com.h2database:h2:2.4.240=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins -com.jayway.jsonpath:json-path:2.9.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +com.jayway.jsonpath:json-path:2.9.0=functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath com.networknt:json-schema-validator:3.0.2=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath com.nimbusds:content-type:2.3=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath com.nimbusds:lang-tag:1.7=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -com.nimbusds:nimbus-jose-jwt:10.4=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +com.nimbusds:nimbus-jose-jwt:10.4=compileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath com.nimbusds:oauth2-oidc-sdk:11.26.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath com.puppycrawl.tools:checkstyle:13.5.0=checkstyle com.squareup.okhttp3:okhttp-jvm:5.2.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath @@ -71,14 +71,14 @@ com.tngtech.archunit:archunit-junit5-engine-api:1.3.0=sampleOffTestRuntimeClassp com.tngtech.archunit:archunit-junit5-engine:1.3.0=sampleOffTestRuntimeClasspath,testRuntimeClasspath com.tngtech.archunit:archunit-junit5:1.3.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath com.tngtech.archunit:archunit:1.3.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -com.vaadin.external.google:android-json:0.0.20131108.vaadin1=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +com.vaadin.external.google:android-json:0.0.20131108.vaadin1=functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath com.zaxxer:HikariCP:7.0.2=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath commons-beanutils:commons-beanutils:1.11.0=checkstyle commons-codec:commons-codec:1.19.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath commons-collections:commons-collections:3.2.2=checkstyle commons-io:commons-io:2.20.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath commons-io:commons-io:2.21.0=spotbugs -commons-logging:commons-logging:1.3.5=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +commons-logging:commons-logging:1.3.5=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath info.picocli:picocli:4.7.7=checkstyle io.github.cdimascio:dotenv-java:3.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,conditionalTransportTestAnnotationProcessor,functionalTestAnnotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor @@ -101,10 +101,10 @@ io.grpc:grpc-stub:1.68.1=conditionalTransportTestRuntimeClasspath io.grpc:grpc-util:1.68.1=conditionalTransportTestRuntimeClasspath io.lettuce:lettuce-core:6.8.1.RELEASE=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath io.micrometer:context-propagation:1.2.0=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -io.micrometer:micrometer-commons:1.16.0=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-commons:1.16.0=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath io.micrometer:micrometer-core:1.16.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath io.micrometer:micrometer-jakarta9:1.16.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -io.micrometer:micrometer-observation:1.16.0=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-observation:1.16.0=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath io.micrometer:micrometer-registry-prometheus:1.16.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath io.micrometer:micrometer-tracing-bridge-otel:1.6.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath io.micrometer:micrometer-tracing:1.6.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath @@ -147,39 +147,39 @@ io.opentelemetry:opentelemetry-sdk:1.55.0=compileClasspath,productionRuntimeClas io.perfmark:perfmark-api:0.27.0=conditionalTransportTestRuntimeClasspath io.projectreactor.netty:reactor-netty-core:1.3.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath io.projectreactor.netty:reactor-netty-http:1.3.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -io.projectreactor:reactor-core:3.8.0=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +io.projectreactor:reactor-core:3.8.0=conditionalTransportTestRuntimeClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath io.prometheus:prometheus-metrics-config:1.4.3=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath io.prometheus:prometheus-metrics-core:1.4.3=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath io.prometheus:prometheus-metrics-exposition-formats:1.4.3=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath io.prometheus:prometheus-metrics-exposition-textformats:1.4.3=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath io.prometheus:prometheus-metrics-model:1.4.3=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath io.prometheus:prometheus-metrics-tracer-common:1.4.3=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -io.swagger.core.v3:swagger-annotations-jakarta:2.2.38=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -io.swagger.core.v3:swagger-core-jakarta:2.2.38=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -io.swagger.core.v3:swagger-models-jakarta:2.2.38=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -jakarta.activation:jakarta.activation-api:2.1.4=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -jakarta.annotation:jakarta.annotation-api:3.0.0=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +io.swagger.core.v3:swagger-annotations-jakarta:2.2.38=functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +io.swagger.core.v3:swagger-core-jakarta:2.2.38=functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +io.swagger.core.v3:swagger-models-jakarta:2.2.38=functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +jakarta.activation:jakarta.activation-api:2.1.4=functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +jakarta.annotation:jakarta.annotation-api:3.0.0=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath jakarta.inject:jakarta.inject-api:2.0.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath jakarta.persistence:jakarta.persistence-api:3.2.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath jakarta.transaction:jakarta.transaction-api:2.0.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -jakarta.validation:jakarta.validation-api:3.1.1=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +jakarta.validation:jakarta.validation-api:3.1.1=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath jakarta.websocket:jakarta.websocket-api:2.2.0=sampleOffTestCompileClasspath,testCompileClasspath jakarta.ws.rs:jakarta.ws.rs-api:4.0.0=sampleOffTestCompileClasspath,testCompileClasspath -jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath javax.inject:javax.inject:1=annotationProcessor,conditionalTransportTestAnnotationProcessor,functionalTestAnnotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor jaxen:jaxen:2.0.0=spotbugs me.paulschwarz:spring-dotenv:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -net.bytebuddy:byte-buddy-agent:1.17.8=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +net.bytebuddy:byte-buddy-agent:1.17.8=functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath net.bytebuddy:byte-buddy:1.17.8=conditionalTransportTestCompileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath net.java.dev.jna:jna:5.18.1=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath net.logstash.logback:logstash-logback-encoder:8.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -net.minidev:accessors-smart:2.6.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -net.minidev:json-smart:2.6.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +net.minidev:accessors-smart:2.6.0=compileClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +net.minidev:json-smart:2.6.0=compileClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs org.antlr:antlr4-runtime:4.13.2=checkstyle,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.apache.bcel:bcel:6.12.0=spotbugs org.apache.commons:commons-compress:1.28.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.apache.commons:commons-lang3:3.20.0=checkstyle,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath +org.apache.commons:commons-lang3:3.20.0=checkstyle,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath org.apache.commons:commons-text:1.15.0=spotbugs org.apache.commons:commons-text:1.3=checkstyle org.apache.httpcomponents.client5:httpclient5:5.5.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath @@ -188,21 +188,21 @@ org.apache.httpcomponents.core5:httpcore5:5.3.6=productionRuntimeClasspath,runti org.apache.httpcomponents:httpclient:4.5.13=checkstyle org.apache.httpcomponents:httpcore:4.4.16=checkstyle org.apache.kafka:kafka-clients:4.1.1=sampleOffTestCompileClasspath,testCompileClasspath -org.apache.logging.log4j:log4j-api:2.25.2=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath +org.apache.logging.log4j:log4j-api:2.25.2=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath org.apache.logging.log4j:log4j-core:2.25.2=spotbugs -org.apache.logging.log4j:log4j-to-slf4j:2.25.2=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.logging.log4j:log4j-to-slf4j:2.25.2=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.apache.maven.doxia:doxia-core:1.12.0=checkstyle org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle -org.apache.tomcat.embed:tomcat-embed-core:11.0.14=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.apache.tomcat.embed:tomcat-embed-el:11.0.14=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.tomcat.embed:tomcat-embed-core:11.0.14=conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.tomcat.embed:tomcat-embed-el:11.0.14=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.apache.xbean:xbean-reflect:3.7=checkstyle org.apiguardian:apiguardian-api:1.1.2=conditionalTransportTestCompileClasspath,functionalTestCompileClasspath,sampleOffTestCompileClasspath,testCompileClasspath org.aspectj:aspectjweaver:1.9.25=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.assertj:assertj-core:3.27.6=conditionalTransportTestCompileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.awaitility:awaitility:4.3.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.awaitility:awaitility:4.3.0=functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.checkerframework:checker-qual:3.42.0=conditionalTransportTestRuntimeClasspath org.checkerframework:checker-qual:3.49.5=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath org.codehaus.mojo:animal-sniffer-annotations:1.24=conditionalTransportTestRuntimeClasspath @@ -224,17 +224,17 @@ org.flywaydb:flyway-database-postgresql:11.14.1=productionRuntimeClasspath,runti org.glassfish.jaxb:jaxb-core:4.0.6=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath org.glassfish.jaxb:jaxb-runtime:4.0.6=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath org.glassfish.jaxb:txw2:4.0.6=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -org.hamcrest:hamcrest:3.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.hamcrest:hamcrest:3.0=functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.hdrhistogram:HdrHistogram:2.2.2=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath org.hibernate.models:hibernate-models:1.0.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath org.hibernate.orm:hibernate-core:7.1.8.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.hibernate.validator:hibernate-validator:9.0.1.Final=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.hibernate.validator:hibernate-validator:9.0.1.Final=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.javassist:javassist:3.28.0-GA=checkstyle -org.jboss.logging:jboss-logging:3.6.1.Final=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.jboss.logging:jboss-logging:3.6.1.Final=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.jetbrains.kotlin:kotlin-stdlib:2.2.21=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath org.jetbrains:annotations:13.0=productionRuntimeClasspath,runtimeClasspath org.jetbrains:annotations:17.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,conditionalTransportTestAnnotationProcessor,conditionalTransportTestCompileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestAnnotationProcessor,functionalTestCompileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestAnnotationProcessor,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath +org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,conditionalTransportTestAnnotationProcessor,conditionalTransportTestCompileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestAnnotationProcessor,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestAnnotationProcessor,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath org.junit.jupiter:junit-jupiter-api:6.0.1=conditionalTransportTestCompileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.junit.jupiter:junit-jupiter-engine:6.0.1=conditionalTransportTestRuntimeClasspath,functionalTestRuntimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath org.junit.jupiter:junit-jupiter-params:6.0.1=conditionalTransportTestCompileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath @@ -246,35 +246,35 @@ org.junit.platform:junit-platform-testkit:6.0.1=sampleOffTestCompileClasspath,sa org.junit:junit-bom:6.0.1=conditionalTransportTestCompileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.junit:junit-bom:6.1.0=spotbugs org.latencyutils:LatencyUtils:2.0.3=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -org.mockito:mockito-core:5.20.0=mockitoAgent,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.mockito:mockito-junit-jupiter:5.20.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.objenesis:objenesis:3.3=sampleOffTestRuntimeClasspath,testRuntimeClasspath -org.openapitools:jackson-databind-nullable:0.2.6=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.mockito:mockito-core:5.20.0=functionalTestCompileClasspath,functionalTestRuntimeClasspath,mockitoAgent,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.mockito:mockito-junit-jupiter:5.20.0=functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.objenesis:objenesis:3.3=functionalTestRuntimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.openapitools:jackson-databind-nullable:0.2.6=functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath org.opentest4j:opentest4j:1.3.0=conditionalTransportTestCompileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.osgi:org.osgi.annotation.bundle:2.0.0=compileClasspath,sampleOffTestCompileClasspath,testCompileClasspath -org.osgi:org.osgi.annotation.versioning:1.1.2=compileClasspath,sampleOffTestCompileClasspath,testCompileClasspath -org.osgi:org.osgi.resource:1.0.0=compileClasspath,sampleOffTestCompileClasspath,testCompileClasspath -org.osgi:org.osgi.service.serviceloader:1.0.0=compileClasspath,sampleOffTestCompileClasspath,testCompileClasspath +org.osgi:org.osgi.annotation.bundle:2.0.0=compileClasspath,functionalTestCompileClasspath,sampleOffTestCompileClasspath,testCompileClasspath +org.osgi:org.osgi.annotation.versioning:1.1.2=compileClasspath,functionalTestCompileClasspath,sampleOffTestCompileClasspath,testCompileClasspath +org.osgi:org.osgi.resource:1.0.0=compileClasspath,functionalTestCompileClasspath,sampleOffTestCompileClasspath,testCompileClasspath +org.osgi:org.osgi.service.serviceloader:1.0.0=compileClasspath,functionalTestCompileClasspath,sampleOffTestCompileClasspath,testCompileClasspath org.ow2.asm:asm-analysis:9.10.1=spotbugs org.ow2.asm:asm-commons:9.10.1=spotbugs org.ow2.asm:asm-tree:9.10.1=spotbugs org.ow2.asm:asm-util:9.10.1=spotbugs org.ow2.asm:asm:9.10.1=spotbugs -org.ow2.asm:asm:9.7.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.ow2.asm:asm:9.7.1=compileClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.pcollections:pcollections:4.0.1=annotationProcessor,conditionalTransportTestAnnotationProcessor,functionalTestAnnotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor org.postgresql:postgresql:42.7.8=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -org.reactivestreams:reactive-streams:1.0.4=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.reactivestreams:reactive-streams:1.0.4=conditionalTransportTestRuntimeClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.reflections:reflections:0.10.2=checkstyle org.rnorth.duct-tape:duct-tape:1.0.8=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.skyscreamer:jsonassert:1.5.3=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.slf4j:jul-to-slf4j:2.0.17=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.slf4j:slf4j-api:2.0.17=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath +org.skyscreamer:jsonassert:1.5.3=functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.slf4j:jul-to-slf4j:2.0.17=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.slf4j:slf4j-api:2.0.17=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j -org.springdoc:springdoc-openapi-starter-common:3.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -org.springdoc:springdoc-openapi-starter-webmvc-api:3.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.springdoc:springdoc-openapi-starter-common:3.0.0=functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.springdoc:springdoc-openapi-starter-webmvc-api:3.0.0=functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-actuator-autoconfigure:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-actuator:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-configuration-processor:4.0.0=annotationProcessor org.springframework.boot:spring-boot-data-commons:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-data-jpa-test:4.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath @@ -283,9 +283,9 @@ org.springframework.boot:spring-boot-flyway:4.0.0=compileClasspath,productionRun org.springframework.boot:spring-boot-graphql:4.0.0=conditionalTransportTestRuntimeClasspath org.springframework.boot:spring-boot-health:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-hibernate:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-http-client:4.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-http-converter:4.0.0=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-jackson:4.0.0=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-http-client:4.0.0=functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-http-converter:4.0.0=conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-jackson:4.0.0=conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-jdbc-test:4.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-jdbc:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-jpa-test:4.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath @@ -293,73 +293,73 @@ org.springframework.boot:spring-boot-jpa:4.0.0=productionRuntimeClasspath,runtim org.springframework.boot:spring-boot-micrometer-metrics:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-micrometer-observation:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-persistence:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-restclient:4.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-resttestclient:4.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-security-oauth2-resource-server:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-security:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-servlet:4.0.0=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-restclient:4.0.0=functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-resttestclient:4.0.0=functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-security-oauth2-resource-server:4.0.0=functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-security:4.0.0=compileClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-servlet:4.0.0=conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-sql:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-actuator:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-data-jpa:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-flyway:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-graphql:4.0.0=conditionalTransportTestRuntimeClasspath -org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-jackson:4.0.0=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-jackson:4.0.0=conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-jdbc:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-json:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-logging:4.0.0=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-logging:4.0.0=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-micrometer-metrics:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-oauth2-resource-server:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-security:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-test:4.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-tomcat:4.0.0=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-validation:4.0.0=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-web:4.0.0=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-webmvc-test:4.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-webmvc:4.0.0=conditionalTransportTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-oauth2-resource-server:4.0.0=functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-security:4.0.0=compileClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-test:4.0.0=functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-tomcat:4.0.0=conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-validation:4.0.0=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-web:4.0.0=conditionalTransportTestRuntimeClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-webmvc-test:4.0.0=functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-webmvc:4.0.0=conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-websocket:4.0.0=conditionalTransportTestRuntimeClasspath -org.springframework.boot:spring-boot-starter:4.0.0=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-test:4.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-tomcat:4.0.0=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter:4.0.0=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-test:4.0.0=functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-tomcat:4.0.0=conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-transaction:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-validation:4.0.0=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-web-server:4.0.0=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-webmvc-test:4.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-webmvc:4.0.0=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-validation:4.0.0=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-web-server:4.0.0=conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-webmvc-test:4.0.0=functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-webmvc:4.0.0=conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-websocket:4.0.0=conditionalTransportTestRuntimeClasspath -org.springframework.boot:spring-boot:4.0.0=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot:4.0.0=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.cloud:spring-cloud-context:4.1.4=sampleOffTestCompileClasspath,testCompileClasspath org.springframework.data:spring-data-commons:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.data:spring-data-jpa:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.graphql:spring-graphql:2.0.0=conditionalTransportTestRuntimeClasspath org.springframework.integration:spring-integration-core:7.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.integration:spring-integration-jdbc:7.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.security:spring-security-config:7.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.security:spring-security-core:7.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.security:spring-security-crypto:7.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.security:spring-security-config:7.0.0=compileClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.security:spring-security-core:7.0.0=compileClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.security:spring-security-crypto:7.0.0=compileClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.security:spring-security-oauth2-client:7.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.security:spring-security-oauth2-core:7.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.security:spring-security-oauth2-jose:7.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -org.springframework.security:spring-security-oauth2-resource-server:7.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.springframework.security:spring-security-oauth2-core:7.0.0=compileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.security:spring-security-oauth2-jose:7.0.0=functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.springframework.security:spring-security-oauth2-resource-server:7.0.0=functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath org.springframework.security:spring-security-test:7.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.security:spring-security-web:7.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.session:spring-session-core:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -org.springframework:spring-aop:7.0.1=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.security:spring-security-web:7.0.0=compileClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.session:spring-session-core:4.0.0=functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.springframework:spring-aop:7.0.1=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework:spring-aspects:7.0.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-beans:7.0.1=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-context:7.0.1=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-core:7.0.1=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-expression:7.0.1=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-beans:7.0.1=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-context:7.0.1=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-core:7.0.1=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-expression:7.0.1=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework:spring-jdbc:7.0.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework:spring-messaging:7.0.1=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework:spring-orm:7.0.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-test:7.0.1=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-test:7.0.1=functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework:spring-tx:7.0.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-web:7.0.1=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-webflux:7.0.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -org.springframework:spring-webmvc:7.0.1=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-web:7.0.1=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-webflux:7.0.1=functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.springframework:spring-webmvc:7.0.1=conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework:spring-websocket:7.0.1=conditionalTransportTestRuntimeClasspath,sampleOffTestCompileClasspath,testCompileClasspath org.testcontainers:testcontainers-database-commons:2.0.2=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.testcontainers:testcontainers-jdbc:2.0.2=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath @@ -367,10 +367,10 @@ org.testcontainers:testcontainers-junit-jupiter:2.0.2=sampleOffTestCompileClassp org.testcontainers:testcontainers-postgresql:2.0.2=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.testcontainers:testcontainers:2.0.2=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs -org.xmlunit:xmlunit-core:2.10.4=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.yaml:snakeyaml:2.5=compileClasspath,conditionalTransportTestCompileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.xmlunit:xmlunit-core:2.10.4=functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.yaml:snakeyaml:2.5=compileClasspath,conditionalTransportTestCompileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath redis.clients.authentication:redis-authx-core:0.1.1-beta2=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -tools.jackson.core:jackson-core:3.0.2=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -tools.jackson.core:jackson-databind:3.0.2=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -tools.jackson:jackson-bom:3.0.2=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +tools.jackson.core:jackson-core:3.0.2=conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +tools.jackson.core:jackson-databind:3.0.2=conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +tools.jackson:jackson-bom:3.0.2=conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath empty=developmentOnly,testAndDevelopmentOnly diff --git a/src/app-bootstrap/src/functionalTest/java/dev/caskeleton/bootstrap/contract/StudioContractDriftTest.java b/src/app-bootstrap/src/functionalTest/java/dev/caskeleton/bootstrap/contract/StudioContractDriftTest.java new file mode 100644 index 0000000..8d4a876 --- /dev/null +++ b/src/app-bootstrap/src/functionalTest/java/dev/caskeleton/bootstrap/contract/StudioContractDriftTest.java @@ -0,0 +1,336 @@ +package dev.caskeleton.bootstrap.contract; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import dev.caskeleton.adapter.inbound.web.envelope.EnvelopeBodyAdvice; +import dev.caskeleton.adapter.inbound.web.settings.SecuritySettings; +import dev.caskeleton.adapter.inbound.web.techlog.studio.controller.StudioCatalogController; +import dev.caskeleton.adapter.inbound.web.techlog.studio.controller.StudioSessionController; +import dev.caskeleton.application.techlog.studio.port.out.CatalogQueryPort; +import dev.caskeleton.application.techlog.studio.query.CatalogEntryType; +import dev.caskeleton.application.techlog.studio.query.CatalogPageView; +import dev.caskeleton.application.techlog.studio.service.ListCatalogUseCase; +import dev.caskeleton.application.transaction.TransactionPort; +import dev.caskeleton.bootstrap.contract.support.RepositoryContractResources; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.function.Supplier; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.security.autoconfigure.SecurityAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.Import; +import org.springframework.test.web.servlet.MockMvc; +import org.yaml.snakeyaml.Yaml; + +/** + * feature-techlog-studio-backend Task 10 — the drift gate spec §5.5 calls for: springdoc's + * published {@code /v3/api-docs} is diffed against the vendored {@code + * config/openapi/studio-v1.yaml} for every {@code /api/v1/studio/**} path it actually exposes. + * Only implemented operations are checked (direction is "published ⊆ contract", never the + * reverse), so this stays green as slices 2-5 add the other 17 operations — on one condition: + * the new controllers must live somewhere under {@code dev.caskeleton.adapter.inbound.web.techlog}, + * the package {@link ContractSurface.ContractSurfaceApp} and {@link EnvelopeWrapping.EnvelopeApp} + * {@code @ComponentScan}. A controller placed there is picked up automatically, with no edit to this + * file. A controller placed outside that package tree is invisible to both minimal contexts + * — springdoc never sees it, so this gate stays green even if its path/method/operationId contradicts + * the contract — and the {@code @ComponentScan} base package below must be widened (or the new + * controller moved) before this gate can be trusted again. (An earlier draft of this class named the + * two controllers directly via {@code @Import} instead of scanning; that hardcoded list had exactly + * this blind spot — confirmed by temporarily reintroducing it and observing a controller with an + * out-of-contract mapping pass silently, see task-10-report.md.) This test also fails the moment an + * in-scan controller's method name drifts from its {@code operationId} or ships an endpoint outside + * the contract. + * + *

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

+ * + *

This repository's own tests never boot the full app under test: {@code + * FileserverRoundTripContractTest} and {@code ActuatorSecurityHttpTest} (both in this module's + * {@code src/test}) spell out why — {@code application.yml} resolves ~50 {@code ${...}} 자리표시자 + * from {@code src/.env} (datasource, OIDC issuer, Redis, messaging, ...), so a full boot drags in + * infrastructure a contract-shape test has nothing to say about. This test follows the same + * playbook: a throwaway {@code @SpringBootConfiguration} that {@code @ComponentScan}s the studio web + * package (so real production controllers like {@link StudioSessionController} and {@link + * StudioCatalogController} are picked up the same way the real app's component scan finds them — + * see the class-level "why scan, not @Import" note above), with {@link SecurityAutoConfiguration} + * excluded and MockMvc filters off ({@code addFilters = false}) — the exclude/addFilters combination + * {@code EnvelopeBodyAdviceTest} and {@code NoResourceFoundErrorHandlingTest} (adapter-inbound-web's + * own test sourceSet) already use for this class of test. + * + *

Because this functionalTest module depends only on {@code :adapter:inbound:web} and {@code + * :application-core} (not {@code :adapter:outbound:persistence-jpa}, cache, or messaging), none of + * the DataSource/Flyway/Redis auto-configuration classes are even on the classpath for {@code + * @EnableAutoConfiguration} to attempt — there is nothing to exclude for them, unlike {@code + * ActuatorSecurityHttpTest}'s explicit JPA/Flyway exclude list. + * + *

{@code @SpringBootTest} (full, unsliced {@code @EnableAutoConfiguration}) is used instead of + * {@code @WebMvcTest}: springdoc's own auto-configuration is a third-party {@code + * AutoConfiguration.imports} entry, not part of Boot's curated {@code @WebMvcTest} slice allowlist, + * so {@code /v3/api-docs} would not be exposed under a sliced test. A full, unsliced context that + * only sees two modules' worth of dependencies keeps the cost bounded to "start web MVC + springdoc" + * without paying for DB/security infrastructure. + * + *

Why two nested contexts instead of one

+ * + *

The obvious design is one shared {@code @SpringBootTest} context for both tests. That does not + * work here, and the reason is worth recording: springdoc's {@code /v3/api-docs} handler + * ({@code OpenApiWebMvcResource.openapiJson}) returns raw {@code byte[]} — it serializes the OpenAPI + * model itself and hands Spring MVC already-encoded bytes. {@link EnvelopeBodyAdvice#supports} + * returns {@code true} unconditionally (by design — it wraps every controller response in the real + * app, not just Studio's), so if it is on the classpath of *that* request it rewrites the body from + * {@code byte[]} to {@code Envelope} — but Spring MVC picks the {@code HttpMessageConverter} + * from the *original* return type before the advice runs, so {@code ByteArrayHttpMessageConverter} + * (already selected for {@code byte[]}) is then asked to write an {@code Envelope}, and + * {@code writeInternal} throws {@code ClassCastException: Envelope cannot be cast to [B}. This + * reproduced with a full stack trace during this task (see task-10-report.md) — it is a real, + * pre-existing defect in shared skeleton code ({@code EnvelopeBodyAdvice} is not Studio-owned and + * not part of this task's brief), not an artifact of this test's plumbing: any app that boots both + * springdoc and {@code EnvelopeBodyAdvice} together and serves {@code /v3/api-docs} unauthenticated + * would hit the same crash. Fixing that advice is out of scope for a contract-regression test, so + * {@link ContractSurface} boots a context *without* {@link EnvelopeBodyAdvice} (springdoc doesn't + * invoke it for anything test 1 checks anyway — introspection is pure reflection over the mapping), + * and {@link EnvelopeWrapping} boots a separate context *with* it, hitting + * {@link StudioCatalogController} instead, whose {@code CatalogPage} return type is an ordinary POJO + * that the same JSON converter handles before and after wrapping. + * + *

Why {@link ListCatalogUseCase} is real, not mocked

+ * + *

{@link StudioCatalogController}'s constructor takes the concrete (non-interface) {@code + * ListCatalogUseCase}, so there is no seam to substitute a fake at that boundary. Its own two + * constructor collaborators, {@link CatalogQueryPort} and {@link TransactionPort}, are + * interfaces (application ports), so this test wires trivial in-memory implementations of those + * instead of pulling in a real persistence adapter — springdoc never invokes a controller method to + * build {@code /v3/api-docs} (pure reflection over the mapping/return-type shape), and the envelope + * test only needs the query to return successfully, not to hold meaningful catalog data. + * + *

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

+ * + *

The brief's original template hits the endpoint through {@code TestRestTemplate} against a + * fully DB+auth-backed app; this functionalTest sourceSet has neither (confirmed: before this task + * it declared only {@code gradleTestKit()} + JUnit + AssertJ, no Spring dependency at all). Rather + * than skip the envelope assertion or force real persistence/security infrastructure into a + * contract-shape test, {@link EnvelopeWrapping} proves the same regression the brief wants — {@link + * EnvelopeBodyAdvice} still wraps {@link StudioCatalogController}'s response — against a minimal + * slice. Envelope wrapping is a {@code ResponseBodyAdvice} concern that is orthogonal to persistence + * and authentication, so stubbing those out does not weaken what the assertion proves, and no + * production security surface changes: {@code SECURITY_PUBLIC_PATHS} is untouched, and this slice + * simply never wires a {@code SecurityFilterChain} at all (same as the two adapter-inbound-web + * precedents cited above), rather than widening what unauthenticated callers may reach in the real + * app. + */ +class StudioContractDriftTest { + + @Nested + @SpringBootTest(classes = ContractSurface.ContractSurfaceApp.class) + @AutoConfigureMockMvc(addFilters = false) + class ContractSurface { + + @Autowired private MockMvc mvc; + + /** + * "구현된 것만 검사" 방향 고정: 계약(19개 operation)이 아니라 published(springdoc이 실제로 내놓는 것, 지금은 2개)를 + * 순회한다. 슬라이스 2~5가 나머지 17개를 추가해도 이 순회 방향 덕분에 이 테스트는 그대로 통과한다 — 반대로 순회했다면 미구현 operation마다 + * 매번 실패했을 것이다. + */ + @Test + void publishedStudioOperationsMatchTheContract() throws Exception { + JsonNode contract = readContract(); + JsonNode published = readPublishedApiDocs(); + + List problems = new ArrayList<>(); + JsonNode publishedPaths = published.path("paths"); + for (Map.Entry path : publishedPaths.properties()) { + if (!path.getKey().startsWith("/api/v1/studio/")) { + continue; + } + JsonNode contractPath = contract.path("paths").path(path.getKey()); + if (contractPath.isMissingNode()) { + problems.add("계약에 없는 path: " + path.getKey()); + continue; + } + for (Map.Entry method : path.getValue().properties()) { + JsonNode contractOp = contractPath.path(method.getKey()); + if (contractOp.isMissingNode()) { + problems.add("계약에 없는 method: " + method.getKey() + " " + path.getKey()); + continue; + } + String publishedId = method.getValue().path("operationId").asText(""); + String contractId = contractOp.path("operationId").asText(""); + if (!publishedId.equals(contractId)) { + problems.add( + "operationId 불일치 " + + method.getKey() + + " " + + path.getKey() + + ": published=" + + publishedId + + " contract=" + + contractId); + } + } + } + assertThat(problems).isEmpty(); + } + + /** + * SnakeYaml(이미 {@code StudioErrorRegistryTest}가 error-codes.yaml에 쓰는 라이브러리)로 읽은 뒤 {@code + * ObjectMapper#valueToTree}로 {@link JsonNode}로 옮긴다 — {@code jackson-dataformat-yaml}을 새 컴파일 + * 의존으로 끌어오지 않고 기존 라이브러리 조합만으로 브리프 템플릿과 같은 {@code JsonNode} 기반 대조 로직을 쓸 수 있다({@code + * jackson-dataformat-yaml}은 이 모듈의 {@code compileClasspath}가 아니라 {@code runtimeClasspath}에만 + * 전이적으로 있었다 — springdoc이 YAML 응답을 만들 때만 필요해서다). + */ + private static JsonNode readContract() throws Exception { + Path contractFile = + RepositoryContractResources.fromSystemProperty() + .requireTrackedFile("src/config/openapi/studio-v1.yaml"); + Map contractYaml; + try (InputStream in = Files.newInputStream(contractFile)) { + contractYaml = new Yaml().load(in); + } + return new ObjectMapper().valueToTree(contractYaml); + } + + private JsonNode readPublishedApiDocs() throws Exception { + String body = + mvc.perform(get("/v3/api-docs")) + .andExpect(status().isOk()) + .andReturn() + .getResponse() + .getContentAsString(); + return new ObjectMapper().readTree(body); + } + + /** + * {@code @Import}로 컨트롤러를 하나씩 나열하지 않고 {@code @ComponentScan}으로 studio web 패키지 전체를 스캔한다. + * 나열 방식은 슬라이스 2~5가 새 컨트롤러를 추가해도 이 파일을 고치지 않는 한 published 표면에 안 잡히는 채로 green을 유지하는 + * 함정이 있었다 — 드리프트가 "없는" 게 아니라 게이트가 "못 보는" 상태였다. 리뷰에서 이 패키지 아래에 계약 밖 매핑을 가진 임시 + * 컨트롤러를 하나 추가해(어떤 {@code @Import}/{@code @ComponentScan} 목록에도 안 넣고) 실측으로 확인했다 — 옛 {@code + * @Import} 목록으로는 이 테스트가 통과, 이 {@code @ComponentScan}으로는 실패. 재현 절차와 두 결과 모두 + * task-10-report.md의 "자동 포함 성질 RED 검증" 절에 남아 있다. {@code EnvelopeBodyAdvice}는 이 패키지 밖({@code + * ...web.envelope})이라 스캔에 안 걸린다 — 일부러 두지 않는다(클래스 javadoc "Why two nested contexts" 참조). + * springdoc은 리플렉션만 하므로 이 test1엔 애초에 관여하지 않는다. + */ + @SpringBootConfiguration + @EnableAutoConfiguration(exclude = SecurityAutoConfiguration.class) + @ComponentScan("dev.caskeleton.adapter.inbound.web.techlog") + static class ContractSurfaceApp { + + @Bean + SecuritySettings securitySettings() { + return StudioContractDriftTest.securitySettingsForTest(); + } + + @Bean + ListCatalogUseCase listCatalogUseCase() { + return StudioContractDriftTest.listCatalogUseCaseForTest(); + } + } + } + + @Nested + @SpringBootTest(classes = EnvelopeWrapping.EnvelopeApp.class) + @AutoConfigureMockMvc(addFilters = false) + class EnvelopeWrapping { + + @Autowired private MockMvc mvc; + + @Test + void everyStudioResponseIsWrappedInTheEnvelope() throws Exception { + String body = + mvc.perform(get("/api/v1/studio/catalog").param("type", "TOPIC")) + .andExpect(status().isOk()) + .andReturn() + .getResponse() + .getContentAsString(); + + assertThat(body).contains("\"success\"").contains("\"data\"").contains("\"meta\""); + assertThat(body).doesNotContain("\"data\":{\"success\""); + } + + /** + * {@code adapter-inbound-web}에 실제 애플리케이션이 없어({@code CaSkeletonApplication}은 app-bootstrap + * 소유) {@code @SpringBootTest}가 부트스트랩할 {@code @SpringBootConfiguration}이 필요하다 — {@code + * StudioSessionEnvelopeTest}(adapter-inbound-web 자체 테스트)가 쓰는 것과 같은 이유의 같은 패턴. {@link + * ContractSurface.ContractSurfaceApp}과 마찬가지로 {@code @ComponentScan}으로 studio web 패키지를 스캔하고, + * {@link EnvelopeBodyAdvice}만 별도로 {@code @Import}한다(스캔 범위 밖 패키지라서) — 이 컨텍스트는 {@code + * /v3/api-docs}를 두드리지 않으므로 클래스 javadoc이 설명하는 {@code byte[]} 크래시를 겪지 않는다. + */ + @SpringBootConfiguration + @EnableAutoConfiguration(exclude = SecurityAutoConfiguration.class) + @ComponentScan("dev.caskeleton.adapter.inbound.web.techlog") + @Import(EnvelopeBodyAdvice.class) + static class EnvelopeApp { + + @Bean + SecuritySettings securitySettings() { + return StudioContractDriftTest.securitySettingsForTest(); + } + + @Bean + ListCatalogUseCase listCatalogUseCase() { + return StudioContractDriftTest.listCatalogUseCaseForTest(); + } + } + } + + /** {@code csrfHeaderName}이 계약 const {@code X-CSRF-TOKEN}과 다르면 {@link StudioSessionController}의 생성자가 즉시 실패한다. */ + private static SecuritySettings securitySettingsForTest() { + SecuritySettings.SessionCookieSettings session = + new SecuritySettings.SessionCookieSettings( + null, null, null, null, null, null, "X-CSRF-TOKEN"); + return new SecuritySettings( + SecuritySettings.AuthenticationMode.JWT, "https://issuer.example", null, List.of(), session); + } + + /** + * 실제 영속성 어댑터 대신 최소 stub 포트로 구성한 진짜 {@link ListCatalogUseCase}. springdoc은 컨트롤러 메서드를 호출하지 않고 + * 리플렉션만 하므로 첫 번째 테스트에는 아예 관여하지 않고, 두 번째 테스트(봉투 확인)는 결과 내용이 아니라 감싸는 모양만 보므로 빈 목록으로 충분하다. + */ + private static ListCatalogUseCase listCatalogUseCaseForTest() { + return new ListCatalogUseCase(new StubCatalogQueryPort(), new PassThroughTransactionPort()); + } + + private static final class StubCatalogQueryPort implements CatalogQueryPort { + @Override + public CatalogPageView search(CatalogEntryType type, String query, String cursor, int limit) { + return new CatalogPageView(List.of(), null); + } + } + + /** 실제 트랜잭션 관리자 없이 액션을 곧장 실행한다 — 이 슬라이스에는 커밋/롤백할 트랜잭션 리소스가 없다. */ + private static final class PassThroughTransactionPort implements TransactionPort { + @Override + public T inWrite(Supplier action) { + return action.get(); + } + + @Override + public T inRootWrite(Supplier action) { + return action.get(); + } + + @Override + public T inRead(Supplier action) { + return action.get(); + } + + @Override + public T inNew(Supplier action) { + return action.get(); + } + } +} diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/techlog/TechLogStudioConfig.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/techlog/TechLogStudioConfig.java new file mode 100644 index 0000000..6256934 --- /dev/null +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/techlog/TechLogStudioConfig.java @@ -0,0 +1,18 @@ +package dev.caskeleton.bootstrap.techlog; + +import dev.caskeleton.application.techlog.studio.port.out.CatalogQueryPort; +import dev.caskeleton.application.techlog.studio.service.ListCatalogUseCase; +import dev.caskeleton.application.transaction.TransactionPort; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** Tech Log Studio 조립. application-core는 Spring을 보지 않으므로 여기서 배선한다. */ +@Configuration +public class TechLogStudioConfig { + + @Bean + ListCatalogUseCase listCatalogUseCase( + CatalogQueryPort catalogQueryPort, TransactionPort transactionPort) { + return new ListCatalogUseCase(catalogQueryPort, transactionPort); + } +} diff --git a/src/app-bootstrap/src/main/resources/application-dev.yml b/src/app-bootstrap/src/main/resources/application-dev.yml index 9545a3b..b14cd2a 100644 --- a/src/app-bootstrap/src/main/resources/application-dev.yml +++ b/src/app-bootstrap/src/main/resources/application-dev.yml @@ -6,9 +6,11 @@ # a value this file can know. What belongs here is the shape dev must have whatever the operator # sets: the vendor and the schema owner. # -# Both keys below restate the repository default rather than change it, so adding this file moves -# no behaviour. That is the point — the moment dev and prod diverge from local, the difference has -# a declared home instead of being implied by whatever the environment happened to inject. +# The flyway/persistence keys below restate the repository default rather than change it, so they +# move no behaviour on their own. That is the point — the moment dev and prod diverge from local, +# the difference has a declared home instead of being implied by whatever the environment happened +# to inject. The security.session key further down is the one exception: it genuinely overrides the +# template default for Tech Log Studio's contract. See the comment there for why. # ============================================================================= spring: @@ -20,3 +22,18 @@ spring: ca-skeleton: persistence: vendor: postgresql + security: + # Studio's contract (studio-v1.yaml StudioSession.csrfHeaderName) fixes this header name as a + # `const`. The template default is X-XSRF-TOKEN; Studio needs X-CSRF-TOKEN to match. + # + # auth-mode intentionally stays at the repository default (jwt) here. Task 8's brief proposed + # switching it to redis-session, but src/app-bootstrap's AuthenticationModeCompositionConfig + # requires a complete Redis session repository/filter pair (`redisVersionedSessionRepository`, + # `springSessionRepositoryFilter`) once that mode is active, and neither bean exists in this + # branch yet — the Redis session infrastructure is out of this task's scope (its design package + # was removed from the working tree ahead of this task; see AGENTS.md / task-8 report). Setting + # auth-mode: redis-session here would make a real `--spring.profiles.active=dev` boot fail the + # composition validator with "Redis Session repository/filter is incomplete". This csrf-header- + # name override is independent of auth-mode and safe on its own. + session: + csrf-header-name: X-CSRF-TOKEN diff --git a/src/app-bootstrap/src/main/resources/application-local.yml b/src/app-bootstrap/src/main/resources/application-local.yml index 6bce536..07818f2 100644 --- a/src/app-bootstrap/src/main/resources/application-local.yml +++ b/src/app-bootstrap/src/main/resources/application-local.yml @@ -143,6 +143,15 @@ ca-skeleton: issuer-uri: http://localhost:8081/realms/ca-skeleton audience: ca-skeleton-api public-paths: /api/healthcheck + # Studio's contract (studio-v1.yaml StudioSession.csrfHeaderName) fixes this header name as a + # `const`. The template default is X-XSRF-TOKEN (application.yml:498, restated verbatim by + # src/.env:125, the profile src/.env:8 activates) — StudioSessionController's constructor + # rejects any other value with IllegalStateException, and because that controller is an + # unconditional @RestController bean, the failure is a BeanCreationException that fails context + # refresh and kills the whole process, not just Studio. See application-dev.yml's identical + # override for the full rationale. + session: + csrf-header-name: X-CSRF-TOKEN cors: enabled: true allowed-origins: http://localhost:3000 diff --git a/src/app-bootstrap/src/main/resources/application-prod.yml b/src/app-bootstrap/src/main/resources/application-prod.yml index 0be1044..efa917e 100644 --- a/src/app-bootstrap/src/main/resources/application-prod.yml +++ b/src/app-bootstrap/src/main/resources/application-prod.yml @@ -26,3 +26,13 @@ spring: ca-skeleton: persistence: vendor: postgresql + security: + # Studio's contract (studio-v1.yaml StudioSession.csrfHeaderName) fixes this header name as a + # `const`. The template default is X-XSRF-TOKEN; Studio needs X-CSRF-TOKEN to match. + # StudioSessionController's constructor rejects any other configured value with + # IllegalStateException, and because that controller is an unconditional @RestController bean, + # the failure is a BeanCreationException that fails context refresh and kills the whole + # process on this profile, not just Studio. See application-dev.yml's identical override for + # the full rationale. + session: + csrf-header-name: X-CSRF-TOKEN diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/StudioErrorRegistryTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/StudioErrorRegistryTest.java new file mode 100644 index 0000000..08b8e6a --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/StudioErrorRegistryTest.java @@ -0,0 +1,208 @@ +package dev.caskeleton.bootstrap.architecture; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.inbound.web.techlog.StudioClientSafeMessages; +import dev.caskeleton.application.techlog.error.StudioError; +import dev.caskeleton.bootstrap.contract.support.RepositoryContractResources; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.yaml.snakeyaml.Yaml; + +/** + * feature-techlog-studio-backend — pins {@link StudioError} against {@code + * docs/registries/error-codes.yaml} (the SSOT the contract and log tooling read) on three axes: + * + *

    + *
  1. every {@link StudioError} constant has a matching registry row (code presence); + *
  2. that row's {@code category}/{@code http_status}/{@code retryable} match the enum's + * declared values exactly — a standing drift gate. Nothing else in the suite checks this for + * {@link StudioError}: {@code ErrorCodeRegistryMappingTest} (a different branch's template) + * only walks {@code OperationalError}. Its absence let the {@code VALIDATION_FAILED} ↔ + * {@code OperationalError.VALIDATION_FAILED} code-name collision ship once already (see + * task-5-report.md Fix Round 1) — this closes that gap for good; + *
  3. {@link StudioClientSafeMessages#forError(StudioError)}'s text matches the row's {@code + * client_safe_message} exactly — the single source of truth for {@code error.message} is the + * registry, and code drifting from it must fail here rather than silently changing what + * clients see. + *
+ * + *

Uses {@link RepositoryContractResources} (the same repository-root resolver the sibling + * registry contract tests in {@code dev.caskeleton.bootstrap.contract} use) rather than a + * hand-rolled relative {@code Path.of("..", "..", ...)}, since Gradle's test working directory is + * not guaranteed to be the module directory the brief's naive relative path assumed. Parses the + * registry with SnakeYaml — the same library/pattern {@code ErrorCodeRegistryMappingTest} and + * {@code RunbookCoverageContractTest} already use in this suite — rather than line-scanning, since + * this test needs structured field access (category/http_status/retryable/client_safe_message), + * not just the {@code code:} key. + */ +class StudioErrorRegistryTest { + + private static Map> registryRowsByCode; + + @BeforeAll + @SuppressWarnings("unchecked") + static void loadRegistry() throws Exception { + Path registry = + RepositoryContractResources.fromSystemProperty() + .requireTrackedFile("docs/registries/error-codes.yaml"); + registryRowsByCode = new LinkedHashMap<>(); + try (InputStream in = Files.newInputStream(registry)) { + Map root = new Yaml().load(in); + List> errors = (List>) root.get("errors"); + for (Map row : errors) { + registryRowsByCode.put((String) row.get("code"), row); + } + } + } + + @Test + void everyStudioErrorHasARegistryRow() { + Set declared = + Arrays.stream(StudioError.values()).map(StudioError::code).collect(Collectors.toSet()); + + assertThat(registryRowsByCode.keySet()).containsAll(declared); + } + + /** + * The value-drift gate: every {@link StudioError}'s registry row must carry the exact same + * category/http_status/retryable the enum declares. {@code PAYLOAD_TOO_LARGE}/{@code + * UNSUPPORTED_MEDIA_TYPE} reuse a pre-existing {@code feature-api-contract-baseline} row instead + * of a Studio-owned one (task-5-report.md §5) — this still holds them to the same standard. + */ + @Test + void everyStudioErrorRowMatchesCategoryHttpStatusAndRetryable() { + for (StudioError error : StudioError.values()) { + Map row = registryRowsByCode.get(error.code()); + assertThat(row).as("registry row for %s", error.code()).isNotNull(); + + assertThat(row.get("category")) + .as("category for %s", error.code()) + .isEqualTo(error.category().name()); + assertThat(((Number) row.get("http_status")).intValue()) + .as("http_status for %s", error.code()) + .isEqualTo(error.httpStatus()); + assertThat(row.get("retryable")) + .as("retryable for %s", error.code()) + .isEqualTo(error.retryable()); + } + } + + /** + * {@code StudioClientSafeMessages} must never drift from the registry's {@code + * client_safe_message} — that column is the single source of truth for what {@code + * error.message} clients see (task-5-report.md Important 1). + */ + @Test + void everyStudioErrorClientSafeMessageMatchesRegistry() { + for (StudioError error : StudioError.values()) { + Map row = registryRowsByCode.get(error.code()); + assertThat(row).as("registry row for %s", error.code()).isNotNull(); + + assertThat(StudioClientSafeMessages.forError(error)) + .as("client_safe_message for %s", error.code()) + .isEqualTo(row.get("client_safe_message")); + } + } + + /** + * final whole-branch review B3: {@code StudioErrorTest.declaresExactlyTheTwentyThreeContractCodes} + * only counts ({@code hasSize(23)}) — it never reads the contract, so a rename or a 1:1 code + * substitution on either side (enum or {@code studio-v1.yaml}) leaves the count at 23 and passes. + * This is the gate that reads {@code src/config/openapi/studio-v1.yaml}'s {@code + * components.schemas.ApiError.properties.code.enum} and requires the two sets to be identical in + * both directions — a code present only in the contract, or only in the enum, fails here. This + * drift already happened once for real (Task 5's vendor copy carrying stale names) and a human + * caught it, not a gate; this closes that gap. + */ + @Test + void enumMatchesContractCodeSetExactly() throws Exception { + Path contract = + RepositoryContractResources.fromSystemProperty() + .requireTrackedFile("src/config/openapi/studio-v1.yaml"); + Set contractCodes = contractApiErrorCodes(contract); + + Set enumCodes = + Arrays.stream(StudioError.values()).map(StudioError::code).collect(Collectors.toSet()); + + assertThat(enumCodes) + .as("StudioError enum vs studio-v1.yaml ApiError.code enum") + .containsExactlyInAnyOrderElementsOf(contractCodes); + } + + @SuppressWarnings("unchecked") + private static Set contractApiErrorCodes(Path contract) throws IOException { + try (InputStream in = Files.newInputStream(contract)) { + Map root = new Yaml().load(in); + Map components = (Map) root.get("components"); + Map schemas = (Map) components.get("schemas"); + Map apiError = (Map) schemas.get("ApiError"); + Map properties = (Map) apiError.get("properties"); + Map code = (Map) properties.get("code"); + List enumValues = (List) code.get("enum"); + return Set.copyOf(enumValues); + } + } + + /** + * final whole-branch review B3: {@code src/config/openapi/studio-v1.yaml} is a vendored copy of + * the design package's contract (MANIFEST.sha256's {@code # source:} line records where from) and + * had zero consumers before this test — nothing detected a local edit to the vendor copy drifting + * from the hash the manifest recorded at vendoring time. This makes {@code MANIFEST.sha256} an + * actual tamper/drift gate rather than a file nobody reads. + */ + @Test + void vendoredContractMatchesTheRecordedManifestHash() throws Exception { + RepositoryContractResources resources = RepositoryContractResources.fromSystemProperty(); + Path contract = resources.requireTrackedFile("src/config/openapi/studio-v1.yaml"); + Path manifest = resources.requireTrackedFile("src/config/openapi/MANIFEST.sha256"); + + String recordedHash = recordedSha256(manifest, "studio-v1.yaml"); + String actualHash = sha256Hex(contract); + + assertThat(actualHash) + .as( + "src/config/openapi/studio-v1.yaml sha256 must match the value MANIFEST.sha256 recorded" + + " for it (local edit or vendoring drift)") + .isEqualTo(recordedHash); + } + + /** + * Parses lines shaped {@code }, skipping {@code #}-prefixed comment + * lines such as MANIFEST.sha256's {@code # source: ...} provenance line. + */ + private static String recordedSha256(Path manifest, String filename) throws IOException { + return Files.readAllLines(manifest).stream() + .map(String::strip) + .filter(line -> !line.isEmpty() && !line.startsWith("#")) + .filter(line -> line.endsWith(filename)) + .map(line -> line.substring(0, line.indexOf(' ')).strip()) + .findFirst() + .orElseThrow( + () -> + new IllegalStateException( + "MANIFEST.sha256 has no hash row for " + filename + ": " + manifest)); + } + + private static String sha256Hex(Path file) throws IOException, NoSuchAlgorithmException { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest(Files.readAllBytes(file)); + StringBuilder hex = new StringBuilder(hash.length * 2); + for (byte b : hash) { + hex.append(String.format("%02x", b)); + } + return hex.toString(); + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/TechLogBoundaryArchTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/TechLogBoundaryArchTest.java new file mode 100644 index 0000000..4821656 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/TechLogBoundaryArchTest.java @@ -0,0 +1,141 @@ +package dev.caskeleton.bootstrap.architecture; + +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses; + +import com.tngtech.archunit.junit.AnalyzeClasses; +import com.tngtech.archunit.junit.ArchTest; +import com.tngtech.archunit.lang.ArchRule; + +/** + * 설계 08장의 bounded context 경계를 빌드로 강제한다. Gradle leaf를 늘리지 않고 + * 패키지로 나눴으므로(spec D1/D2) 경계는 이 규칙이 유일한 방어선이다. + */ +@AnalyzeClasses(packages = "dev.caskeleton", importOptions = ProductionClassImportOption.class) +class TechLogBoundaryArchTest { + + // 새 techlog context를 추가할 때 손봐야 할 지점 (fix round 1에서 asset/publication 규칙이 + // 계획에서 통째로 빠졌던 것이 바로 이 체크리스트를 세워두지 않아서였다 — spec §4.3이 규칙 + // 개수·내용의 원본이고, 이 클래스는 그것의 실행 가능한 사본일 뿐이다): + // (a) 그 context 전용 형제 비의존 규칙(XXX_DOES_NOT_DEPEND_ON_SIBLING_CONTEXTS)을 새로 + // 추가한다. + // (b) 기존 형제 규칙들(CONTENT/INQUIRY/PROJECT/ASSET_DOES_NOT_DEPEND_ON_SIBLING_CONTEXTS)의 + // dependOnClassesThat().resideInAnyPackage(...) 금지 목록에 새 context를 추가한다. + // (c) STUDIO_FACADE_ONLY_TOUCHES_INBOUND_PORTS의 금지 목록(service/port.out)에 새 + // context의 service·port.out 패키지를 추가한다. + // (d) NO_CONTEXT_DEPENDS_ON_STUDIO_FACADE의 대상 목록(that().resideInAnyPackage(...))에 + // 새 context를 추가한다. + // 새 context가 도메인 엔터티를 갖고 다른 context가 그것을 직접 변조하면 안 되는 경우 + // (publication과 같은 성격) NO_CONTEXT_DEPENDS_ON_PUBLICATION_DOMAIN과 같은 모양의 + // 전용 규칙도 검토한다. + + @ArchTest + static final ArchRule CONTENT_DOES_NOT_DEPEND_ON_SIBLING_CONTEXTS = + noClasses() + .that() + .resideInAPackage("..techlog.content..") + .should() + .dependOnClassesThat() + .resideInAnyPackage("..techlog.inquiry..", "..techlog.project..", "..techlog.asset..") + .as("techlog.content는 형제 context에 의존하지 않는다") + .allowEmptyShould(true); + + @ArchTest + static final ArchRule INQUIRY_DOES_NOT_DEPEND_ON_SIBLING_CONTEXTS = + noClasses() + .that() + .resideInAPackage("..techlog.inquiry..") + .should() + .dependOnClassesThat() + .resideInAnyPackage("..techlog.content..", "..techlog.project..", "..techlog.asset..") + .as("techlog.inquiry는 형제 context에 의존하지 않는다") + .allowEmptyShould(true); + + @ArchTest + static final ArchRule PROJECT_DOES_NOT_DEPEND_ON_SIBLING_CONTEXTS = + noClasses() + .that() + .resideInAPackage("..techlog.project..") + .should() + .dependOnClassesThat() + .resideInAnyPackage("..techlog.content..", "..techlog.inquiry..", "..techlog.asset..") + .as("techlog.project는 형제 context에 의존하지 않는다") + .allowEmptyShould(true); + + @ArchTest + static final ArchRule ASSET_DOES_NOT_DEPEND_ON_SIBLING_CONTEXTS = + noClasses() + .that() + .resideInAPackage("..techlog.asset..") + .should() + .dependOnClassesThat() + .resideInAnyPackage("..techlog.content..", "..techlog.inquiry..", "..techlog.project..") + .as( + "spec §4.3 규칙1 ASSET_DOES_NOT_DEPEND_ON_SIBLING_CONTEXTS: techlog.asset는 " + + "형제 context(content/inquiry/project)에 의존하지 않는다 — fix round 1: " + + "content/inquiry/project 세 방향은 이미 asset을 형제 금지 목록에 넣어 막고 " + + "있었지만 asset 자신이 형제를 참조하는 반대 방향이 계획에서 빠져 있었다") + .allowEmptyShould(true); + + @ArchTest + static final ArchRule STUDIO_FACADE_ONLY_TOUCHES_INBOUND_PORTS = + noClasses() + .that() + .resideInAPackage("..application.techlog.studio..") + .should() + .dependOnClassesThat() + .resideInAnyPackage( + "..application.techlog.content.service..", + "..application.techlog.content.port.out..", + "..application.techlog.inquiry.service..", + "..application.techlog.inquiry.port.out..", + "..application.techlog.project.service..", + "..application.techlog.project.port.out..", + "..application.techlog.asset.service..", + "..application.techlog.asset.port.out..", + "..application.techlog.publication.service..", + "..application.techlog.publication.port.out..", + "..domain.techlog..") + .as("studio facade는 타 context의 port.in만 호출한다 (domain·service·port.out 직접 접근 금지)") + .allowEmptyShould(true); + + @ArchTest + static final ArchRule NO_CONTEXT_DEPENDS_ON_STUDIO_FACADE = + noClasses() + .that() + .resideInAnyPackage( + "..techlog.content..", + "..techlog.inquiry..", + "..techlog.project..", + "..techlog.asset..", + "..techlog.publication..") + .should() + .dependOnClassesThat() + .resideInAPackage("..application.techlog.studio..") + .as("도메인 context는 studio facade에 역방향 의존하지 않는다") + .allowEmptyShould(true); + + @ArchTest + static final ArchRule NO_CONTEXT_DEPENDS_ON_PUBLICATION_DOMAIN = + noClasses() + .that() + .resideInAnyPackage( + "..domain.techlog.content..", + "..domain.techlog.inquiry..", + "..domain.techlog.project..", + "..domain.techlog.asset..", + "..domain.techlog.identity..") + .should() + .dependOnClassesThat() + .resideInAPackage("..domain.techlog.publication..") + .as( + "spec §4.3 규칙4 NO_CONTEXT_DEPENDS_ON_PUBLICATION_DOMAIN: " + + "domain.techlog.publication을 제외한 어떤 domain 패키지도 Publication을 " + + "직접 변경하지 않는다. ArchUnit은 '직접 변경'이라는 동작을 정적으로 표현할 " + + "수 없으므로, 타 domain context가 publication 도메인 패키지에 의존하는 것 " + + "자체를 금지하는 보수적 근사로 대신한다 — 위 형제 규칙들(techlog.content/" + + "inquiry/project/asset)도 전면 금지이므로 일관된 강도다. PublicationStatus " + + "같은 타입을 타 context가 읽어야 하는 정당한 필요가 생기면 그 타입을 공유 " + + "위치로 옮기거나 port로 노출하는 것이 옳은 해법이지 이 경계를 뚫는 것이 " + + "아니다") + .allowEmptyShould(true); +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/StudioSessionCsrfHeaderProfileContractTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/StudioSessionCsrfHeaderProfileContractTest.java new file mode 100644 index 0000000..e2d241c --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/StudioSessionCsrfHeaderProfileContractTest.java @@ -0,0 +1,96 @@ +package dev.caskeleton.bootstrap.contract; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Map; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.yaml.snakeyaml.LoaderOptions; +import org.yaml.snakeyaml.Yaml; +import org.yaml.snakeyaml.constructor.SafeConstructor; + +/** + * final whole-branch review B1: pins {@code ca-skeleton.security.session.csrf-header-name} to the + * contract {@code const} ({@code studio-v1.yaml StudioSession.csrfHeaderName}, config/openapi/ + * studio-v1.yaml:832) for every profile that can actually boot the process — {@code local}, {@code + * dev}, {@code prod}. + * + *

{@code StudioSessionController}'s constructor throws {@code IllegalStateException} when the + * configured header name disagrees with the contract const. That controller is an unconditional + * {@code @RestController} bean, so the constructor failure becomes a {@code BeanCreationException} + * during context refresh — the process never starts, taking healthcheck/actuator/fileserver down + * with it. Only {@code application-dev.yml} declared the override before this fix; + * {@code application-local.yml} (the profile {@code src/.env:8} actually activates) and + * {@code application-prod.yml} both resolved to the template default {@code X-XSRF-TOKEN} + * (application.yml:498's inline default, restated verbatim by {@code src/.env:125}), so both + * profiles could not boot. + * + *

File-assertion contract test rather than a booted context, for the same reason {@link + * ProfileSeparationContractTest} gives: booting the real composition root is not possible in this + * source set (see that class's javadoc). This test follows its pattern and location. + */ +class StudioSessionCsrfHeaderProfileContractTest { + + private static final Path REPOSITORY_ROOT = repositoryRoot(); + + /** studio-v1.yaml {@code StudioSession.csrfHeaderName} — config/openapi/studio-v1.yaml:832. */ + private static final String CONTRACT_CSRF_HEADER_NAME = "X-CSRF-TOKEN"; + + @ParameterizedTest + @ValueSource(strings = {"local", "dev", "prod"}) + void everyBootableProfileResolvesTheContractCsrfHeaderName(String profile) throws IOException { + assertThat(csrfHeaderNameOf(profile(profile))) + .as( + "application-%s.yml must set ca-skeleton.security.session.csrf-header-name to \"%s\"" + + " (studio-v1.yaml StudioSession.csrfHeaderName const) — otherwise" + + " StudioSessionController's constructor throws IllegalStateException and the" + + " whole process fails to boot on this profile", + profile, CONTRACT_CSRF_HEADER_NAME) + .isEqualTo(CONTRACT_CSRF_HEADER_NAME); + } + + private static String csrfHeaderNameOf(Map configuration) { + Map session = + child(child(child(configuration, "ca-skeleton"), "security"), "session"); + Object value = session == null ? null : session.get("csrf-header-name"); + return value == null ? null : value.toString(); + } + + private static Map child(Map owner, String key) { + if (owner == null) { + return null; + } + Object value = owner.get(key); + return value instanceof Map map ? map : null; + } + + private static Map profile(String profile) throws IOException { + return yaml("application-" + profile + ".yml"); + } + + private static Map yaml(String resource) throws IOException { + String source = + Files.readString( + REPOSITORY_ROOT.resolve("src/app-bootstrap/src/main/resources").resolve(resource)); + LoaderOptions options = new LoaderOptions(); + options.setAllowDuplicateKeys(false); + Object loaded = new Yaml(new SafeConstructor(options)).load(source); + assertThat(loaded).as("%s must parse as a YAML mapping", resource).isInstanceOf(Map.class); + return (Map) loaded; + } + + private static Path repositoryRoot() { + for (Path path = Paths.get("").toAbsolutePath(); path != null; path = path.getParent()) { + if (Files.isRegularFile(path.resolve("AGENTS.md")) + && Files.isRegularFile(path.resolve("src/settings.gradle"))) { + return path; + } + } + throw new IllegalStateException( + "repository root not found from " + Paths.get("").toAbsolutePath()); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/error/StudioError.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/error/StudioError.java new file mode 100644 index 0000000..d6f23cd --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/error/StudioError.java @@ -0,0 +1,64 @@ +package dev.caskeleton.application.techlog.error; + +import dev.caskeleton.shared.error.ApiErrorCode; +import dev.caskeleton.shared.error.Category; + +/** + * Studio 계약(`studio-v1.yaml`)의 `ApiError.code` enum 23종. 계약과 1:1이며 여기서 코드를 늘리거나 줄이면 계약과 + * `docs/registries/error-codes.yaml`을 함께 고쳐야 한다. + */ +public enum StudioError implements ApiErrorCode { + AUTHENTICATION_REQUIRED(Category.AUTH, 401, false), + STUDIO_ACCESS_DENIED(Category.AUTHZ, 403, false), + DOCUMENT_NOT_FOUND(Category.NOT_FOUND, 404, false), + VERSION_CONFLICT(Category.CONFLICT, 409, false), + REQUEST_VALIDATION_FAILED(Category.VALIDATION, 422, false), + DOCUMENT_VALIDATION_FAILED(Category.VALIDATION, 422, false), + VALIDATION_STALE(Category.CONFLICT, 409, false), + PREVIEW_NOT_FOUND(Category.NOT_FOUND, 404, false), + PREVIEW_STALE(Category.CONFLICT, 409, false), + PREVIEW_EXPIRED(Category.CONFLICT, 409, false), + PUBLICATION_NOT_FOUND(Category.NOT_FOUND, 404, false), + PUBLICATION_CONFLICT(Category.CONFLICT, 409, false), + PUBLICATION_EVENT_NOT_FOUND(Category.NOT_FOUND, 404, false), + PUBLICATION_SNAPSHOT_NOT_FOUND(Category.NOT_FOUND, 404, false), + WARNING_ACKNOWLEDGEMENT_REQUIRED(Category.VALIDATION, 422, false), + IDEMPOTENCY_KEY_REUSED(Category.CONFLICT, 409, false), + ASSET_NOT_FOUND(Category.NOT_FOUND, 404, false), + ASSET_NOT_READY(Category.CONFLICT, 409, false), + ASSET_IN_USE(Category.CONFLICT, 409, false), + ASSET_QUARANTINED(Category.DATA_INTEGRITY, 409, false), + PAYLOAD_TOO_LARGE(Category.VALIDATION, 413, false), + UNSUPPORTED_MEDIA_TYPE(Category.VALIDATION, 415, false), + STUDIO_UNAVAILABLE(Category.TRANSIENT_DEPENDENCY, 503, true); + + private final Category category; + private final int httpStatus; + private final boolean retryable; + + StudioError(Category category, int httpStatus, boolean retryable) { + this.category = category; + this.httpStatus = httpStatus; + this.retryable = retryable; + } + + @Override + public String code() { + return name(); + } + + @Override + public Category category() { + return category; + } + + @Override + public int httpStatus() { + return httpStatus; + } + + @Override + public boolean retryable() { + return retryable; + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/error/StudioException.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/error/StudioException.java new file mode 100644 index 0000000..4e81253 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/error/StudioException.java @@ -0,0 +1,44 @@ +package dev.caskeleton.application.techlog.error; + +import dev.caskeleton.shared.error.ApiErrorCarrier; +import dev.caskeleton.shared.error.ApiErrorCode; + +/** + * Studio use case와 facade가 던지는 유일한 실패 표현. 전송 계층은 {@link ApiErrorCarrier}만 보고 봉투로 옮기므로 application이 + * HTTP를 알 필요가 없다. + * + *

{@code details}는 계약의 {@code ApiError.details}에 그대로 실린다 — {@code VERSION_CONFLICT}면 최신 문서, + * {@code PUBLICATION_CONFLICT}면 최신 Publication. + */ +public final class StudioException extends RuntimeException implements ApiErrorCarrier { + + private final transient StudioError error; + private final transient Object details; + + private StudioException(StudioError error, String message, Object details) { + super(message); + this.error = error; + this.details = details; + } + + public static StudioException of(StudioError error, String message) { + return new StudioException(error, message, null); + } + + public static StudioException withDetails(StudioError error, String message, Object details) { + return new StudioException(error, message, details); + } + + @Override + public ApiErrorCode errorCode() { + return error; + } + + public StudioError studioError() { + return error; + } + + public Object details() { + return details; + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/port/out/CatalogQueryPort.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/port/out/CatalogQueryPort.java new file mode 100644 index 0000000..a64f5a4 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/port/out/CatalogQueryPort.java @@ -0,0 +1,11 @@ +package dev.caskeleton.application.techlog.studio.port.out; + +import dev.caskeleton.application.techlog.studio.query.CatalogEntryType; +import dev.caskeleton.application.techlog.studio.query.CatalogPageView; + +/** Studio catalog는 도메인 Aggregate를 재구성하지 않는다. 전용 read 포트로 union query를 돌린다 (설계 08장 §4). */ +@FunctionalInterface +public interface CatalogQueryPort { + + CatalogPageView search(CatalogEntryType type, String query, String cursor, int limit); +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/query/CatalogEntryType.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/query/CatalogEntryType.java new file mode 100644 index 0000000..e012f42 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/query/CatalogEntryType.java @@ -0,0 +1,9 @@ +package dev.caskeleton.application.techlog.studio.query; + +/** 계약 `CatalogEntryType`과 1:1. API enum이 그대로 application 용어다. */ +public enum CatalogEntryType { + TOPIC, + PROJECT, + RELATION, + EVIDENCE +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/query/CatalogEntryView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/query/CatalogEntryView.java new file mode 100644 index 0000000..ef48683 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/query/CatalogEntryView.java @@ -0,0 +1,14 @@ +package dev.caskeleton.application.techlog.studio.query; + +import java.util.UUID; + +/** + * 계약 `CatalogEntry`의 application 표현. {@code kind}와 {@code publicPath}는 TOPIC/PROJECT에는 없으므로 null이다. + */ +public record CatalogEntryView( + UUID id, + CatalogEntryType type, + String label, + String kind, + String publicPath, + String dependencyRevision) {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/query/CatalogPageView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/query/CatalogPageView.java new file mode 100644 index 0000000..04f039f --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/query/CatalogPageView.java @@ -0,0 +1,10 @@ +package dev.caskeleton.application.techlog.studio.query; + +import java.util.List; + +public record CatalogPageView(List items, String nextCursor) { + + public CatalogPageView { + items = List.copyOf(items); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/query/ListCatalogQuery.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/query/ListCatalogQuery.java new file mode 100644 index 0000000..53147ac --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/query/ListCatalogQuery.java @@ -0,0 +1,6 @@ +package dev.caskeleton.application.techlog.studio.query; + +import dev.caskeleton.application.query.Query; + +public record ListCatalogQuery(CatalogEntryType type, String query, String cursor, int limit) + implements Query {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/ListCatalogUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/ListCatalogUseCase.java new file mode 100644 index 0000000..f39c031 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/ListCatalogUseCase.java @@ -0,0 +1,86 @@ +package dev.caskeleton.application.techlog.studio.service; + +import dev.caskeleton.application.capability.Idempotency; +import dev.caskeleton.application.capability.RepositoryAccess; +import dev.caskeleton.application.capability.UseCaseCapability; +import dev.caskeleton.application.techlog.error.StudioError; +import dev.caskeleton.application.techlog.error.StudioException; +import dev.caskeleton.application.techlog.studio.port.out.CatalogQueryPort; +import dev.caskeleton.application.techlog.studio.query.CatalogPageView; +import dev.caskeleton.application.techlog.studio.query.ListCatalogQuery; +import dev.caskeleton.application.transaction.TransactionMode; +import dev.caskeleton.application.transaction.TransactionPort; +import dev.caskeleton.application.usecase.QueryUseCase; + +/** + * Studio catalog 조회. 도메인 상태를 바꾸지 않으므로 read-only다. + * + *

브리프 원안은 {@code TransactionPort}를 배선하지 않았지만, {@code + * CleanArchitectureTest.USE_CASE_CAPABILITY_MATCHES_TRANSACTION_PORT_BOUNDARY}(feature + * -domain-feature-onboarding-contract D4)가 READ_REPOSITORY+READ_ONLY 조합에 {@code + * TransactionPort.inRead(...)}를 직접 호출하도록 정적으로 강제한다. {@link + * dev.caskeleton.application.notification.NotificationOperationsSnapshotUseCase}와 같은 패턴이다. + */ +@UseCaseCapability( + transactionMode = TransactionMode.READ_ONLY, + idempotency = Idempotency.IDEMPOTENT, + repositoryAccess = RepositoryAccess.READ_REPOSITORY) +public final class ListCatalogUseCase implements QueryUseCase { + + private static final int MAX_LIMIT = 100; + + /** + * studio-v1.yaml {@code components.parameters.Query.schema.maxLength} + * (config/openapi/studio-v1.yaml:579). + */ + private static final int MAX_QUERY_LENGTH = 100; + + /** + * studio-v1.yaml {@code components.parameters.Cursor.schema.minLength} + * (config/openapi/studio-v1.yaml:580). + */ + private static final int MIN_CURSOR_LENGTH = 1; + + /** + * studio-v1.yaml {@code components.parameters.Cursor.schema.maxLength} + * (config/openapi/studio-v1.yaml:580). + */ + private static final int MAX_CURSOR_LENGTH = 2000; + + private final CatalogQueryPort catalogQueryPort; + private final TransactionPort transactions; + + public ListCatalogUseCase(CatalogQueryPort catalogQueryPort, TransactionPort transactions) { + this.catalogQueryPort = catalogQueryPort; + this.transactions = transactions; + } + + @Override + public CatalogPageView handle(ListCatalogQuery input) { + if (input.type() == null) { + throw StudioException.of(StudioError.REQUEST_VALIDATION_FAILED, "type is required"); + } + if (input.limit() < 1 || input.limit() > MAX_LIMIT) { + throw StudioException.of( + StudioError.REQUEST_VALIDATION_FAILED, "limit must be between 1 and " + MAX_LIMIT); + } + if (input.query() != null && input.query().length() > MAX_QUERY_LENGTH) { + throw StudioException.of( + StudioError.REQUEST_VALIDATION_FAILED, + "q must be at most " + MAX_QUERY_LENGTH + " characters"); + } + if (input.cursor() != null + && (input.cursor().length() < MIN_CURSOR_LENGTH + || input.cursor().length() > MAX_CURSOR_LENGTH)) { + throw StudioException.of( + StudioError.REQUEST_VALIDATION_FAILED, + "cursor must be between " + + MIN_CURSOR_LENGTH + + " and " + + MAX_CURSOR_LENGTH + + " characters"); + } + return transactions.inRead( + () -> catalogQueryPort.search(input.type(), input.query(), input.cursor(), input.limit())); + } +} diff --git a/src/application-core/src/test/java/dev/caskeleton/application/techlog/error/StudioErrorTest.java b/src/application-core/src/test/java/dev/caskeleton/application/techlog/error/StudioErrorTest.java new file mode 100644 index 0000000..3d4dadf --- /dev/null +++ b/src/application-core/src/test/java/dev/caskeleton/application/techlog/error/StudioErrorTest.java @@ -0,0 +1,48 @@ +package dev.caskeleton.application.techlog.error; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.shared.error.Category; +import java.util.Arrays; +import org.junit.jupiter.api.Test; + +class StudioErrorTest { + + /** + * final whole-branch review B3: renamed from {@code declaresExactlyTheTwentyThreeContractCodes} — + * this test never reads {@code studio-v1.yaml}, only counts the enum, so "contract codes" was a + * false claim (a rename or 1:1 substitution on either side leaves the count at 23 and this still + * passes). The actual contract-vs-enum set-equality gate is {@code + * StudioErrorRegistryTest#enumMatchesContractCodeSetExactly}; this test stays as a cheap "did the + * count change" tripwire. + */ + @Test + void declaresExactlyTwentyThreeCodes() { + assertThat(StudioError.values()).hasSize(23); + } + + @Test + void everyCodeCarriesACategoryAndAClientFacingStatus() { + Arrays.stream(StudioError.values()) + .forEach( + error -> { + assertThat(error.code()).matches("[A-Z][A-Z0-9_]*"); + assertThat(error.category()).isNotNull(); + assertThat(error.httpStatus()).isBetween(400, 599); + }); + } + + @Test + void versionConflictIsAFourZeroNineConflict() { + assertThat(StudioError.VERSION_CONFLICT.httpStatus()).isEqualTo(409); + assertThat(StudioError.VERSION_CONFLICT.category()).isEqualTo(Category.CONFLICT); + assertThat(StudioError.VERSION_CONFLICT.retryable()).isFalse(); + } + + @Test + void studioUnavailableIsRetryable() { + assertThat(StudioError.STUDIO_UNAVAILABLE.httpStatus()).isEqualTo(503); + assertThat(StudioError.STUDIO_UNAVAILABLE.category()).isEqualTo(Category.TRANSIENT_DEPENDENCY); + assertThat(StudioError.STUDIO_UNAVAILABLE.retryable()).isTrue(); + } +} diff --git a/src/application-core/src/test/java/dev/caskeleton/application/techlog/studio/service/ListCatalogUseCaseTest.java b/src/application-core/src/test/java/dev/caskeleton/application/techlog/studio/service/ListCatalogUseCaseTest.java new file mode 100644 index 0000000..9542ea7 --- /dev/null +++ b/src/application-core/src/test/java/dev/caskeleton/application/techlog/studio/service/ListCatalogUseCaseTest.java @@ -0,0 +1,138 @@ +package dev.caskeleton.application.techlog.studio.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.application.techlog.error.StudioException; +import dev.caskeleton.application.techlog.studio.port.out.CatalogQueryPort; +import dev.caskeleton.application.techlog.studio.query.CatalogEntryType; +import dev.caskeleton.application.techlog.studio.query.CatalogEntryView; +import dev.caskeleton.application.techlog.studio.query.CatalogPageView; +import dev.caskeleton.application.techlog.studio.query.ListCatalogQuery; +import dev.caskeleton.application.transaction.TransactionPort; +import java.util.List; +import java.util.UUID; +import java.util.function.Supplier; +import org.junit.jupiter.api.Test; + +class ListCatalogUseCaseTest { + + private static CatalogQueryPort portReturning(CatalogPageView page) { + return (type, query, cursor, limit) -> page; + } + + @Test + void returnsWhateverThePortFound() { + CatalogEntryView entry = + new CatalogEntryView( + UUID.randomUUID(), CatalogEntryType.TOPIC, "Kafka", null, null, "rev-1"); + ListCatalogUseCase useCase = + new ListCatalogUseCase( + portReturning(new CatalogPageView(List.of(entry), null)), new DirectTransactions()); + + CatalogPageView page = + useCase.handle(new ListCatalogQuery(CatalogEntryType.TOPIC, "ka", null, 20)); + + assertThat(page.items()).containsExactly(entry); + assertThat(page.nextCursor()).isNull(); + } + + @Test + void rejectsALimitAboveTheContractCeiling() { + ListCatalogUseCase useCase = + new ListCatalogUseCase( + portReturning(new CatalogPageView(List.of(), null)), new DirectTransactions()); + + assertThatThrownBy( + () -> useCase.handle(new ListCatalogQuery(CatalogEntryType.TOPIC, null, null, 101))) + .isInstanceOf(StudioException.class) + .hasMessageContaining("limit"); + } + + @Test + void rejectsAMissingType() { + ListCatalogUseCase useCase = + new ListCatalogUseCase( + portReturning(new CatalogPageView(List.of(), null)), new DirectTransactions()); + + assertThatThrownBy(() -> useCase.handle(new ListCatalogQuery(null, null, null, 20))) + .isInstanceOf(StudioException.class); + } + + /** + * studio-v1.yaml {@code components.parameters.Query} — {@code schema: { type: string, maxLength: + * 100 } } (src/config/openapi/studio-v1.yaml:579). + */ + @Test + void rejectsAQueryLongerThanTheContractCeiling() { + ListCatalogUseCase useCase = + new ListCatalogUseCase( + portReturning(new CatalogPageView(List.of(), null)), new DirectTransactions()); + String tooLong = "q".repeat(101); + + assertThatThrownBy( + () -> useCase.handle(new ListCatalogQuery(CatalogEntryType.TOPIC, tooLong, null, 20))) + .isInstanceOf(StudioException.class) + .hasMessageContaining("q"); + } + + /** + * studio-v1.yaml {@code components.parameters.Cursor} — {@code schema: { type: string, minLength: + * 1, maxLength: 2000 } } (src/config/openapi/studio-v1.yaml:580). + */ + @Test + void rejectsAnEmptyCursor() { + ListCatalogUseCase useCase = + new ListCatalogUseCase( + portReturning(new CatalogPageView(List.of(), null)), new DirectTransactions()); + + assertThatThrownBy( + () -> useCase.handle(new ListCatalogQuery(CatalogEntryType.TOPIC, null, "", 20))) + .isInstanceOf(StudioException.class) + .hasMessageContaining("cursor"); + } + + /** Same contract field as {@link #rejectsAnEmptyCursor()}; upper bound instead of lower. */ + @Test + void rejectsACursorLongerThanTheContractCeiling() { + ListCatalogUseCase useCase = + new ListCatalogUseCase( + portReturning(new CatalogPageView(List.of(), null)), new DirectTransactions()); + String tooLong = "c".repeat(2001); + + assertThatThrownBy( + () -> useCase.handle(new ListCatalogQuery(CatalogEntryType.TOPIC, null, tooLong, 20))) + .isInstanceOf(StudioException.class) + .hasMessageContaining("cursor"); + } + + /** + * {@code CleanArchitectureTest.USE_CASE_CAPABILITY_MATCHES_TRANSACTION_PORT_BOUNDARY}가 + * READ_REPOSITORY+READ_ONLY use case에 {@code TransactionPort.inRead(...)} 직접 호출을 요구하므로, {@code + * ListCatalogUseCase}는 생성자에 {@link TransactionPort}를 받는다. 같은 모양의 fake는 {@code + * NotificationOperationsSnapshotUseCaseTest.TrackingTransactions}를 참고했다 — 여기서는 검증 없이 그대로 통과시키기만 + * 하면 된다. + */ + private static final class DirectTransactions implements TransactionPort { + + @Override + public T inWrite(Supplier action) { + return action.get(); + } + + @Override + public T inRootWrite(Supplier action) { + return action.get(); + } + + @Override + public T inRead(Supplier action) { + return action.get(); + } + + @Override + public T inNew(Supplier action) { + return action.get(); + } + } +} diff --git a/src/build.gradle b/src/build.gradle index 263120b..25de083 100644 --- a/src/build.gradle +++ b/src/build.gradle @@ -16,6 +16,8 @@ plugins { id 'com.diffplug.spotless' version '8.6.0' apply false // D1 formatter (google-java-format) id 'com.github.spotbugs' version '6.5.6' apply false // D3 bytecode bug finder (+ D4 FindSecBugs) id 'net.ltgt.errorprone' version '5.1.0' apply false // D5 compile-time checker + // Task 4 — Studio 계약(studio-v1.yaml)에서 DTO만 생성한다(ADR-004/ADR-006). + id 'org.openapi.generator' version '7.18.0' apply false } // feature-build-release-supply-chain-contract D1/D9 — every archive carries an exact SemVer diff --git a/src/config/openapi/MANIFEST.sha256 b/src/config/openapi/MANIFEST.sha256 new file mode 100644 index 0000000..9315d6e --- /dev/null +++ b/src/config/openapi/MANIFEST.sha256 @@ -0,0 +1,2 @@ +# source: tech-log-design-package contracts/openapi/studio-v1.yaml @ b20d7a2 (feature/response-envelope-adr-006) +6cae9924403d0761f401643a022980b8e04183eea0d890c143c9fbbbbc7431e4 studio-v1.yaml diff --git a/src/config/openapi/studio-v1.yaml b/src/config/openapi/studio-v1.yaml new file mode 100644 index 0000000..836aa00 --- /dev/null +++ b/src/config/openapi/studio-v1.yaml @@ -0,0 +1,1836 @@ +openapi: 3.1.0 +info: + title: Tech Log Studio API + version: 3.0.0 + description: | + Tech Log Studio orchestration 계약이다. + + 이 파일이 Studio HTTP 계약의 canonical source다. Frontend의 + `contracts/studio-api.openapi.yaml`은 이 계약에서 생성되어야 하며 + 독립적인 두 번째 canonical source가 되어서는 안 된다. (ADR-004) + + ## 이 계약의 성격 + + 현재 Studio UI의 단일 문서 제작 흐름을 그대로 계약으로 승격한 것이다. + + ```text + 작업본 → 편집 → 저장 → 검증 → Public Preview → 게시/재게시 → 게시 기록/Snapshot + ``` + + 유형별 CMS 메뉴(Case 관리 / Reference 관리 / Question 관리)는 이 계약의 + primary mental model이 아니다. 유형별 specialized management capability는 + `studio-management-v1.yaml`에 secondary API로 보존한다. + + ## WorkingCopy는 API projection이다 + + `WorkingCopy`는 Domain Aggregate가 아니다. Studio API가 여러 도메인을 + 동일한 편집 경험으로 보여주기 위한 API/Application projection이며, + Backend는 이를 기존 유형별 use case로 dispatch한다. + + ```text + createStudioDocument(kind=CASE) → CreateCaseDraft + saveStudioDocument(kind=CASE) → UpdateCaseDraft + validateStudioDocument(kind=CASE) → CasePublicationValidator + publishStudioDocument(kind=CASE) → CasePublicationHandler + + kind=REFERENCE → Reference capability + kind=QUESTION → Inquiry capability + kind=PROJECT_DECISION → ProjectDecision capability + ``` + + `documentId`는 source aggregate id를 그대로 사용한다. 별도 surrogate + Studio id를 만들지 않으며 `working_copy` 범용 테이블도 만들지 않는다. + + ## API 용어 ↔ Domain 용어 mapper 경계 + + API enum은 UI 용어이고 Domain enum은 그대로 보존한다. 변환은 mapper가 한다. + + | API (이 계약) | Domain | 비고 | + |---|---|---| + | `questionStatus=OPEN` | `OPEN`, `INVESTIGATING`, `PAUSED` | 축약 view. 저장이 Domain 상태를 덮어쓰지 않는다 | + | `questionStatus=RESOLVED` | `RESOLVED` | Resolve command로 해석하며 기존 resolve invariant를 통과해야 한다 | + | `decisionStatus=ADOPTED` | `ACCEPTED`/`ADOPTED` | Domain enum을 UI 용어 때문에 변경하지 않는다 | + | `documentId` | `source_kind` + `source_id` | Publication 계열 테이블은 두 컬럼으로 저장한다 | + | `nextAction` | (없음) | Domain state가 아니라 Studio projection이다. `workflow_status`에 저장하지 않는다 | + + `saveStudioDocument`는 편집 가능한 content field만 저장한다. lifecycle + 전이는 명시적인 Domain Action이 담당한다. Frontend가 `OPEN`을 보냈다고 해서 + `INVESTIGATING → OPEN`으로 자동 전이하면 안 된다. + + ## Frontend 계약과의 현재 차이 (2026-08-17 실측) + + `tech-log-frontend`의 `src/features/tech-log/contracts/studio/studio-api.openapi.yaml`과 + 대조한 결과 다음이 이미 일치한다. + + ```text + operation 13개 전부 operationId · method · path 일치 + RecordKind 양쪽 4종 동일 (CASE / REFERENCE / QUESTION / PROJECT_DECISION) + 오류 코드 Frontend 15종이 이 계약의 23종에 모두 포함 + ``` + + Backend가 추가로 제공하는 것은 두 가지이며, Frontend가 이 계약에서 재생성할 + 때 흡수된다. + + 1. Asset operation 5개와 `getStudioSession`. + Frontend는 아직 Asset capability를 구현하지 않았다. + 2. mutating operation의 `X-CSRF-TOKEN`. + Frontend 계약은 `security: []`이며 이 header를 선언하지 않는다. + + ## 게시 경로는 하나다 + + `studio-management-v1.yaml`의 publish/unpublish 계열 operation도 이 계약과 + 동일한 Publication Event/Snapshot 경로를 거쳐야 한다. 서로 다른 두 개의 + 게시 경로를 허용하지 않는다. +servers: +- url: / +security: +- sessionCookie: [] +tags: +- name: Session + description: Studio 접근 제어 +- name: Dashboard + description: Workspace 대시보드 +- name: Documents + description: 통합 Working Copy 편집 +- name: Validation + description: Validation Artifact +- name: Preview + description: 인증된 Public Preview Artifact +- name: Publication + description: Publication Aggregate / Event / Snapshot +- name: Catalog + description: Editor picker 통합 조회 +- name: Assets + description: Asset Library / Picker / Upload +paths: + /api/v1/studio/session: + get: + operationId: getStudioSession + tags: [Session] + summary: 현재 Studio 세션과 CSRF 토큰을 조회한다 + responses: + "200": { description: 인증된 Studio 세션, content: { application/json: { schema: { $ref: "#/components/schemas/StudioSessionEnvelope" } } } } + "401": { $ref: "#/components/responses/AuthenticationRequired" } + "403": { $ref: "#/components/responses/AccessDenied" } + "503": { $ref: "#/components/responses/StudioUnavailable" } + + /api/v1/studio/dashboard: + get: + operationId: getStudioDashboard + tags: [Dashboard] + summary: Get the Studio dashboard + description: | + `nextAction`을 포함한 모든 workflow 상태는 서버가 계산한다. + Frontend는 여러 endpoint를 조합해 workflow 상태를 재추론하지 않는다. + responses: + "200": { description: Dashboard lists and totals, content: { application/json: { schema: { $ref: "#/components/schemas/StudioDashboardEnvelope" } } } } + "401": { $ref: "#/components/responses/AuthenticationRequired" } + "403": { $ref: "#/components/responses/AccessDenied" } + "503": { $ref: "#/components/responses/StudioUnavailable" } + + /api/v1/studio/documents: + get: + operationId: listStudioDocuments + tags: [Documents] + summary: List working copies + description: | + Case/Reference/OpenQuestion/ProjectDecision은 서로 다른 table/aggregate에 + 존재한다. 공통 CRUD repository를 만들지 않고 query side에서 union projection을 + 구성한다(`StudioDocumentQueryService`). + + `cursor`는 normalized filter/sort와 결합된 opaque 값이다. 필터가 달라진 + cursor 재사용은 거절한다. + parameters: + - { $ref: "#/components/parameters/Query" } + - { $ref: "#/components/parameters/DocumentKind" } + - { $ref: "#/components/parameters/PublicationStatus" } + - { $ref: "#/components/parameters/NextAction" } + - { $ref: "#/components/parameters/ProjectId" } + - { $ref: "#/components/parameters/DocumentSort" } + - { $ref: "#/components/parameters/Cursor" } + - { $ref: "#/components/parameters/Limit" } + responses: + "200": { description: Working-copy cursor page, content: { application/json: { schema: { $ref: "#/components/schemas/DocumentPageEnvelope" } } } } + "400": { $ref: "#/components/responses/MalformedRequest" } + "401": { $ref: "#/components/responses/AuthenticationRequired" } + "403": { $ref: "#/components/responses/AccessDenied" } + "422": { $ref: "#/components/responses/RequestValidationFailed" } + "503": { $ref: "#/components/responses/StudioUnavailable" } + post: + operationId: createStudioDocument + tags: [Documents] + summary: Create a working copy + description: 불완전한 초안도 생성할 수 있다. 생성은 Public Projection을 변경하지 않는다. + parameters: + - { $ref: "#/components/parameters/IdempotencyKey" } + - { $ref: "#/components/parameters/CsrfToken" } + requestBody: { required: true, content: { application/json: { schema: { $ref: "#/components/schemas/CreateDocumentInput" } } } } + responses: + "201": + description: Created working copy + headers: { Idempotency-Replayed: { $ref: "#/components/headers/IdempotencyReplayed" } } + content: { application/json: { schema: { $ref: "#/components/schemas/WorkingCopyEnvelope" } } } + "400": { $ref: "#/components/responses/MalformedRequest" } + "401": { $ref: "#/components/responses/AuthenticationRequired" } + "403": { $ref: "#/components/responses/AccessDenied" } + "409": { $ref: "#/components/responses/CommandConflict" } + "422": { $ref: "#/components/responses/RequestValidationFailed" } + "503": { $ref: "#/components/responses/StudioUnavailable" } + + /api/v1/studio/documents/{documentId}: + parameters: [{ $ref: "#/components/parameters/DocumentId" }] + get: + operationId: getStudioDocument + tags: [Documents] + summary: Get a working copy and its current state + responses: + "200": { description: Working-copy detail, content: { application/json: { schema: { $ref: "#/components/schemas/WorkingCopyDetailEnvelope" } } } } + "401": { $ref: "#/components/responses/AuthenticationRequired" } + "403": { $ref: "#/components/responses/AccessDenied" } + "404": { $ref: "#/components/responses/DocumentNotFound" } + "503": { $ref: "#/components/responses/StudioUnavailable" } + put: + operationId: saveStudioDocument + tags: [Documents] + summary: Save a full working copy + description: | + 편집 가능한 content field만 저장한다. lifecycle 전이는 명시적인 Domain + Action이 담당한다. 저장은 Public Projection을 변경하지 않는다. + + `expectedVersion` 불일치는 `VERSION_CONFLICT`이며 응답 `error.details` + (`VersionConflictDetails`)의 `latestDocument`로 현재 상태를 함께 제공한다. + parameters: + - { $ref: "#/components/parameters/IdempotencyKey" } + - { $ref: "#/components/parameters/CsrfToken" } + requestBody: { required: true, content: { application/json: { schema: { $ref: "#/components/schemas/SaveDocumentCommand" } } } } + responses: + "200": + description: Saved working-copy detail + headers: { Idempotency-Replayed: { $ref: "#/components/headers/IdempotencyReplayed" } } + content: { application/json: { schema: { $ref: "#/components/schemas/WorkingCopyDetailEnvelope" } } } + "400": { $ref: "#/components/responses/MalformedRequest" } + "401": { $ref: "#/components/responses/AuthenticationRequired" } + "403": { $ref: "#/components/responses/AccessDenied" } + "404": { $ref: "#/components/responses/DocumentNotFound" } + "409": { $ref: "#/components/responses/CommandConflict" } + "422": { $ref: "#/components/responses/RequestValidationFailed" } + "503": { $ref: "#/components/responses/StudioUnavailable" } + + /api/v1/studio/documents/{documentId}/validate: + parameters: [{ $ref: "#/components/parameters/DocumentId" }] + post: + operationId: validateStudioDocument + tags: [Validation] + summary: Validate a saved working copy + description: | + 단순 request validation이 아니다. 다음 체인을 모두 수행한다. + + ```text + Schema/Input Validation + → 유형별 Domain Validation + → 관계/Project/Topic 존재 검증 + → Asset READY 검증 + → Slug/Route 충돌 검증 + → Publication Validation + ``` + + 결과는 일급 artifact인 `ValidationReport`로 영속되며 `validationId`로 + `createStudioPreview`와 `publishStudioDocument`가 이를 참조한다. + parameters: + - { $ref: "#/components/parameters/IdempotencyKey" } + - { $ref: "#/components/parameters/CsrfToken" } + requestBody: { required: true, content: { application/json: { schema: { $ref: "#/components/schemas/ValidateDocumentCommand" } } } } + responses: + "200": + description: Validation report + headers: { Idempotency-Replayed: { $ref: "#/components/headers/IdempotencyReplayed" } } + content: { application/json: { schema: { $ref: "#/components/schemas/ValidationReportEnvelope" } } } + "400": { $ref: "#/components/responses/MalformedRequest" } + "401": { $ref: "#/components/responses/AuthenticationRequired" } + "403": { $ref: "#/components/responses/AccessDenied" } + "404": { $ref: "#/components/responses/DocumentNotFound" } + "409": { $ref: "#/components/responses/CommandConflict" } + "422": { $ref: "#/components/responses/RequestValidationFailed" } + "503": { $ref: "#/components/responses/StudioUnavailable" } + + /api/v1/studio/documents/{documentId}/preview: + parameters: [{ $ref: "#/components/parameters/DocumentId" }] + get: + operationId: getCurrentStudioPreview + tags: [Preview] + summary: Get the latest preview and its computed state + description: | + Preview 상태(`CURRENT`/`STALE`/`EXPIRED`)는 서버가 계산한다. + anonymous `/preview/{token}` 계약은 폐기되었다. Preview는 인증된 + Studio API로만 조회한다. + responses: + "200": { description: Preview detail, content: { application/json: { schema: { $ref: "#/components/schemas/PreviewDetailEnvelope" } } } } + "401": { $ref: "#/components/responses/AuthenticationRequired" } + "403": { $ref: "#/components/responses/AccessDenied" } + "404": { $ref: "#/components/responses/PreviewNotFound" } + "503": { $ref: "#/components/responses/StudioUnavailable" } + post: + operationId: createStudioPreview + tags: [Preview] + summary: Create a public-layout preview + description: | + 저장된 working version + validationId + dependency revision을 묶어 + `PublicRenderModel` snapshot을 만든다. Preview는 Public과 동일한 + semantic renderer와 Asset resolver를 사용한다. + parameters: + - { $ref: "#/components/parameters/IdempotencyKey" } + - { $ref: "#/components/parameters/CsrfToken" } + requestBody: { required: true, content: { application/json: { schema: { $ref: "#/components/schemas/CreatePreviewCommand" } } } } + responses: + "201": + description: Created preview + headers: { Idempotency-Replayed: { $ref: "#/components/headers/IdempotencyReplayed" } } + content: { application/json: { schema: { $ref: "#/components/schemas/PublicPreviewEnvelope" } } } + "400": { $ref: "#/components/responses/MalformedRequest" } + "401": { $ref: "#/components/responses/AuthenticationRequired" } + "403": { $ref: "#/components/responses/AccessDenied" } + "404": { $ref: "#/components/responses/DocumentNotFound" } + "409": { $ref: "#/components/responses/CommandConflict" } + "422": { $ref: "#/components/responses/PreviewRejected" } + "503": { $ref: "#/components/responses/StudioUnavailable" } + + /api/v1/studio/documents/{documentId}/publish: + parameters: [{ $ref: "#/components/parameters/DocumentId" }] + post: + operationId: publishStudioDocument + tags: [Publication] + summary: Publish or republish a validated preview + description: | + 하나의 transaction으로 다음을 수행한다. + + ```text + 1. source version lock/check + 2. validationId / current dependency revision 검증 + 3. previewId / current dependency revision 검증 + 4. warning acknowledgement 검증 + 5. 유형별 publication validation + 6. Publication Event 생성 (PUBLISHED | REPUBLISHED) + 7. Publication Snapshot 생성 (immutable) + 8. public_resource_projection 교체 + 9. public_route 교체 / alias 처리 + 10. public relation/tag/project projection 교체 + 11. published Asset reference 교체 + 12. Publication Aggregate 갱신 + 13. commit + ``` + + 재시도가 중복 Publication Event를 만들면 안 된다. `Idempotency-Key`로 + 최초 결과를 재생한다. + parameters: + - { $ref: "#/components/parameters/IdempotencyKey" } + - { $ref: "#/components/parameters/CsrfToken" } + requestBody: { required: true, content: { application/json: { schema: { $ref: "#/components/schemas/PublishDocumentCommand" } } } } + responses: + "200": + description: Publication aggregate and immutable event + headers: { Idempotency-Replayed: { $ref: "#/components/headers/IdempotencyReplayed" } } + content: { application/json: { schema: { $ref: "#/components/schemas/PublishResultEnvelope" } } } + "400": { $ref: "#/components/responses/MalformedRequest" } + "401": { $ref: "#/components/responses/AuthenticationRequired" } + "403": { $ref: "#/components/responses/AccessDenied" } + "404": { $ref: "#/components/responses/DocumentNotFound" } + "409": { $ref: "#/components/responses/CommandConflict" } + "422": { $ref: "#/components/responses/PublishRejected" } + "503": { $ref: "#/components/responses/StudioUnavailable" } + + /api/v1/studio/publications: + get: + operationId: listStudioPublications + tags: [Publication] + summary: List immutable publication events + description: Events are ordered by occurredAt DESC, then publicationEventId. + parameters: + - { $ref: "#/components/parameters/Query" } + - { $ref: "#/components/parameters/PublicationEventType" } + - { $ref: "#/components/parameters/Cursor" } + - { $ref: "#/components/parameters/Limit" } + responses: + "200": { description: Publication cursor page, content: { application/json: { schema: { $ref: "#/components/schemas/PublicationPageEnvelope" } } } } + "400": { $ref: "#/components/responses/MalformedRequest" } + "401": { $ref: "#/components/responses/AuthenticationRequired" } + "403": { $ref: "#/components/responses/AccessDenied" } + "422": { $ref: "#/components/responses/RequestValidationFailed" } + "503": { $ref: "#/components/responses/StudioUnavailable" } + + /api/v1/studio/publications/{publicationId}/unpublish: + parameters: [{ $ref: "#/components/parameters/PublicationId" }] + post: + operationId: unpublishStudioPublication + tags: [Publication] + summary: Unpublish the current publication + description: | + `UNPUBLISHED` Event를 생성하고 current projection을 `WITHDRAWN`으로 바꾼다. + canonical route ownership/history는 보존한다. + 과거 Snapshot은 삭제하거나 재계산하지 않는다. + + Backend 내부에서 working copy가 `DRAFT`로 되돌아가는 lifecycle 전이는 + 유지하되 이 API로 직접 노출하지 않는다. 응답은 + `publicationStatus=UNPUBLISHED`와 다음 `nextAction`으로 표현한다. + parameters: + - { $ref: "#/components/parameters/IdempotencyKey" } + - { $ref: "#/components/parameters/CsrfToken" } + requestBody: { required: true, content: { application/json: { schema: { $ref: "#/components/schemas/UnpublishCommand" } } } } + responses: + "200": + description: Updated publication aggregate and event + headers: { Idempotency-Replayed: { $ref: "#/components/headers/IdempotencyReplayed" } } + content: { application/json: { schema: { $ref: "#/components/schemas/PublishResultEnvelope" } } } + "400": { $ref: "#/components/responses/MalformedRequest" } + "401": { $ref: "#/components/responses/AuthenticationRequired" } + "403": { $ref: "#/components/responses/AccessDenied" } + "404": { $ref: "#/components/responses/PublicationNotFound" } + "409": { $ref: "#/components/responses/CommandConflict" } + "422": { $ref: "#/components/responses/RequestValidationFailed" } + "503": { $ref: "#/components/responses/StudioUnavailable" } + + /api/v1/studio/publications/{publicationEventId}/preview: + parameters: [{ $ref: "#/components/parameters/PublicationEventId" }] + get: + operationId: getStudioPublicationSnapshot + tags: [Publication] + summary: Get an immutable publication snapshot + description: | + `PUBLISHED`/`REPUBLISHED` Event 시점에 저장된 불변 `PublicRenderModel`을 + 반환한다. 현재 Working Copy나 현재 Projection에서 재계산하지 않는다. + + `UNPUBLISHED` Event는 자체 snapshot을 갖지 않는다. 이 경우 + `sourcePublishedEventId`가 가리키는 마지막 공개 Snapshot을 사용한다. + responses: + "200": { description: Publication snapshot, content: { application/json: { schema: { $ref: "#/components/schemas/PublicationSnapshotEnvelope" } } } } + "401": { $ref: "#/components/responses/AuthenticationRequired" } + "403": { $ref: "#/components/responses/AccessDenied" } + "404": { $ref: "#/components/responses/PublicationSnapshotNotFound" } + "503": { $ref: "#/components/responses/StudioUnavailable" } + + /api/v1/studio/catalog: + get: + operationId: listStudioCatalog + tags: [Catalog] + summary: List catalog entries for editor pickers + description: | + Editor picker가 사용하는 통합 read API다. Backend source는 다음과 같다. + + ```text + TOPIC → topic capability + PROJECT → project capability + RELATION → Case/Reference/Question/ProjectDecision 중 연결 가능한 대상 + EVIDENCE → resolution/decision 근거로 사용할 수 있는 공개 기록 + ``` + + Asset 검색/업로드/metadata는 Catalog가 아니라 `/api/v1/studio/assets`가 + 소유한다. + parameters: + - { $ref: "#/components/parameters/CatalogType" } + - { $ref: "#/components/parameters/Query" } + - { $ref: "#/components/parameters/Cursor" } + - { $ref: "#/components/parameters/Limit" } + responses: + "200": { description: Catalog cursor page, content: { application/json: { schema: { $ref: "#/components/schemas/CatalogPageEnvelope" } } } } + "400": { $ref: "#/components/responses/MalformedRequest" } + "401": { $ref: "#/components/responses/AuthenticationRequired" } + "403": { $ref: "#/components/responses/AccessDenied" } + "422": { $ref: "#/components/responses/RequestValidationFailed" } + "503": { $ref: "#/components/responses/StudioUnavailable" } + + /api/v1/studio/assets: + get: + operationId: listStudioAssets + tags: [Assets] + summary: List assets for the library and the editor picker + parameters: + - { $ref: "#/components/parameters/Query" } + - { $ref: "#/components/parameters/AssetKindFilter" } + - { $ref: "#/components/parameters/AssetStatusFilter" } + - { $ref: "#/components/parameters/Cursor" } + - { $ref: "#/components/parameters/Limit" } + responses: + "200": { description: Asset cursor page, content: { application/json: { schema: { $ref: "#/components/schemas/AssetPageEnvelope" } } } } + "400": { $ref: "#/components/responses/MalformedRequest" } + "401": { $ref: "#/components/responses/AuthenticationRequired" } + "403": { $ref: "#/components/responses/AccessDenied" } + "422": { $ref: "#/components/responses/RequestValidationFailed" } + "503": { $ref: "#/components/responses/StudioUnavailable" } + post: + operationId: uploadStudioAsset + tags: [Assets] + summary: Upload an image, diagram, or attachment + description: | + MVP 업로드는 `multipart/form-data`를 사용한다. 향후 presigned/resumable로 + 교체하더라도 같은 Asset port 뒤에서 처리한다. + + Backend는 확장자를 신뢰하지 않는다. MIME/type 검증과 크기 제한을 적용하고 + 검사에 실패하면 `REJECTED` 또는 `QUARANTINED`로 저장한다. `READY` 전환은 + 검증 완료 후에만 일어난다. + + `image/svg+xml`을 지원하지만 업로드 원문을 HTML에 inline하지 않는다. + Public/Preview는 검증된 delivery URL을 ``로 렌더링한다. + parameters: + - { $ref: "#/components/parameters/IdempotencyKey" } + - { $ref: "#/components/parameters/CsrfToken" } + requestBody: + required: true + content: + multipart/form-data: + schema: { $ref: "#/components/schemas/AssetUploadForm" } + encoding: + file: { contentType: "image/png, image/jpeg, image/webp, image/gif, image/svg+xml, application/pdf" } + responses: + "201": + description: Stored asset + headers: { Idempotency-Replayed: { $ref: "#/components/headers/IdempotencyReplayed" } } + content: { application/json: { schema: { $ref: "#/components/schemas/AssetEnvelope" } } } + "400": { $ref: "#/components/responses/MalformedRequest" } + "401": { $ref: "#/components/responses/AuthenticationRequired" } + "403": { $ref: "#/components/responses/AccessDenied" } + "409": { $ref: "#/components/responses/CommandConflict" } + "413": { $ref: "#/components/responses/PayloadTooLarge" } + "415": { $ref: "#/components/responses/UnsupportedMediaType" } + "422": { $ref: "#/components/responses/RequestValidationFailed" } + "503": { $ref: "#/components/responses/StudioUnavailable" } + + /api/v1/studio/assets/{assetId}: + parameters: [{ $ref: "#/components/parameters/AssetId" }] + get: + operationId: getStudioAsset + tags: [Assets] + summary: Get an asset with its usage + responses: + "200": { description: Asset detail, content: { application/json: { schema: { $ref: "#/components/schemas/AssetDetailEnvelope" } } } } + "401": { $ref: "#/components/responses/AuthenticationRequired" } + "403": { $ref: "#/components/responses/AccessDenied" } + "404": { $ref: "#/components/responses/AssetNotFound" } + "503": { $ref: "#/components/responses/StudioUnavailable" } + put: + operationId: updateStudioAsset + tags: [Assets] + summary: Update asset metadata + description: | + `altText`, `decorative`, `kind`와 `READY ↔ ARCHIVED` 전환만 허용한다. + `REJECTED`/`QUARANTINED`는 서버 검증 결과이며 클라이언트가 지정할 수 없다. + parameters: + - { $ref: "#/components/parameters/IdempotencyKey" } + - { $ref: "#/components/parameters/CsrfToken" } + requestBody: { required: true, content: { application/json: { schema: { $ref: "#/components/schemas/UpdateAssetCommand" } } } } + responses: + "200": + description: Updated asset + headers: { Idempotency-Replayed: { $ref: "#/components/headers/IdempotencyReplayed" } } + content: { application/json: { schema: { $ref: "#/components/schemas/AssetEnvelope" } } } + "400": { $ref: "#/components/responses/MalformedRequest" } + "401": { $ref: "#/components/responses/AuthenticationRequired" } + "403": { $ref: "#/components/responses/AccessDenied" } + "404": { $ref: "#/components/responses/AssetNotFound" } + "409": { $ref: "#/components/responses/CommandConflict" } + "422": { $ref: "#/components/responses/AssetRejected" } + "503": { $ref: "#/components/responses/StudioUnavailable" } + delete: + operationId: deleteStudioAsset + tags: [Assets] + summary: Delete an unused asset + description: | + 공개 이력이 있는 Asset과 사용 중인 Asset은 hard delete하지 않는다. + 두 경우 모두 `ASSET_IN_USE`로 거절하고 `ARCHIVED` 전환을 사용한다. + parameters: + - { $ref: "#/components/parameters/IdempotencyKey" } + - { $ref: "#/components/parameters/CsrfToken" } + responses: + "204": { description: Deleted } + "401": { $ref: "#/components/responses/AuthenticationRequired" } + "403": { $ref: "#/components/responses/AccessDenied" } + "404": { $ref: "#/components/responses/AssetNotFound" } + "409": { $ref: "#/components/responses/CommandConflict" } + "503": { $ref: "#/components/responses/StudioUnavailable" } + +components: + securitySchemes: + sessionCookie: + type: apiKey + in: cookie + name: TECHLOG_SESSION + description: | + Backend Session Cookie. `TECH_LOG_ADMIN` 권한이 필요하다. + mutating operation은 추가로 `X-CSRF-TOKEN` header를 요구한다. + + parameters: + DocumentId: { name: documentId, in: path, required: true, schema: { type: string, format: uuid } } + PublicationId: { name: publicationId, in: path, required: true, schema: { type: string, format: uuid } } + PublicationEventId: { name: publicationEventId, in: path, required: true, schema: { type: string, format: uuid } } + AssetId: { name: assetId, in: path, required: true, schema: { type: string, format: uuid } } + IdempotencyKey: + name: Idempotency-Key + in: header + required: true + description: | + 동일 key + 동일 normalized request는 최초 결과를 재생한다. + 동일 key + 다른 request는 `IDEMPOTENCY_KEY_REUSED` conflict다. + key 원문을 로그에 남길 때 요청 본문의 민감정보가 함께 기록되지 않게 한다. + schema: { type: string, minLength: 1, maxLength: 200 } + CsrfToken: + name: X-CSRF-TOKEN + in: header + required: true + description: "`getStudioSession`이 발급한 CSRF 토큰." + schema: { type: string, minLength: 1, maxLength: 200 } + Query: { name: q, in: query, description: Free-text query normalized as part of the opaque cursor, schema: { type: string, maxLength: 100 } } + Cursor: { name: cursor, in: query, description: Opaque cursor bound to normalized filters and sort, schema: { type: string, minLength: 1, maxLength: 2000 } } + Limit: { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 100, default: 20 } } + DocumentKind: { name: kind, in: query, schema: { $ref: "#/components/schemas/RecordKind" } } + PublicationStatus: { name: publicationStatus, in: query, schema: { $ref: "#/components/schemas/PublicationStatus" } } + NextAction: { name: nextAction, in: query, schema: { $ref: "#/components/schemas/NextAction" } } + ProjectId: { name: projectId, in: query, schema: { type: string, format: uuid } } + DocumentSort: { name: sort, in: query, schema: { type: string, enum: [UPDATED_DESC, UPDATED_ASC, TITLE_ASC], default: UPDATED_DESC } } + PublicationEventType: { name: type, in: query, schema: { $ref: "#/components/schemas/PublicationEventType" } } + CatalogType: { name: type, in: query, required: true, schema: { $ref: "#/components/schemas/CatalogEntryType" } } + AssetKindFilter: { name: kind, in: query, schema: { $ref: "#/components/schemas/AssetKind" } } + AssetStatusFilter: { name: managementStatus, in: query, schema: { $ref: "#/components/schemas/AssetManagementStatus" } } + + headers: + IdempotencyReplayed: { description: True when the original result was replayed, schema: { type: boolean } } + + responses: + MalformedRequest: { description: Malformed request, x-error-codes: [REQUEST_VALIDATION_FAILED], content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } } + AuthenticationRequired: { description: Authentication required, x-error-codes: [AUTHENTICATION_REQUIRED], content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } } + AccessDenied: { description: Studio access denied, x-error-codes: [STUDIO_ACCESS_DENIED], content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } } + DocumentNotFound: { description: Document not found, x-error-codes: [DOCUMENT_NOT_FOUND], content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } } + PreviewNotFound: { description: Document or preview not found, x-error-codes: [DOCUMENT_NOT_FOUND, PREVIEW_NOT_FOUND], content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } } + PublicationNotFound: { description: Publication not found, x-error-codes: [PUBLICATION_NOT_FOUND], content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } } + PublicationSnapshotNotFound: { description: Publication event or snapshot not found, x-error-codes: [PUBLICATION_EVENT_NOT_FOUND, PUBLICATION_SNAPSHOT_NOT_FOUND], content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } } + AssetNotFound: { description: Asset not found, x-error-codes: [ASSET_NOT_FOUND], content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } } + CommandConflict: + description: | + Command conflicts with current state, freshness, or idempotency. + + `ASSET_IN_USE`는 사용 중이거나 공개 이력이 있는 Asset의 hard delete 시도다. + x-error-codes: [VERSION_CONFLICT, PUBLICATION_CONFLICT, VALIDATION_STALE, PREVIEW_STALE, PREVIEW_EXPIRED, IDEMPOTENCY_KEY_REUSED, ASSET_IN_USE] + content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } + RequestValidationFailed: { description: Request validation failed, x-error-codes: [REQUEST_VALIDATION_FAILED], content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } } + PreviewRejected: + description: Preview 생성이 도메인 규칙으로 거절되었다 + x-error-codes: [REQUEST_VALIDATION_FAILED, DOCUMENT_VALIDATION_FAILED, ASSET_NOT_READY, ASSET_QUARANTINED] + content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } + PublishRejected: + description: | + Publication validation이 실패했다. + + `WARNING_ACKNOWLEDGEMENT_REQUIRED`는 `acknowledgedWarningCodes`가 + 현재 Validation의 WARNING 집합을 덮지 못한 경우다. + x-error-codes: [REQUEST_VALIDATION_FAILED, DOCUMENT_VALIDATION_FAILED, WARNING_ACKNOWLEDGEMENT_REQUIRED, ASSET_NOT_READY, ASSET_QUARANTINED] + content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } + AssetRejected: + description: Asset metadata 변경이 거절되었다 + x-error-codes: [REQUEST_VALIDATION_FAILED, ASSET_NOT_READY, ASSET_QUARANTINED] + content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } + PayloadTooLarge: { description: Upload exceeds the configured size limit, x-error-codes: [PAYLOAD_TOO_LARGE], content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } } + UnsupportedMediaType: { description: Unsupported media type, x-error-codes: [UNSUPPORTED_MEDIA_TYPE], content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } } + StudioUnavailable: { description: Studio unavailable, x-error-codes: [STUDIO_UNAVAILABLE], content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } } + + schemas: + # ------------------------------------------------------------- envelope + # wire format은 봉투다 (ADR-006). payload 스키마는 그대로 두고 + # 응답만 Envelope으로 감싼다. + ResponseMeta: + type: object + additionalProperties: false + required: [requestId, traceId] + properties: + requestId: { type: string, minLength: 1, maxLength: 200 } + traceId: { type: string, minLength: 1, maxLength: 200 } + correlationId: { type: [string, "null"], maxLength: 200 } + page: { type: ["object", "null"], additionalProperties: true, description: "Studio는 body 안 cursor 페이지네이션을 쓰므로 항상 null이다. 백엔드 템플릿의 ResponseMeta record가 이 필드를 직렬화한다." } + ApiError: + type: object + additionalProperties: false + required: [code, category, message, retryable] + properties: + code: + type: string + enum: [AUTHENTICATION_REQUIRED, STUDIO_ACCESS_DENIED, DOCUMENT_NOT_FOUND, VERSION_CONFLICT, + REQUEST_VALIDATION_FAILED, DOCUMENT_VALIDATION_FAILED, VALIDATION_STALE, PREVIEW_NOT_FOUND, + PREVIEW_STALE, PREVIEW_EXPIRED, PUBLICATION_NOT_FOUND, PUBLICATION_CONFLICT, + PUBLICATION_EVENT_NOT_FOUND, PUBLICATION_SNAPSHOT_NOT_FOUND, + WARNING_ACKNOWLEDGEMENT_REQUIRED, IDEMPOTENCY_KEY_REUSED, ASSET_NOT_FOUND, + ASSET_NOT_READY, ASSET_IN_USE, ASSET_QUARANTINED, PAYLOAD_TOO_LARGE, + UNSUPPORTED_MEDIA_TYPE, STUDIO_UNAVAILABLE] + category: + type: string + enum: [VALIDATION, AUTH, AUTHZ, NOT_FOUND, CONFLICT, RATE_LIMIT, + TRANSIENT_DEPENDENCY, PERMANENT_DEPENDENCY, DATA_INTEGRITY, INTERNAL] + message: { type: string, minLength: 1, maxLength: 5000 } + retryable: { type: boolean } + details: + oneOf: + - $ref: "#/components/schemas/ValidationErrorDetails" + - $ref: "#/components/schemas/VersionConflictDetails" + - $ref: "#/components/schemas/PublicationConflictDetails" + - type: "null" + ErrorEnvelope: + type: object + additionalProperties: false + required: [success, error, meta] + properties: + success: { type: boolean, const: false } + error: { $ref: "#/components/schemas/ApiError" } + meta: { $ref: "#/components/schemas/ResponseMeta" } + ValidationErrorDetails: + type: object + additionalProperties: false + required: [fieldErrors] + properties: + fieldErrors: { type: array, maxItems: 200, items: { $ref: "#/components/schemas/FieldError" } } + VersionConflictDetails: + type: object + additionalProperties: false + required: [latestDocument] + properties: + latestDocument: { $ref: "#/components/schemas/WorkingCopyDetail" } + conflictingFields: + type: array + uniqueItems: true + maxItems: 200 + items: { type: string, pattern: "^(?:/(?:[^~/]|~0|~1)*)*$" } + PublicationConflictDetails: + type: object + additionalProperties: false + required: [latestPublication] + properties: + latestPublication: { $ref: "#/components/schemas/PublicationAggregate" } + StudioSessionEnvelope: + type: object + additionalProperties: false + required: [success, data, meta] + properties: + success: { type: boolean, const: true } + data: { $ref: "#/components/schemas/StudioSession" } + meta: { $ref: "#/components/schemas/ResponseMeta" } + StudioDashboardEnvelope: + type: object + additionalProperties: false + required: [success, data, meta] + properties: + success: { type: boolean, const: true } + data: { $ref: "#/components/schemas/StudioDashboard" } + meta: { $ref: "#/components/schemas/ResponseMeta" } + DocumentPageEnvelope: + type: object + additionalProperties: false + required: [success, data, meta] + properties: + success: { type: boolean, const: true } + data: { $ref: "#/components/schemas/DocumentPage" } + meta: { $ref: "#/components/schemas/ResponseMeta" } + WorkingCopyDetailEnvelope: + type: object + additionalProperties: false + required: [success, data, meta] + properties: + success: { type: boolean, const: true } + data: { $ref: "#/components/schemas/WorkingCopyDetail" } + meta: { $ref: "#/components/schemas/ResponseMeta" } + WorkingCopyEnvelope: + type: object + additionalProperties: false + required: [success, data, meta] + properties: + success: { type: boolean, const: true } + data: { $ref: "#/components/schemas/WorkingCopy" } + meta: { $ref: "#/components/schemas/ResponseMeta" } + ValidationReportEnvelope: + type: object + additionalProperties: false + required: [success, data, meta] + properties: + success: { type: boolean, const: true } + data: { $ref: "#/components/schemas/ValidationReport" } + meta: { $ref: "#/components/schemas/ResponseMeta" } + PreviewDetailEnvelope: + type: object + additionalProperties: false + required: [success, data, meta] + properties: + success: { type: boolean, const: true } + data: { $ref: "#/components/schemas/PreviewDetail" } + meta: { $ref: "#/components/schemas/ResponseMeta" } + PublicPreviewEnvelope: + type: object + additionalProperties: false + required: [success, data, meta] + properties: + success: { type: boolean, const: true } + data: { $ref: "#/components/schemas/PublicPreview" } + meta: { $ref: "#/components/schemas/ResponseMeta" } + PublishResultEnvelope: + type: object + additionalProperties: false + required: [success, data, meta] + properties: + success: { type: boolean, const: true } + data: { $ref: "#/components/schemas/PublishResult" } + meta: { $ref: "#/components/schemas/ResponseMeta" } + PublicationPageEnvelope: + type: object + additionalProperties: false + required: [success, data, meta] + properties: + success: { type: boolean, const: true } + data: { $ref: "#/components/schemas/PublicationPage" } + meta: { $ref: "#/components/schemas/ResponseMeta" } + PublicationSnapshotEnvelope: + type: object + additionalProperties: false + required: [success, data, meta] + properties: + success: { type: boolean, const: true } + data: { $ref: "#/components/schemas/PublicationSnapshot" } + meta: { $ref: "#/components/schemas/ResponseMeta" } + CatalogPageEnvelope: + type: object + additionalProperties: false + required: [success, data, meta] + properties: + success: { type: boolean, const: true } + data: { $ref: "#/components/schemas/CatalogPage" } + meta: { $ref: "#/components/schemas/ResponseMeta" } + AssetPageEnvelope: + type: object + additionalProperties: false + required: [success, data, meta] + properties: + success: { type: boolean, const: true } + data: { $ref: "#/components/schemas/AssetPage" } + meta: { $ref: "#/components/schemas/ResponseMeta" } + AssetDetailEnvelope: + type: object + additionalProperties: false + required: [success, data, meta] + properties: + success: { type: boolean, const: true } + data: { $ref: "#/components/schemas/AssetDetail" } + meta: { $ref: "#/components/schemas/ResponseMeta" } + AssetEnvelope: + type: object + additionalProperties: false + required: [success, data, meta] + properties: + success: { type: boolean, const: true } + data: { $ref: "#/components/schemas/Asset" } + meta: { $ref: "#/components/schemas/ResponseMeta" } + # ---------------------------------------------------------------- session + StudioSession: + type: object + additionalProperties: false + required: [authenticated, displayName, roles, csrfToken, csrfHeaderName] + properties: + authenticated: { type: boolean } + displayName: { type: string, minLength: 1, maxLength: 120 } + roles: { type: array, uniqueItems: true, maxItems: 20, items: { type: string, minLength: 1, maxLength: 60 } } + csrfToken: { type: string, minLength: 1, maxLength: 200 } + csrfHeaderName: { type: string, const: X-CSRF-TOKEN } + + # ------------------------------------------------------------ enumerations + RecordKind: + type: string + enum: [CASE, REFERENCE, QUESTION, PROJECT_DECISION] + description: | + Studio 편집 대상 유형. API projection discriminator이며 Domain Aggregate가 + 아니다. `PROJECT_DECISION`은 `ProjectDecision` capability로 dispatch된다. + PublicationStatus: { type: string, enum: [NEVER_PUBLISHED, PUBLISHED, UNPUBLISHED] } + NextAction: + type: string + enum: [CONTINUE_EDITING, VALIDATE, FIX_VALIDATION, CREATE_PREVIEW, PUBLISH, NONE] + description: | + 서버가 계산하는 Studio projection이다. Domain state machine이 아니며 + `workflow_status` 같은 domain 컬럼에 저장하지 않는다. + + # --------------------------------------------------------- shared fragments + RelationInput: + type: object + additionalProperties: false + required: [id, targetId, reason, order] + properties: + id: { type: [string, "null"], format: uuid } + targetId: { type: [string, "null"], format: uuid } + reason: { type: string, maxLength: 100000 } + order: { type: integer, minimum: 0 } + Relation: + type: object + additionalProperties: false + required: [id, targetId, reason, order] + properties: + id: { type: string, format: uuid } + targetId: { type: string, format: uuid } + reason: { type: string, maxLength: 100000 } + order: { type: integer, minimum: 0 } + OrderedText: + type: object + additionalProperties: false + required: [id, text, order] + properties: + id: { type: string, format: uuid } + text: { type: string, minLength: 1, maxLength: 100000 } + order: { type: integer, minimum: 0 } + ReferenceRule: + type: object + additionalProperties: false + required: [id, title, body, order] + properties: + id: { type: string, format: uuid } + title: { type: string, minLength: 1, maxLength: 120 } + body: { type: string, minLength: 1, maxLength: 100000 } + order: { type: integer, minimum: 0 } + QuestionOption: + type: object + additionalProperties: false + required: [id, title, description, order] + properties: + id: { type: string, format: uuid } + title: { type: string, minLength: 1, maxLength: 120 } + description: { type: string, maxLength: 100000 } + order: { type: integer, minimum: 0 } + QuestionResolution: + type: object + additionalProperties: false + required: [summary, evidenceTargetId, linkLabel] + properties: + summary: { type: string, maxLength: 100000 } + evidenceTargetId: { type: [string, "null"], format: uuid } + linkLabel: { type: string, maxLength: 120 } + + # -------------------------------------------------------- working copy input + WorkingCopyInputBase: + type: object + description: | + 불완전한 초안도 저장할 수 있어야 하므로 필드는 required이되 빈 값과 null을 + 허용한다. 게시 가능 여부는 `validateStudioDocument`가 판단한다. + required: [kind, title, slug, summary, topicId, projectId, relations] + properties: + kind: { $ref: "#/components/schemas/RecordKind" } + title: { type: string, maxLength: 120 } + slug: + oneOf: + - { type: string, const: "" } + - { type: string, minLength: 3, maxLength: 100, pattern: "^[a-z0-9]+(?:-[a-z0-9]+)*$" } + summary: { type: string, maxLength: 300 } + topicId: { type: [string, "null"], format: uuid } + projectId: + type: [string, "null"] + format: uuid + description: "`kind=PROJECT_DECISION`은 게시 시점에 non-null이어야 한다. 저장 시점에는 강제하지 않는다." + relations: { type: array, maxItems: 20, items: { $ref: "#/components/schemas/RelationInput" } } + CaseInput: + unevaluatedProperties: false + allOf: + - { $ref: "#/components/schemas/WorkingCopyInputBase" } + - type: object + required: [kind, problem, conclusion, environment, reproduction, lastVerifiedOn, bodyMarkdown] + properties: + kind: { type: string, enum: [CASE] } + problem: { type: string, maxLength: 100000 } + conclusion: { type: string, maxLength: 100000 } + environment: { type: string, maxLength: 100000 } + reproduction: { type: string, maxLength: 100000 } + lastVerifiedOn: { type: [string, "null"], format: date } + bodyMarkdown: + type: string + maxLength: 100000 + description: | + Markdown 원문. Asset은 `:::evidence key=""` directive로 + 참조한다. object storage URL을 원문에 직접 저장하지 않는다. + ReferenceInput: + unevaluatedProperties: false + allOf: + - { $ref: "#/components/schemas/WorkingCopyInputBase" } + - type: object + required: [kind, purpose, rules, applyWhen, exceptions, examples, verifiedOn] + properties: + kind: { type: string, enum: [REFERENCE] } + purpose: { type: string, maxLength: 100000 } + rules: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/ReferenceRule" } } + applyWhen: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } } + exceptions: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } } + examples: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } } + verifiedOn: { type: [string, "null"], format: date } + QuestionInput: + unevaluatedProperties: false + allOf: + - { $ref: "#/components/schemas/WorkingCopyInputBase" } + - type: object + required: [kind, questionStatus, facts, assumptions, unknowns, constraints, options, nextValidation, resolution] + properties: + kind: { type: string, enum: [QUESTION] } + questionStatus: + type: [string, "null"] + enum: [OPEN, RESOLVED, null] + description: | + Backend Inquiry lifecycle의 축약 view다. + `OPEN`은 Domain의 `OPEN`/`INVESTIGATING`/`PAUSED`를 모두 대표하므로 + 저장 시 Domain 상태를 `OPEN`으로 덮어쓰지 않는다. + `RESOLVED`로의 변경만 Resolve command로 해석하며 기존 resolve + invariant를 통과해야 한다. + facts: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } } + assumptions: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } } + unknowns: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } } + constraints: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } } + options: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/QuestionOption" } } + nextValidation: { type: string, maxLength: 100000 } + resolution: + oneOf: + - { $ref: "#/components/schemas/QuestionResolution" } + - { type: "null" } + ProjectDecisionInput: + unevaluatedProperties: false + allOf: + - { $ref: "#/components/schemas/WorkingCopyInputBase" } + - type: object + required: [kind, decisionStatus, decidedOn, statement, rationale, consequences] + properties: + kind: { type: string, enum: [PROJECT_DECISION] } + decisionStatus: + type: [string, "null"] + enum: [PROPOSED, ADOPTED, null] + description: | + UI 용어다. Backend Domain의 `ACCEPTED`/`ADOPTED` 명칭이 다르면 + mapper에서 변환하고 Domain enum을 UI 용어 때문에 변경하지 않는다. + `supersede`/`reject`는 secondary management 계약이 소유한다. + decidedOn: { type: [string, "null"], format: date } + statement: { type: string, maxLength: 100000 } + rationale: { type: string, maxLength: 100000 } + consequences: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } } + WorkingCopyInput: + oneOf: + - { $ref: "#/components/schemas/CaseInput" } + - { $ref: "#/components/schemas/ReferenceInput" } + - { $ref: "#/components/schemas/QuestionInput" } + - { $ref: "#/components/schemas/ProjectDecisionInput" } + discriminator: + propertyName: kind + mapping: + CASE: "#/components/schemas/CaseInput" + REFERENCE: "#/components/schemas/ReferenceInput" + QUESTION: "#/components/schemas/QuestionInput" + PROJECT_DECISION: "#/components/schemas/ProjectDecisionInput" + + # ------------------------------------------------------- working copy output + WorkingCopyBase: + allOf: + - { $ref: "#/components/schemas/WorkingCopyInputBase" } + - type: object + required: [id, version, relations, updatedAt] + properties: + id: + type: string + format: uuid + description: source aggregate id를 그대로 사용한다. 별도 Studio surrogate id를 만들지 않는다. + version: { type: integer, minimum: 1 } + relations: { type: array, maxItems: 20, items: { $ref: "#/components/schemas/Relation" } } + updatedAt: { type: string, format: date-time } + CaseWorkingCopy: + unevaluatedProperties: false + allOf: + - { $ref: "#/components/schemas/WorkingCopyBase" } + - type: object + required: [kind, problem, conclusion, environment, reproduction, lastVerifiedOn, bodyMarkdown] + properties: + kind: { type: string, enum: [CASE] } + problem: { type: string, maxLength: 100000 } + conclusion: { type: string, maxLength: 100000 } + environment: { type: string, maxLength: 100000 } + reproduction: { type: string, maxLength: 100000 } + lastVerifiedOn: { type: [string, "null"], format: date } + bodyMarkdown: { type: string, maxLength: 100000 } + ReferenceWorkingCopy: + unevaluatedProperties: false + allOf: + - { $ref: "#/components/schemas/WorkingCopyBase" } + - type: object + required: [kind, purpose, rules, applyWhen, exceptions, examples, verifiedOn] + properties: + kind: { type: string, enum: [REFERENCE] } + purpose: { type: string, maxLength: 100000 } + rules: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/ReferenceRule" } } + applyWhen: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } } + exceptions: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } } + examples: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } } + verifiedOn: { type: [string, "null"], format: date } + QuestionWorkingCopy: + unevaluatedProperties: false + allOf: + - { $ref: "#/components/schemas/WorkingCopyBase" } + - type: object + required: [kind, questionStatus, facts, assumptions, unknowns, constraints, options, nextValidation, resolution] + properties: + kind: { type: string, enum: [QUESTION] } + questionStatus: { type: [string, "null"], enum: [OPEN, RESOLVED, null] } + facts: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } } + assumptions: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } } + unknowns: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } } + constraints: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } } + options: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/QuestionOption" } } + nextValidation: { type: string, maxLength: 100000 } + resolution: + oneOf: + - { $ref: "#/components/schemas/QuestionResolution" } + - { type: "null" } + ProjectDecisionWorkingCopy: + unevaluatedProperties: false + allOf: + - { $ref: "#/components/schemas/WorkingCopyBase" } + - type: object + required: [kind, decisionStatus, decidedOn, statement, rationale, consequences] + properties: + kind: { type: string, enum: [PROJECT_DECISION] } + decisionStatus: { type: [string, "null"], enum: [PROPOSED, ADOPTED, null] } + decidedOn: { type: [string, "null"], format: date } + statement: { type: string, maxLength: 100000 } + rationale: { type: string, maxLength: 100000 } + consequences: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } } + WorkingCopy: + description: | + API union이다. DB에 `working_copy` 범용 테이블을 만들지 않는다. + + ```text + WorkingCopy + = CaseWorkingCopy + | ReferenceWorkingCopy + | QuestionWorkingCopy + | ProjectDecisionWorkingCopy + ``` + oneOf: + - { $ref: "#/components/schemas/CaseWorkingCopy" } + - { $ref: "#/components/schemas/ReferenceWorkingCopy" } + - { $ref: "#/components/schemas/QuestionWorkingCopy" } + - { $ref: "#/components/schemas/ProjectDecisionWorkingCopy" } + discriminator: + propertyName: kind + mapping: + CASE: "#/components/schemas/CaseWorkingCopy" + REFERENCE: "#/components/schemas/ReferenceWorkingCopy" + QUESTION: "#/components/schemas/QuestionWorkingCopy" + PROJECT_DECISION: "#/components/schemas/ProjectDecisionWorkingCopy" + + # -------------------------------------------------------------- commands + CreateDocumentInput: { $ref: "#/components/schemas/WorkingCopyInput" } + SaveDocumentCommand: + type: object + additionalProperties: false + required: [expectedVersion, document] + properties: + expectedVersion: { type: integer, minimum: 1 } + document: { $ref: "#/components/schemas/WorkingCopyInput" } + ValidateDocumentCommand: + type: object + additionalProperties: false + required: [expectedVersion] + properties: { expectedVersion: { type: integer, minimum: 1 } } + CreatePreviewCommand: + type: object + additionalProperties: false + required: [expectedVersion, validationId] + properties: + expectedVersion: { type: integer, minimum: 1 } + validationId: { type: string, format: uuid } + PublishDocumentCommand: + type: object + additionalProperties: false + required: [expectedVersion, validationId, previewId, acknowledgedWarningCodes] + properties: + expectedVersion: { type: integer, minimum: 1 } + validationId: { type: string, format: uuid } + previewId: { type: string, format: uuid } + acknowledgedWarningCodes: + type: array + uniqueItems: true + maxItems: 200 + items: { type: string, minLength: 1, maxLength: 100 } + description: 현재 Validation의 WARNING code 집합을 모두 덮지 못하면 `WARNING_ACKNOWLEDGEMENT_REQUIRED`로 거절한다. + UnpublishCommand: + type: object + additionalProperties: false + required: [expectedPublicationRevision] + properties: { expectedPublicationRevision: { type: integer, minimum: 1 } } + + # ------------------------------------------------------------- validation + ValidationIssue: + type: object + additionalProperties: false + required: [code, severity, path, message] + properties: + code: { type: string, minLength: 1, maxLength: 100 } + severity: { type: string, enum: [ERROR, WARNING] } + path: + type: string + pattern: "^(?:/(?:[^~/]|~0|~1)*)*$" + description: JSON Pointer to the affected field + message: { type: string, minLength: 1, maxLength: 1000 } + ValidationReport: + type: object + additionalProperties: false + description: | + 일급 application artifact다. 실행 결과를 그때그때 반환하고 버리지 않고 + `studio_validation`에 영속한다. + required: [validationId, documentId, validatedVersion, status, issues, validatedAt, validUntil, dependencyRevision] + properties: + validationId: { type: string, format: uuid } + documentId: { type: string, format: uuid } + validatedVersion: { type: integer, minimum: 1 } + status: { type: string, enum: [INVALID, WARNINGS, VALID] } + issues: { type: array, maxItems: 200, items: { $ref: "#/components/schemas/ValidationIssue" } } + validatedAt: { type: string, format: date-time } + validUntil: { type: string, format: date-time } + dependencyRevision: { $ref: "#/components/schemas/DependencyRevision" } + DependencyRevision: + type: string + minLength: 1 + maxLength: 200 + description: | + 검증 결과에 영향을 주는 외부 의존 상태를 대표하는 값이다. + + ```text + Topic/Project 존재와 publishability + relation target 상태 + Asset READY/QUARANTINED 상태 + slug/route ownership + catalog revision + 필요 시 renderer/content-format version + ``` + + 모든 테이블의 global counter일 필요는 없다. 검증에 사용한 dependency + identity/version을 정규화해 hash로 만들 수 있다. Publish 시 동일 + dependency set을 다시 계산해 값이 다르면 `VALIDATION_STALE`로 거절한다. + + # ------------------------------------------------------- public render model + DisplayTarget: + type: object + additionalProperties: false + required: [id, label, publicPath] + properties: + id: { type: string, format: uuid } + label: { type: string, minLength: 1, maxLength: 200 } + publicPath: { type: [string, "null"], maxLength: 500 } + ResolvedRelation: + type: object + additionalProperties: false + required: [id, targetId, targetKind, title, publicPath, reason, order] + properties: + id: { type: string, format: uuid } + targetId: { type: string, format: uuid } + targetKind: { type: string, enum: [CASE, REFERENCE, QUESTION, PROJECT, PROJECT_DECISION] } + title: { type: string, minLength: 1, maxLength: 200 } + publicPath: { type: [string, "null"], maxLength: 500 } + reason: { type: string, maxLength: 100000 } + order: { type: integer, minimum: 0 } + RenderContext: + type: object + additionalProperties: false + required: [generatedAt, dependencyRevision] + properties: + generatedAt: { type: string, format: date-time } + dependencyRevision: { $ref: "#/components/schemas/DependencyRevision" } + PublicRenderModelBase: + type: object + required: [kind, slug, title, summary, publicPath, topic, project, relations, renderContext] + properties: + kind: { $ref: "#/components/schemas/RecordKind" } + slug: { type: string, minLength: 3, maxLength: 100, pattern: "^[a-z0-9]+(?:-[a-z0-9]+)*$" } + title: { type: string, minLength: 1, maxLength: 120 } + summary: { type: string, minLength: 1, maxLength: 300 } + publicPath: { type: string, minLength: 1, maxLength: 500 } + topic: { $ref: "#/components/schemas/DisplayTarget" } + project: + oneOf: + - { $ref: "#/components/schemas/DisplayTarget" } + - { type: "null" } + relations: { type: array, maxItems: 20, items: { $ref: "#/components/schemas/ResolvedRelation" } } + renderContext: { $ref: "#/components/schemas/RenderContext" } + InlineText: + type: object + additionalProperties: false + required: [type, text] + properties: + type: { type: string, enum: [TEXT] } + text: { type: string, minLength: 1, maxLength: 100000 } + InlineContainer: + type: object + required: [type, children] + properties: + type: { type: string, enum: [EMPHASIS, STRONG] } + children: { type: array, maxItems: 1000, items: { $ref: "#/components/schemas/Inline" } } + InlineCode: + type: object + additionalProperties: false + required: [type, code] + properties: + type: { type: string, enum: [INLINE_CODE] } + code: { type: string, minLength: 1, maxLength: 100000 } + InlineLink: + type: object + additionalProperties: false + required: [type, label, href] + properties: + type: { type: string, enum: [LINK] } + label: { type: string, minLength: 1, maxLength: 100000 } + href: { type: string, format: uri, maxLength: 2000 } + InlineStatus: + type: object + additionalProperties: false + required: [type, label, tone] + properties: + type: { type: string, enum: [STATUS] } + label: { type: string, minLength: 1, maxLength: 120 } + tone: { type: string, enum: [warning, evidence, neutral] } + Inline: + oneOf: + - { $ref: "#/components/schemas/InlineText" } + - { $ref: "#/components/schemas/InlineEmphasis" } + - { $ref: "#/components/schemas/InlineStrong" } + - { $ref: "#/components/schemas/InlineCode" } + - { $ref: "#/components/schemas/InlineLink" } + - { $ref: "#/components/schemas/InlineStatus" } + discriminator: + propertyName: type + mapping: + TEXT: "#/components/schemas/InlineText" + EMPHASIS: "#/components/schemas/InlineEmphasis" + STRONG: "#/components/schemas/InlineStrong" + INLINE_CODE: "#/components/schemas/InlineCode" + LINK: "#/components/schemas/InlineLink" + STATUS: "#/components/schemas/InlineStatus" + InlineEmphasis: + unevaluatedProperties: false + allOf: + - { $ref: "#/components/schemas/InlineContainer" } + - { type: object, properties: { type: { type: string, enum: [EMPHASIS] } } } + InlineStrong: + unevaluatedProperties: false + allOf: + - { $ref: "#/components/schemas/InlineContainer" } + - { type: object, properties: { type: { type: string, enum: [STRONG] } } } + HeadingBlock: + type: object + additionalProperties: false + required: [type, id, level, content] + properties: + type: { type: string, enum: [HEADING] } + id: { type: string, minLength: 1, maxLength: 200 } + level: { type: integer, minimum: 2, maximum: 4 } + content: { type: array, maxItems: 1000, items: { $ref: "#/components/schemas/Inline" } } + ParagraphBlock: + type: object + additionalProperties: false + required: [type, content] + properties: + type: { type: string, enum: [PARAGRAPH] } + content: { type: array, maxItems: 1000, items: { $ref: "#/components/schemas/Inline" } } + BlockquoteBlock: + type: object + additionalProperties: false + required: [type, content] + properties: + type: { type: string, enum: [BLOCKQUOTE] } + content: { type: array, maxItems: 1000, items: { $ref: "#/components/schemas/Inline" } } + ListItem: + type: object + additionalProperties: false + required: [id, content] + properties: + id: { type: string, minLength: 1, maxLength: 200 } + content: { type: array, maxItems: 1000, items: { $ref: "#/components/schemas/Inline" } } + ListBlockBase: + type: object + required: [type, items] + properties: + type: { type: string, enum: [UNORDERED_LIST, ORDERED_LIST] } + items: { type: array, maxItems: 1000, items: { $ref: "#/components/schemas/ListItem" } } + UnorderedListBlock: + unevaluatedProperties: false + allOf: + - { $ref: "#/components/schemas/ListBlockBase" } + - { type: object, properties: { type: { type: string, enum: [UNORDERED_LIST] } } } + OrderedListBlock: + unevaluatedProperties: false + allOf: + - { $ref: "#/components/schemas/ListBlockBase" } + - { type: object, properties: { type: { type: string, enum: [ORDERED_LIST] } } } + CodeBlock: + type: object + additionalProperties: false + required: [type, code, language, label] + properties: + type: { type: string, enum: [CODE_BLOCK] } + code: { type: string, maxLength: 100000 } + language: { type: [string, "null"], maxLength: 100 } + label: { type: [string, "null"], maxLength: 200 } + DataTableColumn: + type: object + additionalProperties: false + required: [id, label, alignment] + properties: + id: { type: string, minLength: 1, maxLength: 200 } + label: { type: string, minLength: 1, maxLength: 500 } + alignment: { type: string, enum: [LEFT, CENTER, RIGHT] } + DataTableCell: + type: object + additionalProperties: false + required: [columnId, content] + properties: + columnId: { type: string, minLength: 1, maxLength: 200 } + content: { type: array, maxItems: 1000, items: { $ref: "#/components/schemas/Inline" } } + DataTableRow: + type: object + additionalProperties: false + required: [id, cells] + properties: + id: { type: string, minLength: 1, maxLength: 200 } + cells: { type: array, maxItems: 100, items: { $ref: "#/components/schemas/DataTableCell" } } + DataTableBlock: + type: object + additionalProperties: false + required: [type, id, caption, rowHeaderColumn, columns, rows] + properties: + type: { type: string, enum: [DATA_TABLE] } + id: { type: string, minLength: 1, maxLength: 200 } + caption: { type: string, maxLength: 1000 } + rowHeaderColumn: { type: [integer, "null"], minimum: 1 } + columns: { type: array, minItems: 1, maxItems: 100, items: { $ref: "#/components/schemas/DataTableColumn" } } + rows: { type: array, maxItems: 1000, items: { $ref: "#/components/schemas/DataTableRow" } } + CalloutBlock: + type: object + additionalProperties: false + required: [type, tone, label, content] + properties: + type: { type: string, enum: [CALLOUT] } + tone: { type: string, enum: [warning, info] } + label: { type: string, maxLength: 200 } + content: { type: array, maxItems: 1000, items: { $ref: "#/components/schemas/Inline" } } + EvidenceFigureBlock: + type: object + additionalProperties: false + required: [type, key, alt, caption, zoom, asset] + description: | + `key`는 managed `asset_key`다. object storage key나 raw URL이 아니다. + + ```text + asset_key → Asset lookup → current approved delivery path + ``` + + `alt`는 빈 문자열을 허용한다. 빈 alt 자체를 syntax error로 차단하지 않고 + Publication Validation이 Asset metadata와 함께 의미 검증한다. + + ```text + Asset decorative=false + 사용 위치 alt 비어 있음 → PublishValidationFailed + Asset decorative=true → alt="" 허용 + ``` + properties: + type: { type: string, enum: [EVIDENCE_FIGURE] } + key: { type: string, minLength: 1, maxLength: 200 } + alt: { type: string, maxLength: 1000 } + caption: { type: string, maxLength: 1000 } + zoom: { type: boolean } + asset: { $ref: "#/components/schemas/ResolvedAsset" } + ResolvedAsset: + type: object + additionalProperties: false + description: | + renderer가 사용하는 Asset descriptor다. Preview/Public/Snapshot이 동일한 + resolver를 통해 동일 semantic output을 만들어야 한다. + SVG도 URL 기반 ``로 렌더링하며 원문을 inline하지 않는다. + required: [assetId, assetKey, mediaType, publicPath, width, height, decorative] + properties: + assetId: { type: string, format: uuid } + assetKey: { type: string, minLength: 1, maxLength: 200 } + mediaType: { type: string, minLength: 1, maxLength: 200 } + publicPath: { type: string, minLength: 1, maxLength: 500 } + width: { type: [integer, "null"], minimum: 1 } + height: { type: [integer, "null"], minimum: 1 } + decorative: { type: boolean } + CaseRenderBlock: + oneOf: + - { $ref: "#/components/schemas/HeadingBlock" } + - { $ref: "#/components/schemas/ParagraphBlock" } + - { $ref: "#/components/schemas/BlockquoteBlock" } + - { $ref: "#/components/schemas/UnorderedListBlock" } + - { $ref: "#/components/schemas/OrderedListBlock" } + - { $ref: "#/components/schemas/CodeBlock" } + - { $ref: "#/components/schemas/DataTableBlock" } + - { $ref: "#/components/schemas/CalloutBlock" } + - { $ref: "#/components/schemas/EvidenceFigureBlock" } + discriminator: + propertyName: type + mapping: + HEADING: "#/components/schemas/HeadingBlock" + PARAGRAPH: "#/components/schemas/ParagraphBlock" + BLOCKQUOTE: "#/components/schemas/BlockquoteBlock" + UNORDERED_LIST: "#/components/schemas/UnorderedListBlock" + ORDERED_LIST: "#/components/schemas/OrderedListBlock" + CODE_BLOCK: "#/components/schemas/CodeBlock" + DATA_TABLE: "#/components/schemas/DataTableBlock" + CALLOUT: "#/components/schemas/CalloutBlock" + EVIDENCE_FIGURE: "#/components/schemas/EvidenceFigureBlock" + CasePublicRenderModel: + unevaluatedProperties: false + allOf: + - { $ref: "#/components/schemas/PublicRenderModelBase" } + - type: object + required: [kind, problem, conclusion, environment, reproduction, lastVerifiedOn, bodyBlocks] + properties: + kind: { type: string, enum: [CASE] } + problem: { type: string, minLength: 1, maxLength: 100000 } + conclusion: { type: string, minLength: 1, maxLength: 100000 } + environment: { type: string, maxLength: 100000 } + reproduction: { type: string, maxLength: 100000 } + lastVerifiedOn: { type: string, format: date } + bodyBlocks: { type: array, maxItems: 10000, items: { $ref: "#/components/schemas/CaseRenderBlock" } } + ReferencePublicRenderModel: + unevaluatedProperties: false + allOf: + - { $ref: "#/components/schemas/PublicRenderModelBase" } + - type: object + required: [kind, purpose, rules, applyWhen, exceptions, examples, verifiedOn] + properties: + kind: { type: string, enum: [REFERENCE] } + purpose: { type: string, minLength: 1, maxLength: 100000 } + rules: { type: array, minItems: 1, maxItems: 50, items: { $ref: "#/components/schemas/ReferenceRule" } } + applyWhen: { type: array, minItems: 1, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } } + exceptions: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } } + examples: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } } + verifiedOn: { type: string, format: date } + ResolvedQuestionResolution: + type: object + additionalProperties: false + required: [summary, evidenceTarget, linkLabel] + properties: + summary: { type: string, minLength: 1, maxLength: 100000 } + evidenceTarget: { $ref: "#/components/schemas/DisplayTarget" } + linkLabel: { type: string, minLength: 1, maxLength: 120 } + QuestionPublicRenderModel: + unevaluatedProperties: false + allOf: + - { $ref: "#/components/schemas/PublicRenderModelBase" } + - type: object + required: [kind, status, facts, assumptions, unknowns, constraints, options, nextValidation, resolution] + properties: + kind: { type: string, enum: [QUESTION] } + status: + type: string + enum: [OPEN, RESOLVED] + description: 공개 표현용 축약 상태다. Domain의 `INVESTIGATING`/`PAUSED`는 `OPEN`으로 표현된다. + facts: { type: array, minItems: 1, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } } + assumptions: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } } + unknowns: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } } + constraints: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } } + options: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/QuestionOption" } } + nextValidation: { type: string, minLength: 1, maxLength: 100000 } + resolution: + oneOf: + - { $ref: "#/components/schemas/ResolvedQuestionResolution" } + - { type: "null" } + ProjectDecisionPublicRenderModel: + unevaluatedProperties: false + allOf: + - { $ref: "#/components/schemas/PublicRenderModelBase" } + - type: object + required: [kind, status, decidedOn, statement, rationale, consequences] + properties: + kind: { type: string, enum: [PROJECT_DECISION] } + status: { type: string, enum: [PROPOSED, ADOPTED] } + decidedOn: { type: string, format: date } + statement: { type: string, minLength: 1, maxLength: 100000 } + rationale: { type: string, minLength: 1, maxLength: 100000 } + consequences: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } } + PublicRenderModel: + oneOf: + - { $ref: "#/components/schemas/CasePublicRenderModel" } + - { $ref: "#/components/schemas/ReferencePublicRenderModel" } + - { $ref: "#/components/schemas/QuestionPublicRenderModel" } + - { $ref: "#/components/schemas/ProjectDecisionPublicRenderModel" } + discriminator: + propertyName: kind + mapping: + CASE: "#/components/schemas/CasePublicRenderModel" + REFERENCE: "#/components/schemas/ReferencePublicRenderModel" + QUESTION: "#/components/schemas/QuestionPublicRenderModel" + PROJECT_DECISION: "#/components/schemas/ProjectDecisionPublicRenderModel" + + # ---------------------------------------------------------------- preview + PublicPreview: + type: object + additionalProperties: false + required: [previewId, documentId, previewVersion, validationId, dependencyRevision, createdAt, expiresAt, renderModel] + properties: + previewId: { type: string, format: uuid } + documentId: { type: string, format: uuid } + previewVersion: { type: integer, minimum: 1 } + validationId: { type: string, format: uuid } + dependencyRevision: { $ref: "#/components/schemas/DependencyRevision" } + createdAt: { type: string, format: date-time } + expiresAt: { type: string, format: date-time } + renderModel: { $ref: "#/components/schemas/PublicRenderModel" } + PreviewDetail: + type: object + additionalProperties: false + required: [preview, state, currentDocumentVersion, currentValidationId] + properties: + preview: { $ref: "#/components/schemas/PublicPreview" } + state: + type: string + enum: [CURRENT, STALE, EXPIRED] + description: | + 서버가 계산한다. + `STALE`은 working version 또는 dependency revision이 달라진 경우, + `EXPIRED`는 `expiresAt`이 지난 경우다. + currentDocumentVersion: { type: integer, minimum: 1 } + currentValidationId: { type: [string, "null"], format: uuid } + + # ------------------------------------------------------------ publication + PublicationAggregate: + type: object + additionalProperties: false + description: 현재 게시 상태다. 게시 이력(`PublicationEvent`)과 구분한다. + required: [publicationId, documentId, status, publishedVersion, publicationRevision, latestEventId, publicPath, updatedAt] + properties: + publicationId: { type: string, format: uuid } + documentId: { type: string, format: uuid } + status: { type: string, enum: [PUBLISHED, UNPUBLISHED] } + publishedVersion: { type: integer, minimum: 1 } + publicationRevision: { type: integer, minimum: 1 } + latestEventId: { type: string, format: uuid } + publicPath: { type: string, minLength: 1, maxLength: 500 } + updatedAt: { type: string, format: date-time } + PublicationEventType: { type: string, enum: [PUBLISHED, REPUBLISHED, UNPUBLISHED] } + PublicationEvent: + type: object + additionalProperties: false + description: 불변 이력이다. 생성 후 수정하지 않는다. + required: [publicationEventId, publicationId, documentId, type, occurredAt, publishedVersion, sourcePublishedEventId, snapshotAvailable] + properties: + publicationEventId: { type: string, format: uuid } + publicationId: { type: string, format: uuid } + documentId: { type: string, format: uuid } + type: { $ref: "#/components/schemas/PublicationEventType" } + occurredAt: { type: string, format: date-time } + publishedVersion: { type: integer, minimum: 1 } + sourcePublishedEventId: + type: [string, "null"] + format: uuid + description: "`UNPUBLISHED` Event가 참조하는 마지막 공개 Snapshot의 Event id다." + snapshotAvailable: { type: boolean } + PublicationSnapshot: + type: object + additionalProperties: false + description: | + `PUBLISHED`/`REPUBLISHED` 시점의 불변 `PublicRenderModel`이다. + 현재 source에서 재생성하지 않는다. + required: [event, renderModel, contentFormatVersion, rendererContractVersion] + properties: + event: { $ref: "#/components/schemas/PublicationEvent" } + renderModel: { $ref: "#/components/schemas/PublicRenderModel" } + contentFormatVersion: { type: string, minLength: 1, maxLength: 50 } + rendererContractVersion: { type: string, minLength: 1, maxLength: 50 } + PublishResult: + type: object + additionalProperties: false + required: [publication, event] + properties: + publication: { $ref: "#/components/schemas/PublicationAggregate" } + event: { $ref: "#/components/schemas/PublicationEvent" } + + # ---------------------------------------------------------------- listing + DocumentSummary: + type: object + additionalProperties: false + required: [id, title, kind, project, updatedAt, publicationStatus, publishedVersion, hasUnpublishedChanges, nextAction] + properties: + id: { type: string, format: uuid } + title: { type: string, maxLength: 120 } + kind: { $ref: "#/components/schemas/RecordKind" } + project: + oneOf: + - { $ref: "#/components/schemas/DisplayTarget" } + - { type: "null" } + updatedAt: { type: string, format: date-time } + publicationStatus: { $ref: "#/components/schemas/PublicationStatus" } + publishedVersion: { type: [integer, "null"], minimum: 1 } + hasUnpublishedChanges: + type: boolean + description: "`currentWorkingVersion != currentPublication.publishedVersion`. 게시 취소 상태에서도 과거 publishedVersion과 비교한다." + nextAction: { $ref: "#/components/schemas/NextAction" } + PublicationAction: { type: string, enum: [VIEW_SNAPSHOT, VIEW_SOURCE_SNAPSHOT, UNPUBLISH] } + PublicationListItem: + type: object + additionalProperties: false + required: [event, publication, document, availableActions] + properties: + event: { $ref: "#/components/schemas/PublicationEvent" } + publication: { $ref: "#/components/schemas/PublicationAggregate" } + document: { $ref: "#/components/schemas/DocumentSummary" } + availableActions: { type: array, uniqueItems: true, maxItems: 3, items: { $ref: "#/components/schemas/PublicationAction" } } + WorkingCopyDetail: + type: object + additionalProperties: false + required: [document, currentValidation, latestPreview, currentPublication, dependencyRevision, nextAction] + properties: + document: { $ref: "#/components/schemas/WorkingCopy" } + currentValidation: + oneOf: + - { $ref: "#/components/schemas/ValidationReport" } + - { type: "null" } + latestPreview: + oneOf: + - { $ref: "#/components/schemas/PublicPreview" } + - { type: "null" } + currentPublication: + oneOf: + - { $ref: "#/components/schemas/PublicationAggregate" } + - { type: "null" } + dependencyRevision: { $ref: "#/components/schemas/DependencyRevision" } + nextAction: { $ref: "#/components/schemas/NextAction" } + StudioDashboard: + type: object + additionalProperties: false + required: [continueWriting, readyToPublish, recentPublications, totals] + properties: + continueWriting: { type: array, maxItems: 5, items: { $ref: "#/components/schemas/DocumentSummary" } } + readyToPublish: { type: array, maxItems: 5, items: { $ref: "#/components/schemas/DocumentSummary" } } + recentPublications: { type: array, maxItems: 5, items: { $ref: "#/components/schemas/PublicationListItem" } } + totals: { $ref: "#/components/schemas/DashboardTotals" } + DashboardTotals: + type: object + additionalProperties: false + required: [documents, needsValidation, readyToPublish, publications] + properties: + documents: { type: integer, minimum: 0 } + needsValidation: { type: integer, minimum: 0 } + readyToPublish: { type: integer, minimum: 0 } + publications: { type: integer, minimum: 0 } + DocumentPage: + type: object + additionalProperties: false + required: [items, nextCursor] + properties: + items: { type: array, maxItems: 100, items: { $ref: "#/components/schemas/DocumentSummary" } } + nextCursor: { type: [string, "null"], maxLength: 2000 } + PublicationPage: + type: object + additionalProperties: false + required: [items, nextCursor] + properties: + items: { type: array, maxItems: 100, items: { $ref: "#/components/schemas/PublicationListItem" } } + nextCursor: { type: [string, "null"], maxLength: 2000 } + + # ---------------------------------------------------------------- catalog + CatalogEntryType: { type: string, enum: [TOPIC, PROJECT, RELATION, EVIDENCE] } + CatalogEntry: + type: object + additionalProperties: false + required: [id, type, label, dependencyRevision] + properties: + id: { type: string, format: uuid } + type: { $ref: "#/components/schemas/CatalogEntryType" } + label: { type: string, minLength: 1, maxLength: 200 } + kind: { type: string, enum: [CASE, REFERENCE, QUESTION, PROJECT, PROJECT_DECISION] } + publicPath: { type: string, minLength: 1, maxLength: 500 } + dependencyRevision: { $ref: "#/components/schemas/DependencyRevision" } + CatalogPage: + type: object + additionalProperties: false + required: [items, nextCursor] + properties: + items: { type: array, maxItems: 100, items: { $ref: "#/components/schemas/CatalogEntry" } } + nextCursor: { type: [string, "null"], maxLength: 2000 } + + # ----------------------------------------------------------------- assets + AssetKind: { type: string, enum: [IMAGE, DIAGRAM, ATTACHMENT] } + AssetManagementStatus: + type: string + enum: [READY, ARCHIVED, REJECTED, QUARANTINED] + description: | + `READY`만 Public Preview/Publish에 사용할 수 있다. + `REJECTED`/`QUARANTINED`는 서버 검증 결과이며 클라이언트가 지정할 수 없다. + Asset: + type: object + additionalProperties: false + required: [id, assetKey, kind, mediaType, originalFilename, byteSize, width, height, altText, decorative, managementStatus, publicPath, usageCount, version, createdAt, updatedAt] + properties: + id: { type: string, format: uuid } + assetKey: + type: string + minLength: 1 + maxLength: 200 + description: | + Public content가 사용하는 안정적인 key다. object storage key나 raw URL이 + 아니다. immutable이며 공개 이력 이후 재사용을 금지한다. + kind: { $ref: "#/components/schemas/AssetKind" } + mediaType: { type: string, minLength: 1, maxLength: 200 } + originalFilename: { type: string, minLength: 1, maxLength: 500 } + byteSize: { type: integer, minimum: 0 } + width: { type: [integer, "null"], minimum: 1 } + height: { type: [integer, "null"], minimum: 1 } + altText: { type: [string, "null"], maxLength: 1000 } + decorative: { type: boolean } + managementStatus: { $ref: "#/components/schemas/AssetManagementStatus" } + publicPath: { type: [string, "null"], maxLength: 500 } + usageCount: { type: integer, minimum: 0 } + version: { type: integer, minimum: 1 } + createdAt: { type: string, format: date-time } + updatedAt: { type: string, format: date-time } + AssetUsage: + type: object + additionalProperties: false + required: [documentId, documentKind, title, published] + properties: + documentId: { type: string, format: uuid } + documentKind: { $ref: "#/components/schemas/RecordKind" } + title: { type: string, minLength: 1, maxLength: 200 } + published: { type: boolean } + AssetDetail: + type: object + additionalProperties: false + required: [asset, usages, hasPublicationHistory] + properties: + asset: { $ref: "#/components/schemas/Asset" } + usages: { type: array, maxItems: 200, items: { $ref: "#/components/schemas/AssetUsage" } } + hasPublicationHistory: + type: boolean + description: true면 hard delete를 금지하고 `ARCHIVED` 전환만 허용한다. + AssetUploadForm: + type: object + additionalProperties: false + required: [file, kind] + properties: + file: { type: string, format: binary } + kind: { $ref: "#/components/schemas/AssetKind" } + altText: { type: string, maxLength: 1000 } + decorative: { type: boolean, default: false } + UpdateAssetCommand: + type: object + additionalProperties: false + required: [expectedVersion] + properties: + expectedVersion: { type: integer, minimum: 1 } + kind: { $ref: "#/components/schemas/AssetKind" } + altText: { type: [string, "null"], maxLength: 1000 } + decorative: { type: boolean } + managementStatus: { type: string, enum: [READY, ARCHIVED] } + AssetPage: + type: object + additionalProperties: false + required: [items, nextCursor] + properties: + items: { type: array, maxItems: 100, items: { $ref: "#/components/schemas/Asset" } } + nextCursor: { type: [string, "null"], maxLength: 2000 } + + # ------------------------------------------------------------------ errors + FieldError: + type: object + additionalProperties: false + required: [path, message] + properties: + path: + type: string + pattern: "^(?:/(?:[^~/]|~0|~1)*)*$" + description: JSON Pointer to the invalid field + message: { type: string, minLength: 1, maxLength: 1000 } diff --git a/src/config/spotbugs/exclude.xml b/src/config/spotbugs/exclude.xml index 928020f..4f681e6 100644 --- a/src/config/spotbugs/exclude.xml +++ b/src/config/spotbugs/exclude.xml @@ -16,6 +16,14 @@ + + + + + + + + + + From ab0447a0f9c200506acfc2f213f8be05e664d7e2 Mon Sep 17 00:00:00 2001 From: DongHyeonka Date: Wed, 19 Aug 2026 15:56:29 +0900 Subject: [PATCH 02/10] =?UTF-8?q?fix:=20=EC=84=A4=EA=B3=84=20=EB=AC=B8?= =?UTF-8?q?=EC=84=9C=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- fileserver-superpowers-package/README.md | 25 - fileserver-superpowers-package/VALIDATION.md | 43 - .../fileserver-platform-design.md | 1893 --------- ...fileserver-platform-implementation-plan.md | 3422 ---------------- .../validate_fileserver_docs.py | 174 - httpclient-superpowers-package/README.md | 23 - httpclient-superpowers-package/VALIDATION.md | 31 - ...httpclient-platform-implementation-plan.md | 3635 ----------------- .../2026-08-08-httpclient-platform-design.md | 1956 --------- .../validate_httpclient_docs.py | 148 - redis-superpowers-package/README.md | 43 - redis-superpowers-package/VALIDATION.md | 38 - ...s-wrapper-typed-api-implementation-plan.md | 2233 ---------- ...26-08-07-redis-wrapper-typed-api-design.md | 1497 ------- scripts/verify-httpclient-docs.py | 135 - 15 files changed, 15296 deletions(-) delete mode 100644 fileserver-superpowers-package/README.md delete mode 100644 fileserver-superpowers-package/VALIDATION.md delete mode 100644 fileserver-superpowers-package/fileserver-platform-design.md delete mode 100644 fileserver-superpowers-package/fileserver-platform-implementation-plan.md delete mode 100644 fileserver-superpowers-package/validate_fileserver_docs.py delete mode 100644 httpclient-superpowers-package/README.md delete mode 100644 httpclient-superpowers-package/VALIDATION.md delete mode 100644 httpclient-superpowers-package/docs/superpowers/plans/2026-08-08-httpclient-platform-implementation-plan.md delete mode 100644 httpclient-superpowers-package/docs/superpowers/specs/2026-08-08-httpclient-platform-design.md delete mode 100644 httpclient-superpowers-package/validate_httpclient_docs.py delete mode 100644 redis-superpowers-package/README.md delete mode 100644 redis-superpowers-package/VALIDATION.md delete mode 100644 redis-superpowers-package/docs/superpowers/plans/2026-08-07-redis-wrapper-typed-api-implementation-plan.md delete mode 100644 redis-superpowers-package/docs/superpowers/specs/2026-08-07-redis-wrapper-typed-api-design.md delete mode 100755 scripts/verify-httpclient-docs.py diff --git a/fileserver-superpowers-package/README.md b/fileserver-superpowers-package/README.md deleted file mode 100644 index eda4fb6..0000000 --- a/fileserver-superpowers-package/README.md +++ /dev/null @@ -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` 방식이 권장된다. diff --git a/fileserver-superpowers-package/VALIDATION.md b/fileserver-superpowers-package/VALIDATION.md deleted file mode 100644 index 0b19a1d..0000000 --- a/fileserver-superpowers-package/VALIDATION.md +++ /dev/null @@ -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은 실행하지 않았다. -- 본 검증은 설계·계획 문서의 구조, 내부 일관성, 범위 추적성, 미확정 표식과 중복 경로를 확인한 정적 검증이다. diff --git a/fileserver-superpowers-package/fileserver-platform-design.md b/fileserver-superpowers-package/fileserver-platform-design.md deleted file mode 100644 index 7ea0daa..0000000 --- a/fileserver-superpowers-package/fileserver-platform-design.md +++ /dev/null @@ -1,1893 +0,0 @@ -# Fileserver Platform 설계서 - -**문서 상태:** 설계 확정안 -**작성 기준일:** 2026-08-07 -**입력 근거:** `Spring 기반 Fileserver 설계 심층 리서치` -**대상 저장소:** Spring 기반 Backend Skeleton - ---- - -## 1. 요약 - -이 설계는 Fileserver를 단순한 업로드·다운로드 컨트롤러가 아니라 다음 네 계층을 분리한 공통 파일 서비스 플랫폼으로 정의한다. - -1. **Content Store** — byte stream, staging, range read, publish, delete를 담당한다. -2. **Metadata Store** — 파일 상태, 소유·권한 연결 정보, 크기, digest, MIME 판정, version, lease, 만료를 관리한다. -3. **Transfer Adapter** — Spring MVC, Spring WebFlux, Nginx 위임으로 HTTP 전송을 제공한다. -4. **Verification Layer** — checksum, 형식 판정, 악성 파일 검사, quarantine을 담당한다. - -공개 API는 `fileId`와 `uploadId`만 사용한다. `Path`, 실제 파일명, 디렉터리, mount 경로, symlink와 같은 파일시스템 개념은 로컬 저장소 어댑터 밖으로 노출하지 않는다. 파일 내용과 메타데이터는 하나의 ACID transaction으로 묶을 수 없으므로 상태 머신, version, writer lease, reconciliation을 통해 일관성을 유지한다. - -최초 Stable 릴리스는 Linux 로컬 파일시스템과 인증된 Kubernetes PVC RWO를 대상으로 다음을 제공한다. - -- raw 및 multipart 단일 업로드 -- 제한형 다중 파일 업로드 -- streaming append와 SHA-256 검증 -- GET, HEAD, 단일 Range, 조건부 요청 -- 직접 전송과 Nginx 위임 -- logical delete와 비동기 physical cleanup -- MVC와 WebFlux 어댑터 -- 다중 인스턴스용 DB version·lease -- tus 1.0 별도 Stable 모듈 -- NFS·PVC RWX 제한 지원 프로파일 -- IETF resumable upload draft-12 Experimental 모듈 - ---- - -## 2. 목표와 성공 기준 - -### 2.1 목표 - -- 다양한 웹 서비스가 파일 업로드·다운로드를 즉시 사용할 수 있는 공통 기술 모듈을 제공한다. -- 로컬 디스크, PVC, NFS, 향후 Object Storage가 동일한 저장소 의미론을 공유하도록 한다. -- 최대 파일 크기에서도 JVM heap 사용량이 파일 크기에 비례하지 않도록 한다. -- 부분 파일, 경로 탈출, 권한 우회, 검사 전 공개를 구조적으로 차단한다. -- 장애 후 성공 여부가 모호한 작업을 단순 실패와 구분하고 복구할 수 있게 한다. -- 구현자가 설계 중 다시 판단하지 않도록 HTTP 계약, 상태 전이, 오류, 설정, 테스트 완료 조건을 고정한다. - -### 2.2 성공 기준 - -| 영역 | 완료 기준 | -|---|---| -| 공개 식별자 | 외부 API가 `fileId`, `uploadId`만 사용하고 실제 경로를 노출하지 않는다. | -| 업로드 | raw·multipart 스트리밍이 bounded memory로 동작하며 부분 파일은 READY 이전에 읽을 수 없다. | -| 무결성 | 서버가 actual size와 SHA-256을 계산하고 client digest가 있으면 검증한다. | -| publish | atomic move probe가 통과하거나 metadata pointer publish를 사용한다. | -| 다운로드 | `200`, `206`, `304`, `412`, `416`과 관련 header 계약을 일관되게 제공한다. | -| 보안 | traversal, symlink escape, 원본명 저장, 무조건 overwrite, 검사 전 공개를 차단한다. | -| 다중 인스턴스 | upload별 단일 writer lease와 metadata version 충돌 검사가 동작한다. | -| 장애 복구 | process kill, disk full, network interruption 후 READY invariant가 깨지지 않는다. | -| 운영 | temp, orphan, quota, disk usage, transfer, verification metric과 cleanup job을 제공한다. | -| 플랫폼 | Linux local과 지정 PVC 프로파일의 인증 테스트를 통과한다. | - ---- - -## 3. 범위 - -### 3.1 포함 범위 - -- Spring MVC와 Spring WebFlux -- blocking channel SPI와 async publisher SPI -- Linux local disk -- Kubernetes PVC RWO 인증 프로파일 -- 인증된 PVC RWX·NFSv4.1 제한 프로파일 -- Windows NTFS 호환성 CI 프로파일 -- 단일·다중 인스턴스 -- `multipart/form-data`, `application/octet-stream` -- 단일·제한형 다중 파일 업로드 -- streaming upload, cancellation, status, cleanup -- GET, HEAD, byte range, conditional request, cache header -- 애플리케이션 직접 전송, zero-copy capability, Nginx 위임 -- SHA-256, MIME·signature 검사 SPI, AV·CDR SPI -- quota reservation, concurrency limit, storage high-water 보호 -- tus 1.0 -- IETF resumable upload draft-12 Experimental -- 관리자 health, orphan scan, reconcile, cleanup, reverify -- metric, trace, audit, problem detail - -### 3.2 제외 범위 - -- 공개 API의 임의 절대·상대 경로 입력 -- 공개 디렉터리 list·scan -- symlink follow·생성 -- hard link 생성 -- 공개 재귀 삭제 -- webroot 내부 저장 -- 원본 파일명 그대로의 physical filename -- 조건 없는 overwrite -- READY 이전 다운로드 -- 하나의 offset에 대한 동시 append -- proxy가 이미 전달한 비멱등 upload의 자동 재시도 -- NFS lock만을 이용한 다중 인스턴스 정합성 -- 다른 `FileStore` 사이의 atomic move 보장 -- copy 실패 시 자동 rollback 보장 -- 모든 파일 형식의 안전성 판정 -- 임의 ZIP extraction -- Object Storage provider 구현과 signed URL -- FTP, SFTP, SMB client 기능 - ---- - -## 4. 고정 설계 결정 - -| 항목 | 결정 | -|---|---| -| 운영 우선 플랫폼 | Linux | -| Java | Java 21 | -| Spring | 6.2 최신 patch와 7.0 최신 patch를 release matrix에서 검증 | -| MVC | 정식 지원, streaming 전용 `AsyncTaskExecutor` 사용 | -| WebFlux | 정식 지원, event loop에서 blocking filesystem I/O 금지 | -| 공통 저장소 계약 | `Path`가 아니라 create·append·finalize·stat·openRead·delete 의미론 | -| metadata 기준 | 관계형 DB의 metadata가 authoritative | -| publish 기준 | same-FileStore atomic move 또는 metadata pointer publish | -| 공개 식별자 | opaque `FileId`, `UploadId` | -| physical key | 서버가 생성한 `ContentKey` | -| 원본명 | 비신뢰 표시 metadata | -| 기본 업로드 | create-only | -| overwrite | `If-Match` 또는 metadata version 필수 | -| checksum | 서버 계산 SHA-256 필수, client digest 선택 검증 | -| ETag | immutable READY bytes의 SHA-256 strong ETag | -| private cache | `private, no-store` 기본 | -| 재개 업로드 | tus 1.0 Stable, HTTPbis draft-12 Experimental | -| 다중 append | 단일 writer lease, 병렬 업로드는 독립 part 후 concatenate 방식만 | -| 삭제 | logical delete 후 physical cleanup | -| NFS | 외부 DB version·lease와 reconciliation을 전제로 제한 지원 | -| Windows | 초기 non-blocking compatibility profile | - ---- - -## 5. 지원 매트릭스 - -### 5.1 런타임·저장소 - -| 대상 | 지원 수준 | 조건 | -|---|---|---| -| Linux ext4/XFS local | 완전 지원 | startup capability probe 통과 | -| Kubernetes PVC RWO | 조건부 완전 | 지정 CSI·StorageClass·mount option 인증 | -| Kubernetes PVC RWX | 제한 지원 | 실제 backend별 release certification | -| NFSv4.1 | 제한 지원 | DB lease·version, ambiguous completion reconciliation | -| Windows NTFS | 호환성 | nightly test, 운영 지원은 후속 확정 | -| Nginx stable | 완전 지원 | internal location과 Range 계약 인증 | -| 단일 인스턴스 | 완전 지원 | process-local serialization 가능 | -| 다중 인스턴스 | 완전 지원 조건부 | 공유 metadata DB와 writer lease 필수 | - -### 5.2 프로토콜·기능 - -| 기능 | 수준 | 모듈 | -|---|---|---| -| raw upload | Stable | `fileserver-mvc`, `fileserver-webflux` | -| multipart 단일 | Stable | MVC·WebFlux | -| multipart batch | Stable 제한형 | 별도 batch endpoint, 비원자적 결과 배열 | -| direct download | Stable | MVC·WebFlux | -| single Range | Stable | core HTTP contract | -| multi Range | Beta | 개수·overlap·총량 budget 필수 | -| Nginx delegation | Stable | `fileserver-nginx` | -| tus 1.0 | Stable 별도 모듈 | `fileserver-tus` | -| HTTPbis draft-12 | Experimental | `fileserver-resumable-httpbis-draft12` | -| NFS RWX | Limited | 인증 프로파일 | -| Windows | Compatibility | CI profile | - ---- - -## 6. 전체 아키텍처 - -```text -HTTP Client - │ - ├─ Spring MVC Adapter - ├─ Spring WebFlux Adapter - └─ tus / HTTPbis Adapter - │ - ▼ -Application Services - ├─ UploadApplicationService - ├─ FinalizeUploadService - ├─ DownloadApplicationService - ├─ FileLifecycleService - ├─ CleanupApplicationService - └─ ReconciliationService - │ - ├───────────────┐ - ▼ ▼ -Metadata Store Port Content Store Port - │ │ - ▼ ├─ Local Filesystem Adapter -JPA Metadata Adapter └─ Future Object Storage Adapter - │ - ├─ Verification Port - ├─ Authorization Port - ├─ Quota Port - └─ Observability - -Download path -Application authorization - ├─ Direct transfer - └─ Nginx X-Accel-Redirect -``` - -### 6.1 의존 방향 - -- `fileserver-core-api`는 Spring MVC, WebFlux, JPA, NIO 구현 타입에 의존하지 않는다. -- `fileserver-application`은 core port만 사용한다. -- `fileserver-storage-local`은 NIO와 local path를 캡슐화한다. -- `fileserver-metadata-jpa`는 metadata port를 구현한다. -- HTTP adapter는 application service만 호출한다. -- Nginx 모듈은 물리 경로 대신 안전한 internal URI descriptor만 생성한다. -- 검사·권한·quota 정책은 SPI로 주입하며 Fileserver가 비즈니스 규칙을 내장하지 않는다. - -### 6.2 업로드 실행 흐름 - -```text -1. 인증·기술 정책 확인 -2. quota 예약 -3. FileRecord(CREATED)와 UploadSession 생성 -4. ContentStore.createUpload(CREATE_NEW) -5. FileRecord → UPLOADING -6. stream append + actual size + SHA-256 계산 -7. channel close -8. FileRecord → UPLOADED -9. verification 실행 -10. VERIFYING / QUARANTINED / REJECTED -11. publish strategy 실행 -12. physical stat 재검증 -13. metadata pointer, size, digest, MIME, version 기록 -14. FileRecord → READY -15. quota 예약을 committed usage로 전환 -``` - -### 6.3 다운로드 실행 흐름 - -```text -1. FileId 조회 -2. 존재 은닉 정책을 포함한 authorization -3. READY 상태 확인 -4. conditional header 평가 -5. Range parsing·budget 검증 -6. transfer mode 선택 - - DIRECT - - ZERO_COPY capability - - NGINX_DELEGATED -7. 응답 header 확정 -8. bytes 전송 또는 internal redirect -9. 성공·중단·전송량 관측 -``` - ---- - -## 7. 모듈 구조 - -```text -backend-skeleton/ -├── modules/fileserver/ -│ ├── fileserver-core-api/ -│ ├── fileserver-application/ -│ ├── fileserver-metadata-jpa/ -│ ├── fileserver-storage-local/ -│ ├── fileserver-verification/ -│ ├── fileserver-mvc/ -│ ├── fileserver-webflux/ -│ ├── fileserver-nginx/ -│ ├── fileserver-admin/ -│ ├── fileserver-tus/ -│ ├── fileserver-resumable-httpbis-draft12/ -│ ├── fileserver-spring-boot-starter/ -│ └── fileserver-testkit/ -├── infra/fileserver/ -│ ├── local/ -│ ├── nginx/ -│ ├── nfs/ -│ └── kubernetes/ -└── docs/fileserver/ - ├── support-matrix.md - ├── http-contract.md - ├── storage-certification.md - ├── security.md - ├── operations.md - └── upgrade-guide.md -``` - -| 모듈 | 책임 | -|---|---| -| `fileserver-core-api` | ID, 상태, value object, port, 오류, capability | -| `fileserver-application` | upload·download·lifecycle orchestration | -| `fileserver-metadata-jpa` | metadata, lease, quota reservation persistence | -| `fileserver-storage-local` | staging, append, range read, publish, delete, probe | -| `fileserver-verification` | digest, MIME verdict, scanner pipeline | -| `fileserver-mvc` | Servlet multipart/raw/download adapter | -| `fileserver-webflux` | `PartEvent`, `DataBuffer`, reactive transfer adapter | -| `fileserver-nginx` | internal URI와 `X-Accel-Redirect` response strategy | -| `fileserver-admin` | health, orphan, reconcile, cleanup, reverify | -| `fileserver-tus` | tus 1.0 protocol adapter | -| `fileserver-resumable-httpbis-draft12` | versioned Experimental protocol adapter | -| `fileserver-spring-boot-starter` | properties, auto-configuration, startup gate | -| `fileserver-testkit` | contract, filesystem, HTTP, fault, performance harness | - ---- - -## 8. 핵심 공개 모델 - -### 8.1 식별자 - -```java -public record FileId(UUID value) { - public FileId { - Objects.requireNonNull(value, "value"); - } -} - -public record UploadId(UUID value) { - public UploadId { - Objects.requireNonNull(value, "value"); - } -} - -public record ContentKey(String value) { - public ContentKey { - if (value == null || !value.matches("[a-z0-9/_-]{16,200}")) { - throw new IllegalArgumentException("invalid content key"); - } - } -} - -public record StorageNamespace(String value) { - public StorageNamespace { - if (value == null || !value.matches("[a-z][a-z0-9-]{1,62}")) { - throw new IllegalArgumentException("invalid storage namespace"); - } - } -} -``` - -`ContentKey`는 public HTTP contract에 포함하지 않는다. `FileId`는 추측하기 어려운 ID를 사용하지만 비밀 token으로 취급하지 않으며 모든 요청에서 authorization을 수행한다. - -### 8.2 파일 상태 - -```java -public enum FileState { - CREATED, - UPLOADING, - UPLOADED, - VERIFYING, - QUARANTINED, - READY, - REJECTED, - FAILED, - DELETING, - DELETED, - EXPIRED -} -``` - -허용 전이는 `FileStateMachine` 하나에서 관리한다. persistence adapter나 controller가 상태를 직접 대입하지 않는다. - -```java -public interface FileStateMachine { - void requireTransition(FileState current, FileState target); - boolean canTransition(FileState current, FileState target); -} -``` - -### 8.3 ByteRange - -```java -public record ByteRange(long startInclusive, long endInclusive) { - public ByteRange { - if (startInclusive < 0 || endInclusive < startInclusive) { - throw new IllegalArgumentException("invalid byte range"); - } - } - - public long length() { - return Math.addExact(Math.subtractExact(endInclusive, startInclusive), 1); - } -} -``` - -HTTP suffix/open-ended Range는 HTTP adapter의 parser가 현재 representation 길이를 기준으로 위 value object로 정규화한다. - -### 8.4 파일 metadata - -```java -public record FileDescriptor( - FileId fileId, - StorageNamespace namespace, - FileState state, - String originalFilename, - String mediaType, - long size, - String sha256, - String strongEtag, - Instant publishedAt, - long version -) {} -``` - -실제 path, scanner 원문 응답, user metadata 원문은 public descriptor에 포함하지 않는다. - ---- - -## 9. 상태 머신과 invariant - -### 9.1 상태 전이 - -```text -CREATED → UPLOADING -UPLOADING → UPLOADED | FAILED | EXPIRED | DELETING -UPLOADED → VERIFYING | FAILED | DELETING -VERIFYING → READY | QUARANTINED | REJECTED | FAILED -QUARANTINED → VERIFYING | READY | REJECTED | DELETING -READY → DELETING -REJECTED → DELETING -FAILED → UPLOADING | VERIFYING | DELETING | EXPIRED -DELETING → DELETED | FAILED -EXPIRED → DELETING -``` - -`FAILED`에서의 복구 전이는 저장된 `lastErrorCode`와 recovery policy가 허용할 때만 수행한다. - -### 9.2 필수 invariant - -- READY에는 읽을 수 있는 immutable content가 존재한다. -- READY의 size와 SHA-256은 실제 bytes와 일치한다. -- READY가 아닌 레코드는 direct download와 Nginx internal mapping에서 제외된다. -- 하나의 upload에는 하나의 유효 writer lease만 존재한다. -- offset은 durable append가 확인된 byte 수만큼만 증가한다. -- client가 주장한 크기·MIME·파일명은 authoritative 값이 아니다. -- REJECTED, DELETED, EXPIRED는 public API에서 재활성화되지 않는다. -- DB와 storage가 불일치하면 READY를 추정하지 않고 recovery queue로 보낸다. -- logical delete가 성공하면 신규 download authorization은 즉시 차단된다. -- physical cleanup 실패는 DELETING 또는 FAILED 상태와 운영 경보로 남는다. - ---- - -## 10. Metadata Store 설계 - -### 10.1 Port - -```java -public interface FileMetadataStore { - FileRecord insert(FileRecordDraft draft); - Optional find(FileId fileId); - FileRecord transition( - FileId fileId, - long expectedVersion, - FileState expectedState, - FileState targetState, - FileRecordMutation mutation - ); - FileRecord markDeleting(FileId fileId, long expectedVersion); - List findRecoverable(FileRecoveryQuery query); -} - -public interface UploadSessionStore { - UploadSession create(UploadSessionDraft draft); - Optional find(UploadId uploadId); - WriterLease acquireLease( - UploadId uploadId, - String owner, - Instant now, - Duration leaseDuration, - long expectedVersion - ); - UploadSession commitOffset( - UploadId uploadId, - WriterLease lease, - long expectedOffset, - long committedOffset - ); - void releaseLease(UploadId uploadId, WriterLease lease); - List findExpired(Instant cutoff, int limit); -} -``` - -### 10.2 관계형 schema - -| Table | 핵심 컬럼 | -|---|---| -| `fs_file` | `file_id`, `namespace`, `state`, `content_key`, `original_name`, `claimed_media_type`, `verified_media_type`, `expected_size`, `actual_size`, `sha256`, `strong_etag`, `published_at`, `version`, `last_error_code`, timestamps | -| `fs_upload_session` | `upload_id`, `file_id`, `expected_length`, `committed_offset`, `protocol`, `expires_at`, `lease_owner`, `lease_until`, `version` | -| `fs_verification_result` | `file_id`, `verifier`, `verdict`, `details_code`, `started_at`, `completed_at` | -| `fs_quota_reservation` | `reservation_id`, `scope`, `reserved_bytes`, `committed_bytes`, `expires_at`, `status`, `version` | -| `fs_cleanup_item` | `cleanup_id`, `file_id`, `content_key`, `type`, `attempt`, `next_attempt_at`, `status`, `last_error_code` | - -`fs_file.version`과 `fs_upload_session.version`은 optimistic locking에 사용한다. 모든 상태 전이는 `WHERE version = ? AND state = ?` 조건을 포함한다. - -### 10.3 authoritative source - -- 공개 metadata는 `fs_file`을 기준으로 한다. -- physical `stat`은 publish 검증과 reconciliation에 사용한다. -- NFS·PVC의 timestamp는 Last-Modified의 authoritative source로 사용하지 않는다. -- `published_at`을 HTTP Last-Modified로 사용한다. - - -## 11. Content Store Port - -### 11.1 Capability - -```java -public record ContentStoreCapabilities( - boolean rangedRead, - boolean atomicCreate, - boolean atomicPublish, - boolean conditionalWrite, - boolean serverSideCopy, - boolean delegatedDownload, - boolean resumableAppend -) {} -``` - -Capability는 설정값만 읽지 않고 실제 저장소 root에서 startup probe한 결과로 생성한다. - -### 11.2 Blocking SPI - -```java -public interface BlockingContentStore { - UploadHandle createUpload(CreateContentCommand command); - - AppendResult append( - UploadHandle handle, - long expectedOffset, - ReadableByteChannel source, - long contentLength - ); - - StoredContent finalizeUpload( - UploadHandle handle, - FinalizeContentCommand command - ); - - ContentMetadata stat(ContentKey key); - - ReadableByteChannel openRead(ContentKey key, ByteRange range); - - DeleteResult delete(ContentKey key, DeletePrecondition precondition); - - ContentStoreCapabilities capabilities(); -} -``` - -### 11.3 Async SPI - -```java -public interface AsyncContentStore { - CompletionStage createUpload(CreateContentCommand command); - - CompletionStage append( - UploadHandle handle, - long expectedOffset, - Flow.Publisher content - ); - - CompletionStage finalizeUpload( - UploadHandle handle, - FinalizeContentCommand command - ); - - CompletionStage stat(ContentKey key); - - Flow.Publisher openRead(ContentKey key, ByteRange range); - - CompletionStage delete( - ContentKey key, - DeletePrecondition precondition - ); - - ContentStoreCapabilities capabilities(); -} -``` - -공통 SPI에 Spring `Resource`, `DataBuffer`, Reactor 타입을 포함하지 않는다. WebFlux adapter는 `Flow.Publisher`와 `Flux` 사이를 변환하고 pooled buffer의 수명주기를 책임진다. - -### 11.4 Capability 확장 - -```java -public interface CopyCapableContentStore { - CompletionStage copy( - ContentKey source, - ContentKey target, - CopyPrecondition precondition - ); -} - -public interface CapacityAwareContentStore { - StorageCapacity capacity(); -} - -public interface DelegatedDownloadStore { - DelegatedDownloadDescriptor createDelegation( - ContentKey key, - ByteRange range, - Duration ttl - ); -} -``` - -`copy`, capacity, delegation은 최소 Port에 강제하지 않는다. - ---- - -## 12. Local Filesystem Adapter - -### 12.1 저장 레이아웃 - -```text -${root}/ -├── staging/ -│ └── ab/cd/.part -├── content/ -│ └── ab/cd/.bin -├── quarantine/ -│ └── ab/cd/.bin -└── probe/ -``` - -- shard는 server-generated ID의 앞 2 byte씩 사용한다. -- 원본 파일명과 확장자를 physical filename에 사용하지 않는다. -- `staging`, `content`, `quarantine`은 동일 `FileStore`에 위치해야 한다. -- root는 application source, config, webroot와 분리한다. -- startup에서 디렉터리 owner·permission을 검증한다. - -### 12.2 경로 안전 규칙 - -```java -public interface PhysicalPathResolver { - Path stagingPath(UploadId uploadId); - Path contentPath(ContentKey contentKey); - Path quarantinePath(ContentKey contentKey); -} -``` - -`PhysicalPathResolver`는 `fileserver-storage-local` 내부 package-private 구현으로 둔다. 공개 module export 대상이 아니다. - -필수 검사: - -1. absolute 또는 drive-qualified 입력을 받지 않는다. -2. ID에서 만든 고정 component만 resolve한다. -3. normalize 결과가 root 아래인지 확인한다. -4. 모든 open·stat·delete에 `NOFOLLOW_LINKS`를 사용한다. -5. parent component가 symlink인지 확인한다. -6. provider가 지원하면 `SecureDirectoryStream`을 사용한다. -7. open 후 file identity와 expected parent identity를 재검증한다. - -### 12.3 staging 생성 - -- `CREATE_NEW`, `WRITE`, `NOFOLLOW_LINKS`로 연다. -- 충돌 시 새로운 storage key를 재발급하지 않고 invariant violation으로 기록한다. -- file permission은 owner read·write만 허용하는 프로파일을 기본으로 한다. -- append 전에 실제 file length와 metadata offset을 대조한다. - -### 12.4 append - -- 고정 크기 direct buffer pool 또는 heap buffer를 사용하며 파일 전체를 적재하지 않는다. -- 기본 buffer는 128 KiB다. -- `expectedOffset`이 실제 길이 또는 metadata offset과 다르면 append를 수행하지 않는다. -- 실제 수신 byte 수가 정책 최대값을 넘으면 즉시 중단한다. -- append 도중 실제 size와 SHA-256을 streaming 계산한다. -- `contentLength >= 0`이면 실제 append byte와 일치해야 한다. -- cancellation과 exception 시 channel을 닫고 session은 복구 가능한 상태로 남긴다. - -### 12.5 delete - -- symbolic link를 따라가지 않는다. -- logical delete를 먼저 수행한 뒤 cleanup worker가 physical object를 삭제한다. -- large file은 삭제 latency와 filesystem 특성을 metric으로 기록한다. -- 실제 파일이 이미 없으면 idempotent success로 처리하되 reconciliation event를 남긴다. - ---- - -## 13. Storage Capability Probe와 Startup Gate - -### 13.1 Probe 항목 - -| Probe | 통과 기준 | 실패 정책 | -|---|---|---| -| writable root | create·write·close·delete 성공 | startup 실패 | -| `CREATE_NEW` 경쟁 | 두 동시 create 중 정확히 하나 성공 | startup 실패 | -| same `FileStore` | staging·content·quarantine 동일 | startup 실패 | -| atomic move | observer가 partial target을 보지 않고 move 성공 | mode에 따라 실패 또는 pointer publish | -| replace | old 또는 new만 관측 | overwrite capability 비활성 | -| fsync profile | force 후 restart test 결과 저장 | durability 등급 표시 | -| symlink no-follow | target 접근이 차단됨 | startup 실패 | -| open-delete | OS 동작 기록 | lifecycle policy 조정 | -| capacity | usable·total 조회 가능 | admin capability 제한 | - -### 13.2 Publish mode - -```java -public enum PublishMode { - ATOMIC_MOVE_REQUIRED, - ATOMIC_MOVE_PREFERRED, - METADATA_POINTER -} -``` - -- `ATOMIC_MOVE_REQUIRED`: probe 실패 시 startup 실패 -- `ATOMIC_MOVE_PREFERRED`: 가능하면 atomic move, 불가능하면 pointer publish -- `METADATA_POINTER`: immutable physical key를 완성한 뒤 DB pointer를 READY boundary로 사용 - -기본값은 `ATOMIC_MOVE_PREFERRED`다. - -### 13.3 Runtime capability endpoint - -`GET /internal/fileserver/capabilities`는 다음을 제공한다. - -```json -{ - "storageType": "LOCAL", - "publishMode": "ATOMIC_MOVE_PREFERRED", - "rangedRead": true, - "atomicCreate": true, - "atomicPublish": true, - "conditionalWrite": true, - "delegatedDownload": true, - "resumableAppend": true, - "filesystemProfile": "linux-ext4" -} -``` - -physical root와 mount detail은 반환하지 않는다. - ---- - -## 14. Publish와 완료 처리 - -### 14.1 Atomic move strategy - -```text -staging channel close -→ optional `FileChannel.force(true)` -→ verify expected length·digest -→ target parent 준비 -→ `Files.move(staging, target, ATOMIC_MOVE)` -→ target stat -→ DB READY transition -``` - -`REPLACE_EXISTING`은 overwrite precondition이 있는 경로에서만 사용한다. create-only 경로는 target이 이미 있으면 실패한다. - -### 14.2 Metadata pointer strategy - -```text -staging write 완료 -→ immutable content key로 새 physical object 완성 -→ physical stat 검증 -→ DB transaction에서 contentKey pointer와 READY 상태 publish -→ 이전 physical object를 cleanup queue에 등록 -``` - -이 전략은 rename의 원자성 대신 metadata store transaction을 public publish boundary로 사용한다. - -### 14.3 Ambiguous completion - -다음 상황은 `AmbiguousCompletionException`으로 분류한다. - -- NFS rename request가 서버에서 처리되었을 수 있으나 응답이 유실됨 -- write·force 후 연결 또는 mount 응답이 사라짐 -- DB commit 응답을 받지 못해 상태 전이 성공 여부를 알 수 없음 - -처리 순서: - -1. operation ID와 expected physical key를 조회한다. -2. metadata version과 state를 재조회한다. -3. physical stat·size·digest를 확인한다. -4. 명백한 성공이면 성공 결과를 복원한다. -5. 명백한 미실행이면 제한적으로 재실행한다. -6. 판정 불가면 recovery queue와 `retryable=false, reconciliationRequired=true` 오류를 반환한다. - ---- - -## 15. Upload Application 설계 - -### 15.1 공개 command - -```java -public record CreateUploadRequest( - StorageNamespace namespace, - String originalFilename, - String claimedMediaType, - OptionalLong expectedLength, - Optional expectedSha256, - UploadProtocol protocol, - Instant expiresAt -) {} - -public interface UploadApplicationService { - UploadSessionView create(CreateUploadRequest request, RequestContext context); - - AppendUploadResult append( - UploadId uploadId, - long expectedOffset, - ReadableByteChannel content, - long contentLength, - RequestContext context - ); - - FileView finalizeUpload( - UploadId uploadId, - FinalizeUploadRequest request, - RequestContext context - ); - - UploadSessionView status(UploadId uploadId, RequestContext context); - - void cancel(UploadId uploadId, RequestContext context); -} -``` - -Async API는 별도 interface로 동일 의미를 제공한다. - -### 15.2 Create - -- authorization hook 실행 -- expected length가 있으면 정책 최대값 검증 -- quota reservation 생성 -- FileRecord CREATED 생성 -- UploadSession 생성 -- storage staging 생성 -- state를 UPLOADING으로 전이 -- `Location`과 current offset 0 반환 - -DB 생성 후 storage 생성이 실패하면 FileRecord를 FAILED로 전이하고 quota reservation을 해제한다. storage 생성 후 DB 응답이 모호하면 operation ID로 reconciliation한다. - -### 15.3 Append - -- upload 상태·만료 확인 -- writer lease 획득 -- metadata offset, physical length, request offset 일치 검증 -- concurrency, rate, storage high-water gate 확인 -- streaming append -- committed offset 저장 -- lease release - -append 실패 후 offset은 실제 저장이 확인된 길이까지만 증가한다. metadata offset과 physical length가 다르면 자동 append하지 않고 reconciliation으로 보낸다. - -### 15.4 Finalize - -- expected length가 있으면 committed offset과 비교 -- server SHA-256과 client digest 비교 -- state를 UPLOADED로 전이 -- verification pipeline 실행 -- verdict가 ACCEPT이면 publish -- metadata READY 전이 -- quota commit -- REJECT 또는 QUARANTINE이면 public download 금지 - -### 15.5 Multipart batch - -`POST /v1/files:batch`는 다음 계약을 사용한다. - -- 최대 part 수 기본 16 -- 각 파일은 독립 FileRecord·UploadSession -- 요청 전체 ACID 원자성은 보장하지 않는다. -- 일부 실패 시 성공 파일을 rollback하지 않는다. -- `200 OK`와 파일별 결과 배열을 반환한다. -- 총 request byte와 tenant quota를 요청 전·중 모두 검사한다. - -```json -{ - "results": [ - {"clientPartId":"a", "status":"CREATED", "fileId":"..."}, - {"clientPartId":"b", "status":"REJECTED", "problem":{"code":"FILE_TOO_LARGE"}} - ] -} -``` - ---- - -## 16. Verification Layer - -### 16.1 Port - -```java -public interface FileVerifier { - String verifierId(); - CompletionStage verify(VerificationRequest request); -} - -public record VerificationResult( - VerificationVerdict verdict, - String code, - Optional verifiedMediaType, - Map safeMetadata -) {} - -public enum VerificationVerdict { - ACCEPT, - QUARANTINE, - REJECT, - RETRY -} -``` - -### 16.2 기본 pipeline - -```text -Length verifier -→ SHA-256 verifier -→ filename policy -→ media type detector -→ signature/parser verifier -→ optional AV scanner -→ optional CDR -→ final policy combiner -``` - -- client `Content-Type`은 claimed metadata로만 저장한다. -- 단순 magic byte 일치만으로 안전 판정을 내리지 않는다. -- scanner timeout은 READY로 우회하지 않는다. -- 위험 형식은 quarantine 또는 reject한다. -- HTML, SVG 등 scriptable 문서는 기본 attachment이며 inline은 명시적 안전 프로파일에서만 허용한다. - -### 16.3 검사 비동기화 - -- 검사 시간이 짧은 프로파일은 upload request 안에서 완료하여 `201`을 반환할 수 있다. -- AV·CDR처럼 긴 검사는 `202 Accepted`와 VERIFYING 상태를 반환한다. -- READY 전환은 verification worker가 수행한다. -- retryable scanner 장애는 exponential backoff와 최대 시도 횟수를 사용한다. -- 최대 시도 초과는 FAILED 또는 QUARANTINED로 전이한다. - ---- - -## 17. Authorization과 기술 정책 Hook - -```java -public interface FileAccessPolicy { - void authorize(FileOperation operation, FileAccessSubject subject, FileDescriptor descriptor); -} - -public enum FileOperation { - CREATE, - APPEND, - FINALIZE, - READ_METADATA, - DOWNLOAD, - DELETE, - COPY, - MOVE, - ADMIN_REVERIFY, - ADMIN_FORCE_DELETE -} -``` - -Fileserver는 사용자 등급·업무 역할 같은 비즈니스 정책을 내장하지 않는다. 대신 모든 공개 operation에서 위 hook을 반드시 호출하고, starter가 no-op allow-all 구현을 운영 프로파일에서 자동 생성하지 않도록 한다. - -존재 은닉 프로파일에서는 권한 없는 file에 `404`를 반환한다. 내부 audit에는 `ACCESS_DENIED`를 기록하되 fileId·userId 원문을 metric label에 사용하지 않는다. - ---- - -## 18. Quota, Capacity와 Transfer Budget - -### 18.1 Quota Port - -```java -public interface FileQuotaService { - QuotaReservation reserve(QuotaScope scope, long expectedBytes, Duration ttl); - void extend(QuotaReservation reservation, long additionalBytes); - void commit(QuotaReservation reservation, long actualBytes); - void release(QuotaReservation reservation); -} -``` - -expected length가 없으면 프로파일별 initial reservation을 잡고 append 중 증분 예약한다. - -### 18.2 기본 운영 프로파일 - -| 설정 | Standard | Large-file | -|---|---:|---:| -| 최대 파일 | 100 MiB | 5 GiB | -| 최대 request | 116 MiB | 5 GiB + 16 MiB | -| 최대 multipart part | 16 | 16 | -| in-memory part | 512 KiB | 256 KiB | -| stream buffer | 128 KiB | 256 KiB | -| 인스턴스 동시 upload | 16 | 32 | -| 인스턴스 direct download | 64 | 128 | -| scope 동시 upload | 4 | 8 | -| temp soft limit | usable 70% | usable 70% | -| temp hard limit | usable 85% | usable 85% | -| idle read timeout | 45 s | 60 s | -| 미완료 upload TTL | 24 h | 72 h | -| multi Range 최대 개수 | 8 | 8 | - -이 값은 starter 기본값이며 운영 환경은 부하 인증 결과로 재정의한다. - -### 18.3 Admission control - -새 upload는 다음 중 하나가 발생하면 거절한다. - -- quota reservation 실패 -- storage hard high-water 초과 -- instance upload permit 고갈 -- scope 동시성 초과 -- verification queue hard limit 초과 - -soft high-water에서는 대용량 upload를 throttle하거나 `429/503`과 `Retry-After`를 반환한다. - ---- - -## 19. HTTP API - -### 19.1 공개 endpoint - -| Method·Path | 목적 | 성공 | -|---|---|---| -| `POST /v1/files` | multipart 단일 업로드 | `201` READY 또는 `202` VERIFYING | -| `POST /v1/files:raw` | raw streaming 업로드 | `201` 또는 `202` | -| `POST /v1/files:batch` | 제한형 다중 업로드 | `200` 결과 배열 | -| `PUT /v1/files/{fileId}/content` | create-only·조건부 교체 | `201` 또는 `204` | -| `GET /v1/files/{fileId}` | metadata | `200` | -| `GET /v1/files/{fileId}/content` | download | `200`, `206`, `304` | -| `HEAD /v1/files/{fileId}/content` | download metadata | `200`, `304` | -| `DELETE /v1/files/{fileId}` | logical delete | `202` 또는 `204` | -| `POST /v1/files/{fileId}:copy` | 조건부 copy | `202` | -| `POST /v1/files/{fileId}:move` | logical namespace move | `200` 또는 `204` | -| `POST /v1/uploads` | resumable resource 생성 | `201` | -| `HEAD /v1/uploads/{uploadId}` | offset 조회 | protocol별 `200/204` | -| `PATCH /v1/uploads/{uploadId}` | append | `204` | -| `DELETE /v1/uploads/{uploadId}` | cancel | `204` | - -### 19.2 Header 계약 - -| Header | 계약 | -|---|---| -| `Content-Type` | client 값은 claimed type, verified type을 별도 저장 | -| `Content-Length` | 있으면 사전 검증, 없어도 streamed hard limit 적용 | -| `Content-Disposition` | `inline` 또는 `attachment`, `filename` + `filename*` | -| `Accept-Ranges` | byte range 지원 시 `bytes` | -| `Range` | 기본 single, budget이 있는 경우 제한형 multi | -| `Content-Range` | `206` 실제 범위, `416`은 `bytes */size` | -| `ETag` | SHA-256 strong validator | -| `Last-Modified` | `publishedAt` | -| `If-None-Match` | GET·HEAD revalidation, create-only `*` | -| `If-Modified-Since` | ETag 보조 | -| `If-Match` | overwrite·delete lost-update 방지 | -| `If-Range` | validator 일치 시에만 partial | -| `Cache-Control` | private 기본 `private, no-store` | -| `Content-Digest` | 실제 HTTP message content digest | -| `Repr-Digest` | 전체 representation digest 선택 제공 | -| `Location` | 생성된 file·upload resource | -| `Retry-After` | `429`, `503`, 장기 검사의 polling 힌트 | -| `X-Accel-Redirect` | Nginx 내부 응답 전용 | - -### 19.3 상태 코드 - -| Status | 조건 | -|---:|---| -| `200` | metadata, 전체 GET, batch result | -| `201` | file 또는 upload 생성 | -| `202` | 검사 또는 physical cleanup 비동기 | -| `204` | append, cancel, body 없는 update | -| `206` | satisfiable Range | -| `304` | GET·HEAD validator 일치 | -| `400` | 잘못된 header·요청 조합 | -| `401` | 인증 없음 | -| `403/404` | 접근 거부 또는 존재 은닉 | -| `409` | 상태·offset·lease 충돌 | -| `410` | 만료 upload | -| `411` | `require-content-length=true` 프로파일 | -| `412` | precondition 실패 | -| `413` | 크기·quota 정책 위반 | -| `415` | 허용하지 않는 upload media type | -| `416` | 만족 불가능 Range | -| `422` | digest·signature·scanner reject | -| `429` | 동시성·rate limit | -| `503` | storage·scanner unavailable | -| `504` | downstream timeout | -| `507` | 저장공간 부족 | - ---- - -## 20. Range와 Conditional Request - -### 20.1 Range parser - -```java -public interface HttpRangeResolver { - ResolvedRanges resolve(String rangeHeader, long representationLength, RangeBudget budget); -} - -public record RangeBudget( - int maxRanges, - long maxTotalBytes, - boolean mergeOverlaps -) {} -``` - -기본 public 다운로드는 single Range만 허용한다. multi Range를 활성화한 profile에서는 최대 8개, overlap merge 후 총 byte가 representation 길이 이하인 경우만 허용한다. - -### 20.2 응답 결정 순서 - -```text -authorization -→ READY 확인 -→ current ETag·Last-Modified 계산 -→ If-Match / If-Unmodified-Since -→ If-None-Match / If-Modified-Since -→ Range parse -→ If-Range 평가 -→ 200 / 206 / 304 / 412 / 416 결정 -``` - -`If-Range`가 불일치하면 Range를 무시하고 전체 `200`을 반환한다. - -### 20.3 ETag와 digest - -- stored SHA-256을 quoted strong ETag로 사용한다. -- metadata-only 변경은 representation ETag를 바꾸지 않는다. -- `Content-Digest`는 전송 bytes 기준이다. -- full response에서는 stored SHA-256을 재사용할 수 있다. -- partial response에서는 해당 range digest를 streaming 계산하거나 기능을 비활성화한다. -- 전체 representation digest가 필요하면 `Repr-Digest`를 제공한다. - - -## 21. Spring MVC Adapter - -### 21.1 Upload - -- `MultipartFile#getBytes()`를 사용하지 않는다. -- raw upload는 request input stream을 `ReadableByteChannel`로 변환한다. -- multipart는 container threshold와 temp directory를 starter가 명시적으로 설정한다. -- upload request thread가 storage write를 장시간 점유하지 않도록 전용 executor를 사용한다. -- 기본 executor는 bounded queue와 rejection policy를 가진다. -- request cancellation과 client disconnect를 application service에 전달한다. - -### 21.2 Download - -전송 전략은 다음 순서로 선택한다. - -1. Nginx 위임이 활성화되고 threshold 이상이면 delegation -2. local `Path`를 안전하게 반환할 수 있고 zero-copy 조건이 맞으면 zero-copy capability -3. 그 외 `StreamingResponseBody` - -Range 처리는 core HTTP contract가 결정한다. Spring의 자동 Range 지원에만 의존하지 않고 MVC와 WebFlux가 같은 결과를 반환하도록 공통 resolver를 사용한다. `InputStreamResource`는 반복 가능한 Range resource로 사용하지 않는다. - -### 21.3 Executor - -```java -public record MvcTransferExecutorProperties( - int coreThreads, - int maxThreads, - int queueCapacity, - Duration shutdownTimeout -) {} -``` - -기본값: - -```text -coreThreads=8 -maxThreads=32 -queueCapacity=64 -shutdownTimeout=30s -``` - -queue가 가득 차면 무제한 대기하지 않고 `429` 또는 `503`으로 변환한다. - ---- - -## 22. Spring WebFlux Adapter - -### 22.1 Upload - -- raw body는 `Flux`를 순차 소비한다. -- multipart streaming은 `Flux`를 사용한다. -- pooled `DataBuffer`는 전달하거나 명시적으로 release한다. -- blocking local filesystem adapter 호출은 bounded elastic이 아니라 전용 bounded scheduler에서 실행한다. -- async store가 제공되면 event loop를 유지한 채 `Flow.Publisher`로 전달한다. -- cancellation 시 channel, lease, temp resource를 정리한다. - -### 22.2 Download - -- async store는 `Flux`로 변환한다. -- local file zero-copy가 runtime에서 가능하면 capability optimization으로 사용한다. -- Range와 conditional 결정은 MVC와 동일한 core resolver를 사용한다. -- slow client에서 in-flight buffer 수가 설정 상한을 넘지 않도록 한다. - -### 22.3 Blocking 검출 - -CI에서 BlockHound 또는 동등한 검증으로 다음을 차단한다. - -- event loop에서 `Files.*`, `FileChannel`, JDBC 호출 -- synchronous scanner 호출 -- blocking metadata repository 호출 - ---- - -## 23. Nginx 전송 위임 - -### 23.1 구조 - -```text -Client -→ GET /v1/files/{fileId}/content -→ Application authorization + READY gate -→ validated ContentKey를 internal relative URI로 변환 -→ X-Accel-Redirect: /__files/ab/cd/.bin -→ Nginx internal location -→ physical content transfer -``` - -internal URI는 절대 physical path를 포함하지 않는다. `NginxInternalUriMapper`는 검증된 `ContentKey`만 받아 `/__files/` 아래의 상대 URI를 생성한다. 이 header는 Nginx가 내부 redirect로 소비하므로 client 응답에는 노출하지 않는다. 별도 공개 signed URL을 발급하는 기능은 Object Storage 모듈의 책임으로 남긴다. - -### 23.2 정책 - -- 기본 delegation threshold는 16 MiB다. -- private file은 Nginx shared cache를 기본 비활성화한다. -- `internal` location은 외부 직접 요청을 거부한다. -- `X-Accel-Redirect`는 downstream client에 그대로 전달되지 않도록 한다. -- Range, ETag, Content-Disposition, Cache-Control 결과가 direct mode와 동일해야 한다. -- Nginx access log에 physical root와 원본 파일명을 남기지 않는다. -- mapper가 생성한 URI는 `ContentKey`의 허용 문자와 shard 규칙을 다시 검증한다. - -### 23.3 Nginx upload - -| 경로 | 기본 buffering | -|---|---| -| 작은 multipart | on 허용 | -| 대용량 raw | off | -| tus PATCH | off | -| HTTPbis PATCH | off | - -upstream 전송이 시작된 non-idempotent upload에는 `proxy_next_upstream` 재시도를 적용하지 않는다. - ---- - -## 24. 재개 가능한 업로드 - -### 24.1 공통 원칙 - -- upload resource별 single writer lease -- offset은 metadata와 physical length를 함께 검증 -- mismatch 시 body를 쓰지 않고 `409` -- 서버 재시작 후 offset reconciliation -- create 시 quota 예약 -- expiration과 cleanup -- client checksum 검증 -- upload resource는 READY file과 별도 수명주기를 가진다. - -### 24.2 tus 1.0 Stable - -지원 기능: - -- creation -- `HEAD`와 `Upload-Offset` -- `PATCH application/offset+octet-stream` -- checksum extension -- expiration extension -- termination extension -- concatenation extension은 Beta - -성공 append는 `204`와 새 `Upload-Offset`을 반환한다. offset mismatch는 resource를 변경하지 않고 `409`를 반환한다. - -### 24.3 HTTPbis draft-12 Experimental - -- module 이름과 package에 `draft12`를 포함한다. -- feature flag 없이는 bean을 생성하지 않는다. -- media type과 header를 draft version에 고정한다. -- 104 interim response 지원 여부를 runtime capability로 표시한다. -- 최종 RFC 변화에 따른 breaking change를 허용한다. -- Stable core와 endpoint namespace를 분리한다. - -### 24.4 병렬 upload - -하나의 upload offset에 여러 writer를 허용하지 않는다. 병렬 전송은 다음 구조만 제공한다. - -```text -parent upload -├─ part 1 resource -├─ part 2 resource -└─ part N resource -→ 각 part checksum 검증 -→ 순서와 총 길이 검증 -→ concatenate -→ final verification -``` - ---- - -## 25. 파일 관리 기능 - -### 25.1 stat - -공개 `stat`은 DB metadata를 반환한다. physical stat은 내부 일관성 검증에만 사용한다. - -### 25.2 delete - -```text -If-Match 검증 -→ READY/REJECTED/FAILED → DELETING -→ 공개 read 즉시 차단 -→ cleanup item 등록 -→ physical delete -→ quota 반영 -→ DELETED -``` - -### 25.3 copy - -- capability가 없으면 application-level stream copy를 사용한다. -- target은 create-only가 기본이다. -- source와 target metadata는 별도 레코드다. -- copy 실패 시 incomplete target은 cleanup queue로 보낸다. -- 자동 rollback 보장을 선언하지 않는다. - -### 25.4 move - -공개 move는 physical path move가 아니라 logical namespace·ownership metadata 변경이다. physical content는 immutable key를 유지한다. physical move는 admin maintenance에만 사용한다. - -### 25.5 list·scan - -public API에는 제공하지 않는다. admin API는 bounded pagination, prefix allowlist, rate limit, dry-run을 요구한다. - ---- - -## 26. 오류 모델과 Problem Detail - -### 26.1 예외 hierarchy - -```text -FileserverException -├─ FileNotFoundException -├─ FileAlreadyExistsException -├─ InvalidPathException -├─ PathOutsideNamespaceException -├─ FileAccessDeniedException -├─ StorageFullException -├─ QuotaExceededException -├─ FileTooLargeException -├─ UnsupportedMediaTypeException -├─ IntegrityMismatchException -├─ UploadOffsetMismatchException -├─ UploadExpiredException -├─ FileNotReadyException -├─ AtomicPublishUnsupportedException -├─ TransferTimeoutException -├─ PartialWriteException -├─ AmbiguousCompletionException -├─ StorageUnavailableException -├─ ConcurrentFileModificationException -└─ MalwareDetectedException -``` - -모든 예외는 다음 metadata를 가진다. - -```java -public record FileserverFailureContext( - String code, - boolean retryable, - boolean ambiguous, - boolean reconciliationRequired, - Optional fileId, - Optional uploadId, - OptionalLong expectedOffset, - OptionalLong currentOffset, - Optional currentState -) {} -``` - -### 26.2 Problem Detail - -```json -{ - "type": "urn:fileserver:problem:upload-offset-mismatch", - "title": "Upload offset mismatch", - "status": 409, - "code": "UPLOAD_OFFSET_MISMATCH", - "retryable": true, - "uploadId": "...", - "expectedOffset": 1048576, - "currentOffset": 524288, - "traceId": "..." -} -``` - -내부 path, mount, scanner credential, storage token을 포함하지 않는다. - ---- - -## 27. 보안 정책 - -### 27.1 위험 등급 - -| 등급 | 대상 | 정책 | -|---|---|---| -| F1 | ID 기반 create·read·delete, single Range | 기본 허용, auth·size·state gate | -| F2 | 대용량 stream, multi Range, resumable, overwrite, copy | quota·budget·precondition 필수 | -| F3 | list, capacity, orphan, force delete, reverify | internal admin plane | -| F4 | arbitrary path, symlink, recursive delete, webroot storage | 전체 차단 | - -### 27.2 필수 방어 - -- opaque ID와 server-generated physical key -- original filename sanitization -- extension allowlist가 있더라도 Content-Type을 신뢰하지 않음 -- signature/parser·scanner verdict -- executable permission 제거 -- separate mount와 webroot 밖 저장 -- size, part count, concurrency, minimum-rate 제한 -- private download cache 제한 -- READY gate -- CSRF 방어가 필요한 cookie 기반 upload endpoint -- authorization on every access -- range bomb 제한 -- ZIP/XML expanded-size 제한을 verifier에 적용 - -### 27.3 파일명 sanitization - -제거·치환 대상: - -- `/`, `\`, NUL -- control characters -- bidi override characters -- CR/LF와 quote injection -- trailing dot·space -- Windows reserved names -- UTF-8 255 byte 초과 - -sanitized name은 Content-Disposition에만 사용하며 physical path 생성에는 사용하지 않는다. - ---- - -## 28. 다중 인스턴스와 NFS - -### 28.1 Writer lease - -```java -public record WriterLease( - UploadId uploadId, - String owner, - UUID token, - Instant expiresAt, - long version -) {} -``` - -- DB conditional update로 획득한다. -- append 중 주기적으로 갱신한다. -- lease token이 다르면 offset commit을 거부한다. -- process pause로 lease가 만료된 writer는 이후 commit하지 못한다. -- local file lock이나 NFS lock을 correctness 근거로 사용하지 않는다. - -### 28.2 NFS reconciliation - -다음 이벤트에서 metadata와 physical state를 재확인한다. - -- rename timeout -- stale file handle -- mount reconnect -- attribute mismatch -- server restart -- lease takeover - -reconciliation 결과: - -```text -CONFIRMED_SUCCESS -CONFIRMED_NOT_APPLIED -RECOVERABLE_PARTIAL -QUARANTINE_REQUIRED -UNRESOLVED -``` - -`UNRESOLVED`는 자동 retry하지 않고 운영 queue로 보낸다. - -### 28.3 PVC certification unit - -지원 단위는 `PVC`라는 이름이 아니라 다음 tuple이다. - -```text -Kubernetes version -+ CSI driver/version -+ StorageClass -+ access mode -+ filesystem/backend -+ mount options -``` - ---- - -## 29. Cleanup와 Reconciliation - -### 29.1 Cleanup 종류 - -- expired upload -- cancelled staging -- failed verification content -- deleted READY content -- orphan physical object -- stale quota reservation -- abandoned lease -- previous version after pointer publish - -### 29.2 안전 규칙 - -- cleanup은 version과 lease를 확인한다. -- 기본 admin 실행은 dry-run이다. -- active upload와 동일 physical key는 삭제하지 않는다. -- batch size와 bytes budget을 둔다. -- 실패는 exponential backoff와 최대 retry를 사용한다. -- 장기 실패는 orphan metric과 alert로 승격한다. - -### 29.3 Reconciliation - -```java -public interface FileReconciliationService { - ReconciliationResult reconcile(FileId fileId); - ReconciliationBatchResult reconcileOrphans(ReconciliationQuery query); -} -``` - -자동 reconciliation이 READY를 임의 추정해서는 안 된다. size, digest, expected content key, metadata version이 모두 맞을 때만 상태를 복원한다. - ---- - -## 30. 관측성 - -### 30.1 Metric - -| Metric | 주요 tag | -|---|---| -| upload count·duration | protocol, storageType, resultCode, sizeBucket | -| download count·duration | transferMode, rangeType, resultCode, sizeBucket | -| transfer bytes | direction, storageType | -| active transfers | direction, instance | -| interruption | direction, reason | -| resumable append | protocol, result | -| offset mismatch | protocol, clientType | -| checksum failure | algorithm, stage | -| verification queue | verifier, verdict, ageBucket | -| temp·orphan bytes | storagePool, ageBucket | -| storage usage | pool, mountProfile | -| quota | scopeType, result | -| cleanup | type, result | -| delegation ratio | sizeBucket | -| access denial | operation, policyCode | - -실제 file ID, upload ID, filename, path, user ID를 metric label로 사용하지 않는다. - -### 30.2 Trace - -```text -upload.create -upload.append -upload.finalize -verify.digest -verify.media-type -verify.malware -storage.publish -storage.stat -metadata.transition -download.authorize -download.resolve-range -download.open -download.delegate -cleanup.item -reconcile.file -``` - -### 30.3 Audit - -다음 작업은 audit 대상이다. - -- overwrite -- delete·force delete -- admin reverify -- orphan reconcile -- quarantine 승인·거절 -- delegated download 발급 -- access denial - -filename, path, signed token, content sample은 audit에 기록하지 않는다. - ---- - -## 31. Spring Boot 설정 - -```yaml -backend: - fileserver: - enabled: true - storage: - type: local - root: /var/lib/backend/files - publish-mode: atomic-move-preferred - require-same-file-store: true - fail-on-symlink: true - buffer-size: 128KiB - upload: - profile: standard - max-file-size: 100MiB - max-request-size: 116MiB - max-parts: 16 - require-content-length: false - incomplete-ttl: 24h - idle-timeout: 45s - instance-concurrency: 16 - scope-concurrency: 4 - download: - single-range-only: true - max-ranges: 8 - direct-concurrency: 64 - private-cache-control: "private, no-store" - content-digest: false - nginx: - enabled: false - delegate-threshold: 16MiB - internal-prefix: /__files/ - verification: - async: true - checksum: sha-256 - require-media-type-verdict: true - scanner-required: false - max-attempts: 5 - quota: - enabled: true - reservation-ttl: 24h - cleanup: - batch-size: 100 - max-bytes-per-run: 10GiB - fixed-delay: 5m - tus: - enabled: false - checksum: true - expiration: true - termination: true - httpbis-draft12: - enabled: false - mvc: - executor: - core-threads: 8 - max-threads: 32 - queue-capacity: 64 - webflux: - io-workers: 16 - max-in-flight-buffers: 8 -``` - -### 31.1 Startup validation - -다음 조건은 startup 실패다. - -- storage root가 webroot 또는 application config 아래임 -- staging과 content가 다른 `FileStore` -- symlink no-follow probe 실패 -- `ATOMIC_MOVE_REQUIRED`인데 probe 실패 -- metadata store 없이 multi-instance mode 활성화 -- no-op authorization policy가 production profile에서 활성화 -- scanner-required인데 verifier bean 없음 -- Nginx delegation을 켰는데 token service 또는 mapping 검증 없음 - ---- - -## 32. 관리자 API - -| Method·Path | 기능 | 통제 | -|---|---|---| -| `GET /internal/fileserver/storage-health` | capacity와 probe 결과 | admin network·role | -| `GET /internal/fileserver/capabilities` | runtime capability | path 비노출 | -| `GET /internal/fileserver/orphans` | bounded orphan 조회 | pagination·rate limit | -| `POST /internal/fileserver/orphans:reconcile` | dry-run·apply | audit | -| `POST /internal/fileserver/files/{id}:reverify` | 재검사 | audit | -| `POST /internal/fileserver/files/{id}:force-delete` | 강제 삭제 | 사유·이중 권한 | -| `GET /internal/fileserver/uploads/incomplete` | 미완료 조회 | filename 마스킹 | -| `POST /internal/fileserver/uploads:cleanup` | cleanup | lease·version 확인 | -| `GET /internal/fileserver/verification-queue` | 검사 지연 | bounded result | - -관리자 API는 public starter에서 자동 노출하지 않고 별도 `fileserver-admin` 모듈과 management port에서만 활성화한다. - ---- - -## 33. 테스트 전략 - -### 33.1 계약 테스트 - -- Content Store blocking·async contract -- Metadata optimistic transition contract -- state machine illegal transition -- upload offset and lease -- checksum and size -- GET·HEAD header parity -- `200/206/304/412/416` -- Range first, middle, suffix, end, empty -- `If-Range`, `If-Match`, `If-None-Match` -- multipart single·batch -- tus create·HEAD·PATCH·checksum·expiry·termination - -### 33.2 보안 테스트 - -- `../`, percent-encoded separator, absolute path, Windows drive path -- parent symlink replacement race -- hard link discovery -- filename CRLF·bidi·reserved name -- extension·Content-Type·signature mismatch -- scriptable content inline 차단 -- scanner timeout·malware verdict -- internal Nginx path direct access -- unauthorized download and existence hiding -- multi Range bomb - -### 33.3 장애 테스트 - -- write 전·중·후 process kill -- close 후 publish 전 kill -- physical publish 후 DB commit 전 kill -- disk full and quota exhaustion -- permission denied -- slow upload·download -- network disconnect -- WebFlux cancellation -- MVC executor saturation -- NFS disconnect·server restart·rename ambiguity -- PVC remount·Pod reschedule -- scanner unavailable - -### 33.4 성능 테스트 - -- 100 MiB와 5 GiB streaming -- concurrent upload/download -- direct vs Nginx throughput -- p50, p95, p99, max latency -- heap, direct memory, allocation, GC -- temp disk and scanner throughput -- Range overhead -- cleanup throughput - -### 33.5 인증 매트릭스 - -| 프로파일 | 빈도 | Gate | -|---|---|---| -| Linux ext4 local | PR | 필수 | -| Linux XFS local | nightly | release 필수 | -| PVC RWO 주 CSI | release | 필수 | -| PVC RWX | release | 지원 선언 시 필수 | -| NFSv4.1 | nightly | 제한 지원 필수 | -| NFS fault injection | RC | 제한 지원 필수 | -| Windows NTFS | nightly | 초기 non-blocking | -| Nginx stable | release | nginx 모듈 필수 | -| MVC Tomcat | PR | 필수 | -| MVC Jetty | release | 지원 선언 시 필수 | -| WebFlux Reactor Netty | PR | 필수 | -| Spring 6.2 latest | release | 필수 | -| Spring 7.0 latest | release | 필수 | - ---- - -## 34. CI 품질 Gate - -모든 pull request: - -```text -unit test -core contract test -local ext4 integration -MVC Tomcat HTTP contract -WebFlux Reactor Netty contract -architecture test -path traversal·symlink security suite -bounded-memory regression -``` - -Nightly: - -```text -XFS -NFSv4.1 -Windows NTFS -large-file performance -slow client -process-kill matrix -scanner failure -``` - -Release: - -```text -Spring 6.2 / 7.0 matrix -PVC certification -Nginx contract -multi-instance lease -fault injection -support-matrix diff -sensitive-log scan -``` - ---- - -## 35. 릴리스 단계 - -### Milestone A — Core Alpha - -- core model·state machine -- JPA metadata -- local staging·append·publish -- raw upload -- full download -- checksum - -### Milestone B — HTTP Beta - -- multipart -- GET·HEAD·single Range -- conditional request -- MVC·WebFlux -- security verifier -- cleanup - -### Milestone C — Distributed RC - -- multi-instance lease -- Nginx delegation -- PVC RWO certification -- admin plane -- chaos·performance gate - -### Milestone D — Extended Release - -- tus 1.0 -- NFS limited profile -- PVC RWX certification -- multi Range Beta -- HTTPbis draft-12 Experimental - ---- - -## 36. 구현자가 임의로 변경하면 안 되는 결정 - -- 공개 API에 `Path`와 physical filename을 노출하지 않는다. -- Content Store의 최소 Port를 filesystem 명령 mirror로 바꾸지 않는다. -- READY 이전 다운로드를 허용하지 않는다. -- metadata DB를 우회해 physical file 존재만으로 READY를 추정하지 않는다. -- create-only 기본값을 unconditional overwrite로 바꾸지 않는다. -- client Content-Type과 filename을 신뢰하지 않는다. -- WebFlux event loop에서 blocking I/O를 실행하지 않는다. -- MVC streaming에 unbounded executor를 사용하지 않는다. -- NFS lock을 단독 correctness mechanism으로 사용하지 않는다. -- upload timeout 후 blind retry를 수행하지 않는다. -- arbitrary path, symlink, recursive delete를 escape hatch로 열지 않는다. -- IETF draft 모듈을 Stable API와 섞지 않는다. -- metric label에 fileId·filename·path를 넣지 않는다. - ---- - -## 37. 완료 정의 - -프로젝트 완료는 다음 산출물이 코드와 CI에 연결됐을 때 선언한다. - -| 산출물 | 완료 기준 | -|---|---| -| 지원 매트릭스 | runtime·filesystem·protocol별 자동 test job 연결 | -| 상태 머신 | 모든 허용·금지 전이 contract test | -| Content Store | blocking·async contract와 local adapter 인증 | -| Metadata Store | optimistic version·lease·recovery test | -| HTTP 계약 | MVC·WebFlux·Nginx mode parity | -| 보안 | traversal·symlink·MIME·권한 공격 suite | -| 장애 | crash point·disk full·network fault 후 invariant 유지 | -| 성능 | 최대 파일에서도 bounded heap·direct memory | -| 운영 | metric, trace, audit, cleanup, reconciliation, runbook | -| 재개 업로드 | tus 1.0 contract suite | -| 제한 지원 | NFS·PVC RWX·Windows 수준이 runtime capability와 문서에 표시 | - ---- - -## 38. 구현 순서 - -```text -1. 모듈·품질 기반 -2. core ID·상태·오류 -3. Content Store와 Metadata Store 계약 -4. JPA metadata -5. local path·staging·capability probe -6. append·checksum·quota -7. publish·state transition·reconciliation -8. upload application -9. HTTP Range·conditional core -10. MVC -11. WebFlux -12. verification·authorization -13. delete·cleanup·admin -14. Nginx delegation -15. multi-instance·PVC -16. tus 1.0 -17. NFS limited certification -18. HTTPbis draft Experimental -19. chaos·performance·release matrix -``` diff --git a/fileserver-superpowers-package/fileserver-platform-implementation-plan.md b/fileserver-superpowers-package/fileserver-platform-implementation-plan.md deleted file mode 100644 index 93932a6..0000000 --- a/fileserver-superpowers-package/fileserver-platform-implementation-plan.md +++ /dev/null @@ -1,3422 +0,0 @@ -# Fileserver Platform Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Spring 기반 Backend Skeleton에 로컬 파일시스템·PVC·제한형 NFS를 대상으로 안전한 streaming upload, 상태 기반 publish, HTTP Range 다운로드, MVC·WebFlux, Nginx 위임, tus 1.0을 제공하는 운영 가능한 Fileserver 플랫폼을 구현한다. - -**Architecture:** `fileserver-core-api`는 저장소 구현과 Spring 타입이 새지 않는 ID·상태·Port를 정의하고, `fileserver-application`이 metadata와 content store를 조정한다. 로컬 저장소는 staging과 immutable content를 분리하고, 관계형 metadata DB의 version·lease·READY 상태가 공개 가능 여부를 결정한다. HTTP adapter, 검사, Nginx, 재개 업로드는 별도 모듈로 분리한다. - -**Tech Stack:** Java 21, Gradle Kotlin DSL, Spring MVC, Spring WebFlux, Spring Data JPA, Flyway, Reactor, Micrometer, OpenTelemetry, JUnit 5, AssertJ, ArchUnit, Testcontainers, Toxiproxy, Awaitility, BlockHound, Nginx. - -## Global Constraints - -- 공개 API에는 `Path`, 실제 파일명, mount 경로를 노출하지 않는다. -- 공개 식별자는 opaque `FileId`와 `UploadId`다. -- metadata store가 상태와 공개 가능 여부의 authoritative source다. -- READY가 아닌 파일은 direct와 Nginx 경로 모두에서 다운로드할 수 없다. -- 로컬 staging·content·quarantine은 동일 `FileStore`에 둔다. -- create-only가 기본이며 overwrite에는 `If-Match` 또는 metadata version이 필요하다. -- 서버 계산 SHA-256과 actual size를 저장한다. -- client filename과 `Content-Type`은 비신뢰 metadata다. -- Spring MVC streaming은 bounded 전용 executor를 사용한다. -- Spring WebFlux event loop에서 filesystem, JDBC, scanner blocking call을 실행하지 않는다. -- multi-instance upload는 DB writer lease와 optimistic version을 사용한다. -- NFS lock을 단독 정합성 근거로 사용하지 않는다. -- timeout 후 write는 blind retry하지 않고 ambiguous completion을 표현한다. -- tus 1.0은 Stable 모듈, HTTPbis draft-12는 Experimental 모듈이다. -- arbitrary path, symlink follow, hard link 생성, recursive delete는 구현하지 않는다. -- 실제 file ID, filename, path, checksum 원문을 metric label에 기록하지 않는다. -- 모든 작업은 실패 테스트 작성 → 실패 확인 → 최소 구현 → 통과 확인 → 커밋 순서로 진행한다. -- 각 작업은 독립 검토가 가능한 하나의 커밋으로 종료한다. - ---- - -## 1. 확정 파일 구조 - -```text -backend-skeleton/ -├── settings.gradle.kts -├── build.gradle.kts -├── build-logic/ -│ └── src/main/kotlin/fileserver-library-conventions.gradle.kts -├── modules/fileserver/ -│ ├── fileserver-core-api/ -│ ├── fileserver-application/ -│ ├── fileserver-metadata-jpa/ -│ ├── fileserver-storage-local/ -│ ├── fileserver-verification/ -│ ├── fileserver-mvc/ -│ ├── fileserver-webflux/ -│ ├── fileserver-nginx/ -│ ├── fileserver-admin/ -│ ├── fileserver-tus/ -│ ├── fileserver-resumable-httpbis-draft12/ -│ ├── fileserver-spring-boot-starter/ -│ └── fileserver-testkit/ -├── infra/fileserver/ -│ ├── nginx/ -│ ├── nfs/ -│ └── kubernetes/ -├── docs/fileserver/ -│ ├── support-matrix.md -│ ├── http-contract.md -│ ├── storage-certification.md -│ ├── security.md -│ ├── operations.md -│ └── upgrade-guide.md -└── docs/superpowers/specs/2026-08-07-fileserver-platform-design.md -``` - -## 2. 핵심 패키지 - -```text -io.backend.skeleton.fileserver.api -io.backend.skeleton.fileserver.api.content -io.backend.skeleton.fileserver.api.error -io.backend.skeleton.fileserver.api.metadata -io.backend.skeleton.fileserver.api.security -io.backend.skeleton.fileserver.api.transfer -io.backend.skeleton.fileserver.application -io.backend.skeleton.fileserver.jpa -io.backend.skeleton.fileserver.local -io.backend.skeleton.fileserver.verification -io.backend.skeleton.fileserver.mvc -io.backend.skeleton.fileserver.webflux -io.backend.skeleton.fileserver.nginx -io.backend.skeleton.fileserver.admin -io.backend.skeleton.fileserver.tus -io.backend.skeleton.fileserver.httpbisdraft12 -io.backend.skeleton.fileserver.autoconfigure -io.backend.skeleton.fileserver.testkit -``` - ---- - -### Task 1: Gradle 멀티모듈과 공통 품질 규칙 구성 - -**Files:** -- Modify: `settings.gradle.kts` -- Create: `build-logic/src/main/kotlin/fileserver-library-conventions.gradle.kts` -- Create: `modules/fileserver/fileserver-core-api/build.gradle.kts` -- Create: `modules/fileserver/fileserver-application/build.gradle.kts` -- Create: `modules/fileserver/fileserver-metadata-jpa/build.gradle.kts` -- Create: `modules/fileserver/fileserver-storage-local/build.gradle.kts` -- Create: `modules/fileserver/fileserver-verification/build.gradle.kts` -- Create: `modules/fileserver/fileserver-mvc/build.gradle.kts` -- Create: `modules/fileserver/fileserver-webflux/build.gradle.kts` -- Create: `modules/fileserver/fileserver-nginx/build.gradle.kts` -- Create: `modules/fileserver/fileserver-admin/build.gradle.kts` -- Create: `modules/fileserver/fileserver-tus/build.gradle.kts` -- Create: `modules/fileserver/fileserver-resumable-httpbis-draft12/build.gradle.kts` -- Create: `modules/fileserver/fileserver-spring-boot-starter/build.gradle.kts` -- Create: `modules/fileserver/fileserver-testkit/build.gradle.kts` -- Test: `modules/fileserver/fileserver-core-api/src/test/java/io/backend/skeleton/fileserver/api/ModuleSmokeTest.java` - -**Interfaces:** -- Produces all Gradle project paths used by later tasks. -- `fileserver-core-api` must have no Spring MVC, WebFlux, JPA, NIO filesystem implementation dependency. -- Java toolchain is 21. - -- [ ] **Step 1: Write the failing core module smoke test** - -```java -package io.backend.skeleton.fileserver.api; - -import org.junit.jupiter.api.Test; - -import static org.assertj.core.api.Assertions.assertThat; - -class ModuleSmokeTest { - @Test - void coreApiModuleLoads() { - assertThat(ModuleSmokeTest.class.getPackageName()) - .isEqualTo("io.backend.skeleton.fileserver.api"); - } -} -``` - -- [ ] **Step 2: Register module paths and verify the build fails before module build files exist** - -Add to `settings.gradle.kts`: - -```kotlin -include( - ":modules:fileserver:fileserver-core-api", - ":modules:fileserver:fileserver-application", - ":modules:fileserver:fileserver-metadata-jpa", - ":modules:fileserver:fileserver-storage-local", - ":modules:fileserver:fileserver-verification", - ":modules:fileserver:fileserver-mvc", - ":modules:fileserver:fileserver-webflux", - ":modules:fileserver:fileserver-nginx", - ":modules:fileserver:fileserver-admin", - ":modules:fileserver:fileserver-tus", - ":modules:fileserver:fileserver-resumable-httpbis-draft12", - ":modules:fileserver:fileserver-spring-boot-starter", - ":modules:fileserver:fileserver-testkit" -) -``` - -Run: - -```bash -./gradlew :modules:fileserver:fileserver-core-api:test -``` - -Expected: FAIL because the registered module build files do not exist. - -- [ ] **Step 3: Add the convention plugin and module dependency boundaries** - -Create `fileserver-library-conventions.gradle.kts`: - -```kotlin -plugins { - `java-library` - id("java-test-fixtures") -} - -java { - toolchain { - languageVersion.set(JavaLanguageVersion.of(21)) - } -} - -tasks.withType().configureEach { - useJUnitPlatform() - failFast = false -} - -dependencies { - "testImplementation"(platform("org.junit:junit-bom:5.12.2")) - "testImplementation"("org.junit.jupiter:junit-jupiter") - "testImplementation"("org.assertj:assertj-core:3.27.3") -} -``` - -Apply it to every Fileserver module. Add only these directed dependencies: - -```text -application → core-api -metadata-jpa → core-api -storage-local → core-api -verification → core-api -mvc → application, core-api -webflux → application, core-api -nginx → application, core-api -admin → application, core-api -tus → application, core-api -httpbis-draft12 → application, core-api -starter → all runtime modules -testkit → core-api, application -``` - -- [ ] **Step 4: Run module tests and dependency report** - -```bash -./gradlew :modules:fileserver:fileserver-core-api:test \ - :modules:fileserver:fileserver-core-api:dependencies -``` - -Expected: PASS; dependency report contains no Spring MVC, WebFlux, Hibernate, or `java.nio.file.Path`-specific adapter library. - -- [ ] **Step 5: Commit** - -```bash -git add settings.gradle.kts build-logic modules/fileserver -git commit -m "build: add fileserver module boundaries" -``` - ---- - -### Task 2: 식별자, 상태, 범위 값 객체 구현 - -**Files:** -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/FileId.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/UploadId.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/ContentKey.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/StorageNamespace.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/FileState.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/ByteRange.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/FileStateMachine.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/DefaultFileStateMachine.java` -- Test: `modules/fileserver/fileserver-core-api/src/test/java/io/backend/skeleton/fileserver/api/FileStateMachineTest.java` -- Test: `modules/fileserver/fileserver-core-api/src/test/java/io/backend/skeleton/fileserver/api/ValueObjectTest.java` - -**Interfaces:** -- Produces `FileId`, `UploadId`, `ContentKey`, `StorageNamespace`, `FileState`, `ByteRange`. -- Later persistence and HTTP tasks use these exact types. - -- [ ] **Step 1: Write failing value object and transition tests** - -```java -class FileStateMachineTest { - private final FileStateMachine stateMachine = new DefaultFileStateMachine(); - - @Test - void allowsUploadedToVerifying() { - assertThat(stateMachine.canTransition(FileState.UPLOADED, FileState.VERIFYING)) - .isTrue(); - } - - @Test - void rejectsCreatedToReady() { - assertThatThrownBy(() -> - stateMachine.requireTransition(FileState.CREATED, FileState.READY)) - .isInstanceOf(IllegalStateException.class) - .hasMessageContaining("CREATED -> READY"); - } -} -``` - -```java -class ValueObjectTest { - @Test - void rejectsInvalidContentKey() { - assertThatThrownBy(() -> new ContentKey("../../etc/passwd")) - .isInstanceOf(IllegalArgumentException.class); - } - - @Test - void calculatesInclusiveRangeLength() { - assertThat(new ByteRange(10, 19).length()).isEqualTo(10); - } -} -``` - -- [ ] **Step 2: Run the tests to verify they fail** - -```bash -./gradlew :modules:fileserver:fileserver-core-api:test \ - --tests '*FileStateMachineTest' --tests '*ValueObjectTest' -``` - -Expected: FAIL because the types do not exist. - -- [ ] **Step 3: Implement exact state transitions and validation** - -```java -public final class DefaultFileStateMachine implements FileStateMachine { - private static final Map> ALLOWED = Map.ofEntries( - Map.entry(FileState.CREATED, Set.of(FileState.UPLOADING)), - Map.entry(FileState.UPLOADING, Set.of( - FileState.UPLOADED, FileState.FAILED, FileState.EXPIRED, FileState.DELETING)), - Map.entry(FileState.UPLOADED, Set.of( - FileState.VERIFYING, FileState.FAILED, FileState.DELETING)), - Map.entry(FileState.VERIFYING, Set.of( - FileState.READY, FileState.QUARANTINED, FileState.REJECTED, FileState.FAILED)), - Map.entry(FileState.QUARANTINED, Set.of( - FileState.VERIFYING, FileState.READY, FileState.REJECTED, FileState.DELETING)), - Map.entry(FileState.READY, Set.of(FileState.DELETING)), - Map.entry(FileState.REJECTED, Set.of(FileState.DELETING)), - Map.entry(FileState.FAILED, Set.of( - FileState.UPLOADING, FileState.VERIFYING, FileState.DELETING, FileState.EXPIRED)), - Map.entry(FileState.DELETING, Set.of(FileState.DELETED, FileState.FAILED)), - Map.entry(FileState.EXPIRED, Set.of(FileState.DELETING)), - Map.entry(FileState.DELETED, Set.of()) - ); - - @Override - public boolean canTransition(FileState current, FileState target) { - return ALLOWED.getOrDefault(current, Set.of()).contains(target); - } - - @Override - public void requireTransition(FileState current, FileState target) { - if (!canTransition(current, target)) { - throw new IllegalStateException("illegal file transition: " + current + " -> " + target); - } - } -} -``` - -Implement ID records with non-null validation and `ContentKey`/namespace regex exactly as the design document. - -- [ ] **Step 4: Run the module tests** - -```bash -./gradlew :modules:fileserver:fileserver-core-api:test -``` - -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add modules/fileserver/fileserver-core-api -git commit -m "feat: add fileserver core value objects and state machine" -``` - ---- - -### Task 3: 안정된 오류 모델과 failure context 구현 - -**Files:** -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/error/FileserverException.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/error/FileserverFailureContext.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/error/UploadOffsetMismatchException.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/error/AmbiguousCompletionException.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/error/FileNotReadyException.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/error/StorageFullException.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/error/IntegrityMismatchException.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/error/FileNotFoundException.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/error/FileAlreadyExistsException.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/error/InvalidPathException.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/error/PathOutsideNamespaceException.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/error/FileAccessDeniedException.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/error/QuotaExceededException.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/error/FileTooLargeException.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/error/UnsupportedMediaTypeException.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/error/UploadExpiredException.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/error/AtomicPublishUnsupportedException.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/error/TransferTimeoutException.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/error/PartialWriteException.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/error/StorageUnavailableException.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/error/ConcurrentFileModificationException.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/error/MalwareDetectedException.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/error/RangeNotSatisfiableException.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/error/TransferAdmissionRejectedException.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/error/FileserverErrorCode.java` -- Test: `modules/fileserver/fileserver-core-api/src/test/java/io/backend/skeleton/fileserver/api/error/FileserverExceptionTest.java` - -**Interfaces:** -- Produces `FileserverException#context()` and stable `FileserverErrorCode` values. -- HTTP adapters map these errors without inspecting storage-driver exceptions. - -- [ ] **Step 1: Write a failing ambiguous execution test** - -```java -class FileserverExceptionTest { - @Test - void ambiguousCompletionCarriesReconciliationFlag() { - AmbiguousCompletionException exception = new AmbiguousCompletionException( - "publish result is unknown", - FileserverFailureContext.forUpload( - FileserverErrorCode.AMBIGUOUS_COMPLETION, - new UploadId(UUID.randomUUID()), - false, - true, - true - ) - ); - - assertThat(exception.context().ambiguous()).isTrue(); - assertThat(exception.context().reconciliationRequired()).isTrue(); - assertThat(exception.context().retryable()).isFalse(); - } -} -``` - -- [ ] **Step 2: Run the test to verify it fails** - -```bash -./gradlew :modules:fileserver:fileserver-core-api:test \ - --tests '*FileserverExceptionTest' -``` - -Expected: FAIL because the exception hierarchy does not exist. - -- [ ] **Step 3: Implement the hierarchy and context** - -```java -public abstract class FileserverException extends RuntimeException { - private final FileserverFailureContext context; - - protected FileserverException(String message, FileserverFailureContext context) { - super(message); - this.context = Objects.requireNonNull(context, "context"); - } - - public final FileserverFailureContext context() { - return context; - } -} -``` - -```java -public record FileserverFailureContext( - FileserverErrorCode code, - boolean retryable, - boolean ambiguous, - boolean reconciliationRequired, - Optional fileId, - Optional uploadId, - OptionalLong expectedOffset, - OptionalLong currentOffset, - Optional currentState -) {} -``` - -Add all design error codes, including `FILE_NOT_FOUND`, `FILE_NOT_READY`, `FILE_TOO_LARGE`, `QUOTA_EXCEEDED`, `STORAGE_FULL`, `UPLOAD_OFFSET_MISMATCH`, `INTEGRITY_MISMATCH`, `CONCURRENT_MODIFICATION`, `STORAGE_UNAVAILABLE`, and `AMBIGUOUS_COMPLETION`. - -- [ ] **Step 4: Run error tests** - -```bash -./gradlew :modules:fileserver:fileserver-core-api:test \ - --tests '*FileserverExceptionTest' -``` - -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/error \ - modules/fileserver/fileserver-core-api/src/test/java/io/backend/skeleton/fileserver/api/error -git commit -m "feat: define fileserver failure semantics" -``` - ---- - -### Task 4: Content Store capability와 blocking·async Port 구현 - -**Files:** -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/content/ContentStoreCapabilities.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/content/BlockingContentStore.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/content/AsyncContentStore.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/content/UploadHandle.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/content/CreateContentCommand.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/content/FinalizeContentCommand.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/content/AppendResult.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/content/StoredContent.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/content/ContentMetadata.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/content/DeletePrecondition.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/content/DeleteResult.java` -- Test: `modules/fileserver/fileserver-core-api/src/test/java/io/backend/skeleton/fileserver/api/content/ContentStoreApiArchitectureTest.java` - -**Interfaces:** -- Produces the exact storage SPI consumed by application and implemented by local storage. -- No public signature may include `Path`, `Resource`, `DataBuffer`, `Flux`, or provider SDK types. - -- [ ] **Step 1: Write a failing architecture test** - -```java -class ContentStoreApiArchitectureTest { - @Test - void publicContentApiDoesNotExposeFrameworkOrFilesystemTypes() { - Set forbidden = Set.of( - "java.nio.file.Path", - "org.springframework.core.io.Resource", - "org.springframework.core.io.buffer.DataBuffer", - "reactor.core.publisher.Flux" - ); - - for (Method method : BlockingContentStore.class.getMethods()) { - assertThat(method.getReturnType().getName()).isNotIn(forbidden); - assertThat(Arrays.stream(method.getParameterTypes()).map(Class::getName)) - .doesNotContainAnyElementsOf(forbidden); - } - } -} -``` - -- [ ] **Step 2: Run the test to verify it fails** - -```bash -./gradlew :modules:fileserver:fileserver-core-api:test \ - --tests '*ContentStoreApiArchitectureTest' -``` - -Expected: FAIL because the interfaces do not exist. - -- [ ] **Step 3: Implement the blocking and async contracts** - -Use these signatures exactly: - -```java -public interface BlockingContentStore { - UploadHandle createUpload(CreateContentCommand command); - AppendResult append(UploadHandle handle, long expectedOffset, - ReadableByteChannel source, long contentLength); - StoredContent finalizeUpload(UploadHandle handle, FinalizeContentCommand command); - ContentMetadata stat(ContentKey key); - ReadableByteChannel openRead(ContentKey key, ByteRange range); - DeleteResult delete(ContentKey key, DeletePrecondition precondition); - ContentStoreCapabilities capabilities(); -} -``` - -```java -public interface AsyncContentStore { - CompletionStage createUpload(CreateContentCommand command); - CompletionStage append( - UploadHandle handle, long expectedOffset, Flow.Publisher content); - CompletionStage finalizeUpload( - UploadHandle handle, FinalizeContentCommand command); - CompletionStage stat(ContentKey key); - Flow.Publisher openRead(ContentKey key, ByteRange range); - CompletionStage delete( - ContentKey key, DeletePrecondition precondition); - ContentStoreCapabilities capabilities(); -} -``` - -- [ ] **Step 4: Run API and architecture tests** - -```bash -./gradlew :modules:fileserver:fileserver-core-api:test -``` - -Expected: PASS; `jdeps` or ArchUnit output confirms no forbidden adapter dependency. - -- [ ] **Step 5: Commit** - -```bash -git add modules/fileserver/fileserver-core-api -git commit -m "feat: define content store ports" -``` - ---- - -### Task 5: Metadata Store, upload session, lease, quota Port 구현 - -**Files:** -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/metadata/FileRecord.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/metadata/FileRecordDraft.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/metadata/FileRecordMutation.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/metadata/FileDescriptor.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/metadata/FileRecoveryQuery.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/metadata/FileMetadataStore.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/metadata/UploadSession.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/metadata/UploadSessionDraft.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/metadata/UploadSessionStore.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/metadata/WriterLease.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/metadata/QuotaReservation.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/metadata/FileQuotaService.java` -- Test: `modules/fileserver/fileserver-core-api/src/test/java/io/backend/skeleton/fileserver/api/metadata/MetadataPortContractTest.java` - -**Interfaces:** -- Produces optimistic transition and writer lease signatures used by Tasks 6, 12, 15, and 24. -- Offset commit always requires a lease token and expected offset. - -- [ ] **Step 1: Write failing port signature tests** - -```java -class MetadataPortContractTest { - @Test - void offsetCommitRequiresLeaseAndExpectedOffset() throws Exception { - Method method = UploadSessionStore.class.getMethod( - "commitOffset", - UploadId.class, - WriterLease.class, - long.class, - long.class - ); - - assertThat(method.getReturnType()).isEqualTo(UploadSession.class); - } - - @Test - void fileTransitionRequiresExpectedVersionAndState() throws Exception { - Method method = FileMetadataStore.class.getMethod( - "transition", - FileId.class, - long.class, - FileState.class, - FileState.class, - FileRecordMutation.class - ); - - assertThat(method).isNotNull(); - } -} -``` - -- [ ] **Step 2: Run the tests to verify they fail** - -```bash -./gradlew :modules:fileserver:fileserver-core-api:test \ - --tests '*MetadataPortContractTest' -``` - -Expected: FAIL because the port types do not exist. - -- [ ] **Step 3: Implement metadata records and exact methods** - -```java -public interface FileMetadataStore { - FileRecord insert(FileRecordDraft draft); - Optional find(FileId fileId); - FileRecord transition( - FileId fileId, - long expectedVersion, - FileState expectedState, - FileState targetState, - FileRecordMutation mutation - ); - FileRecord markDeleting(FileId fileId, long expectedVersion); - List findRecoverable(FileRecoveryQuery query); -} -``` - -```java -public interface UploadSessionStore { - UploadSession create(UploadSessionDraft draft); - Optional find(UploadId uploadId); - WriterLease acquireLease( - UploadId uploadId, - String owner, - Instant now, - Duration leaseDuration, - long expectedVersion - ); - UploadSession commitOffset( - UploadId uploadId, - WriterLease lease, - long expectedOffset, - long committedOffset - ); - void releaseLease(UploadId uploadId, WriterLease lease); - List findExpired(Instant cutoff, int limit); -} -``` - -- [ ] **Step 4: Run the core API tests** - -```bash -./gradlew :modules:fileserver:fileserver-core-api:test -``` - -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add modules/fileserver/fileserver-core-api -git commit -m "feat: define fileserver metadata and lease ports" -``` - ---- - -### Task 6: Flyway metadata schema와 JPA entity 구성 - -**Files:** -- Create: `modules/fileserver/fileserver-metadata-jpa/src/main/resources/db/migration/fileserver/V1__create_fileserver_metadata.sql` -- Create: `modules/fileserver/fileserver-metadata-jpa/src/main/java/io/backend/skeleton/fileserver/jpa/entity/FileEntity.java` -- Create: `modules/fileserver/fileserver-metadata-jpa/src/main/java/io/backend/skeleton/fileserver/jpa/entity/UploadSessionEntity.java` -- Create: `modules/fileserver/fileserver-metadata-jpa/src/main/java/io/backend/skeleton/fileserver/jpa/entity/VerificationResultEntity.java` -- Create: `modules/fileserver/fileserver-metadata-jpa/src/main/java/io/backend/skeleton/fileserver/jpa/entity/QuotaReservationEntity.java` -- Create: `modules/fileserver/fileserver-metadata-jpa/src/main/java/io/backend/skeleton/fileserver/jpa/entity/CleanupItemEntity.java` -- Create: `modules/fileserver/fileserver-metadata-jpa/src/main/java/io/backend/skeleton/fileserver/jpa/repository/JpaFileRepository.java` -- Create: `modules/fileserver/fileserver-metadata-jpa/src/main/java/io/backend/skeleton/fileserver/jpa/repository/JpaUploadSessionRepository.java` -- Test: `modules/fileserver/fileserver-metadata-jpa/src/test/java/io/backend/skeleton/fileserver/jpa/FileserverMigrationTest.java` - -**Interfaces:** -- Consumes `FileState`, IDs, and metadata records from Tasks 2 and 5. -- Produces database tables and JPA repositories used by Task 7. - -- [ ] **Step 1: Write a failing migration test** - -```java -@Testcontainers -class FileserverMigrationTest { - @Container - static final PostgreSQLContainer POSTGRES = - new PostgreSQLContainer<>("postgres:17-alpine"); - - @Test - void createsFileserverTablesAndVersionColumns() throws Exception { - Flyway.configure() - .dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword()) - .locations("classpath:db/migration/fileserver") - .load() - .migrate(); - - try (Connection connection = DriverManager.getConnection( - POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword())) { - assertThat(columnExists(connection, "fs_file", "version")).isTrue(); - assertThat(columnExists(connection, "fs_upload_session", "lease_until")).isTrue(); - assertThat(columnExists(connection, "fs_quota_reservation", "reserved_bytes")).isTrue(); - } - } -} -``` - -- [ ] **Step 2: Run the migration test to verify it fails** - -```bash -./gradlew :modules:fileserver:fileserver-metadata-jpa:test \ - --tests '*FileserverMigrationTest' -``` - -Expected: FAIL because the migration does not exist. - -- [ ] **Step 3: Create the schema and entity mappings** - -Use the following core DDL shape: - -```sql -create table fs_file ( - file_id uuid primary key, - namespace varchar(63) not null, - state varchar(32) not null, - content_key varchar(200), - original_name varchar(255) not null, - claimed_media_type varchar(255), - verified_media_type varchar(255), - expected_size bigint, - actual_size bigint, - sha256 char(64), - strong_etag varchar(80), - published_at timestamptz, - last_error_code varchar(64), - version bigint not null default 0, - created_at timestamptz not null, - updated_at timestamptz not null, - constraint ck_fs_file_size check (actual_size is null or actual_size >= 0) -); - -create table fs_upload_session ( - upload_id uuid primary key, - file_id uuid not null references fs_file(file_id), - protocol varchar(32) not null, - expected_length bigint, - committed_offset bigint not null default 0, - expires_at timestamptz not null, - lease_owner varchar(128), - lease_token uuid, - lease_until timestamptz, - version bigint not null default 0, - created_at timestamptz not null, - updated_at timestamptz not null, - constraint ck_fs_upload_offset check (committed_offset >= 0) -); -``` - -Add the verification, quota, and cleanup tables from the design with indexes on state, expiry, lease, and cleanup schedule. Map optimistic version with `@Version`. - -- [ ] **Step 4: Run migration and JPA schema validation** - -```bash -./gradlew :modules:fileserver:fileserver-metadata-jpa:test \ - --tests '*FileserverMigrationTest' -``` - -Expected: PASS; Hibernate schema validation reports no mismatch. - -- [ ] **Step 5: Commit** - -```bash -git add modules/fileserver/fileserver-metadata-jpa -git commit -m "feat: add fileserver metadata schema" -``` - ---- - -### Task 7: JPA Metadata Store와 optimistic transition 구현 - -**Files:** -- Create: `modules/fileserver/fileserver-metadata-jpa/src/main/java/io/backend/skeleton/fileserver/jpa/JpaFileMetadataStore.java` -- Create: `modules/fileserver/fileserver-metadata-jpa/src/main/java/io/backend/skeleton/fileserver/jpa/JpaUploadSessionStore.java` -- Create: `modules/fileserver/fileserver-metadata-jpa/src/main/java/io/backend/skeleton/fileserver/jpa/JpaFileQuotaService.java` -- Create: `modules/fileserver/fileserver-metadata-jpa/src/main/java/io/backend/skeleton/fileserver/jpa/FileEntityMapper.java` -- Create: `modules/fileserver/fileserver-metadata-jpa/src/main/java/io/backend/skeleton/fileserver/jpa/repository/FileTransitionRepository.java` -- Create: `modules/fileserver/fileserver-metadata-jpa/src/main/java/io/backend/skeleton/fileserver/jpa/repository/UploadLeaseRepository.java` -- Test: `modules/fileserver/fileserver-metadata-jpa/src/test/java/io/backend/skeleton/fileserver/jpa/JpaFileMetadataStoreTest.java` -- Test: `modules/fileserver/fileserver-metadata-jpa/src/test/java/io/backend/skeleton/fileserver/jpa/JpaUploadSessionStoreTest.java` - -**Interfaces:** -- Consumes metadata ports from Task 5 and schema from Task 6. -- Produces transactional implementations used by the application layer. - -- [ ] **Step 1: Write failing concurrent transition and lease tests** - -```java -@Test -void onlyOneReadyTransitionWinsForTheSameVersion() { - FileRecord record = fixture.insertVerifyingFile(); - - CompletableFuture first = async(() -> store.transition( - record.fileId(), record.version(), FileState.VERIFYING, FileState.READY, - FileRecordMutation.publish(fixture.contentKey(), 10, fixture.sha256(), fixture.etag()))); - CompletableFuture second = async(() -> store.transition( - record.fileId(), record.version(), FileState.VERIFYING, FileState.READY, - FileRecordMutation.publish(fixture.contentKey(), 10, fixture.sha256(), fixture.etag()))); - - assertThat(successCount(first, second)).isEqualTo(1); - assertThat(concurrentModificationCount(first, second)).isEqualTo(1); -} -``` - -```java -@Test -void onlyOneWriterLeaseIsValid() { - UploadSession session = fixture.insertActiveUpload(); - Instant now = Instant.parse("2026-08-07T10:00:00Z"); - - WriterLease first = store.acquireLease( - session.uploadId(), "node-a", now, Duration.ofSeconds(30), session.version()); - - assertThatThrownBy(() -> store.acquireLease( - session.uploadId(), "node-b", now.plusSeconds(1), Duration.ofSeconds(30), session.version())) - .isInstanceOf(ConcurrentFileModificationException.class); - assertThat(first.owner()).isEqualTo("node-a"); -} -``` - -- [ ] **Step 2: Run the tests to verify they fail** - -```bash -./gradlew :modules:fileserver:fileserver-metadata-jpa:test \ - --tests '*JpaFileMetadataStoreTest' --tests '*JpaUploadSessionStoreTest' -``` - -Expected: FAIL because store implementations do not exist. - -- [ ] **Step 3: Implement conditional update repositories** - -Use an update query that includes both state and version: - -```java -@Modifying -@Query(""" - update FileEntity f - set f.state = :targetState, - f.contentKey = :contentKey, - f.actualSize = :actualSize, - f.sha256 = :sha256, - f.strongEtag = :strongEtag, - f.publishedAt = :publishedAt, - f.version = f.version + 1, - f.updatedAt = :updatedAt - where f.fileId = :fileId - and f.state = :expectedState - and f.version = :expectedVersion - """) -int transition(...); -``` - -Lease acquisition must update only when `lease_until is null or lease_until < now` and the expected version matches. `commitOffset` must require matching `lease_token`, current offset, and unexpired lease. - -- [ ] **Step 4: Run all JPA tests** - -```bash -./gradlew :modules:fileserver:fileserver-metadata-jpa:test -``` - -Expected: PASS; repeated concurrency runs produce one winner only. - -- [ ] **Step 5: Commit** - -```bash -git add modules/fileserver/fileserver-metadata-jpa -git commit -m "feat: implement fileserver metadata stores" -``` - ---- - -### Task 8: 원본 파일명 sanitization과 path 정책 구현 - -**Files:** -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/security/OriginalFilenamePolicy.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/security/SanitizedFilename.java` -- Create: `modules/fileserver/fileserver-storage-local/src/main/java/io/backend/skeleton/fileserver/local/LocalStorageLayout.java` -- Create: `modules/fileserver/fileserver-storage-local/src/main/java/io/backend/skeleton/fileserver/local/PhysicalPathResolver.java` -- Create: `modules/fileserver/fileserver-storage-local/src/main/java/io/backend/skeleton/fileserver/local/DefaultPhysicalPathResolver.java` -- Test: `modules/fileserver/fileserver-core-api/src/test/java/io/backend/skeleton/fileserver/api/security/OriginalFilenamePolicyTest.java` -- Test: `modules/fileserver/fileserver-storage-local/src/test/java/io/backend/skeleton/fileserver/local/PhysicalPathResolverTest.java` - -**Interfaces:** -- Produces sanitized display names and package-private physical path resolution. -- No controller may call `PhysicalPathResolver` directly. - -- [ ] **Step 1: Write failing malicious filename and root escape tests** - -```java -class OriginalFilenamePolicyTest { - private final OriginalFilenamePolicy policy = new OriginalFilenamePolicy(255); - - @Test - void removesPathAndHeaderInjectionCharacters() { - SanitizedFilename result = policy.sanitize("../report\r\nX-Test: yes.pdf"); - - assertThat(result.value()).doesNotContain("..", "/", "\\", "\r", "\n"); - assertThat(result.value()).endsWith(".pdf"); - } - - @Test - void replacesWindowsReservedName() { - assertThat(policy.sanitize("CON").value()).isEqualTo("_CON"); - } -} -``` - -```java -class PhysicalPathResolverTest { - @TempDir Path root; - - @Test - void generatedContentPathAlwaysStaysBelowContentRoot() { - DefaultPhysicalPathResolver resolver = new DefaultPhysicalPathResolver(root); - Path result = resolver.contentPath(new ContentKey("ab/cd/0123456789abcdef")); - - assertThat(result.normalize()).startsWith(root.resolve("content").normalize()); - } -} -``` - -- [ ] **Step 2: Run the tests to verify they fail** - -```bash -./gradlew :modules:fileserver:fileserver-core-api:test \ - :modules:fileserver:fileserver-storage-local:test \ - --tests '*OriginalFilenamePolicyTest' --tests '*PhysicalPathResolverTest' -``` - -Expected: FAIL because policy and resolver do not exist. - -- [ ] **Step 3: Implement sanitization and server-generated layout** - -`OriginalFilenamePolicy` must: - -```text -strip path separators and NUL -replace control and bidi override characters -remove CR/LF and quote injection -trim trailing dot and space -prefix Windows reserved names with `_` -truncate by UTF-8 byte length, preserving the final extension when possible -return `file` when the normalized name becomes empty -``` - -`DefaultPhysicalPathResolver` must only accept validated IDs and construct: - -```text -staging///.part -content///.bin -quarantine///.bin -``` - -- [ ] **Step 4: Run filename and path tests** - -```bash -./gradlew :modules:fileserver:fileserver-core-api:test \ - :modules:fileserver:fileserver-storage-local:test -``` - -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add modules/fileserver/fileserver-core-api modules/fileserver/fileserver-storage-local -git commit -m "feat: enforce fileserver filename and path policy" -``` - ---- - -### Task 9: Local staging 생성과 `CREATE_NEW` 경쟁 제어 구현 - -**Files:** -- Create: `modules/fileserver/fileserver-storage-local/src/main/java/io/backend/skeleton/fileserver/local/LocalBlockingContentStore.java` -- Create: `modules/fileserver/fileserver-storage-local/src/main/java/io/backend/skeleton/fileserver/local/LocalStorageProperties.java` -- Create: `modules/fileserver/fileserver-storage-local/src/main/java/io/backend/skeleton/fileserver/local/SafeFileChannelFactory.java` -- Create: `modules/fileserver/fileserver-storage-local/src/main/java/io/backend/skeleton/fileserver/local/LocalUploadHandle.java` -- Test: `modules/fileserver/fileserver-storage-local/src/test/java/io/backend/skeleton/fileserver/local/LocalCreateUploadTest.java` -- Test: `modules/fileserver/fileserver-storage-local/src/test/java/io/backend/skeleton/fileserver/local/LocalCreateUploadConcurrencyTest.java` - -**Interfaces:** -- Implements `BlockingContentStore#createUpload` from Task 4. -- Produces `LocalUploadHandle` used by append and finalize tasks. - -- [ ] **Step 1: Write failing create-only and concurrent-create tests** - -```java -@Test -void createsStagingFileWithZeroLengthAndNoOriginalName() { - UploadHandle handle = store.createUpload(commandFor("../../secret.pdf")); - - Path staging = testSupport.pathOf(handle); - assertThat(staging).exists().isEmptyFile(); - assertThat(staging.getFileName().toString()).doesNotContain("secret.pdf"); -} -``` - -```java -@Test -void exactlyOneConcurrentCreateWinsForSameUploadId() { - CreateContentCommand command = fixture.commandWithFixedUploadId(); - - List failures = runConcurrently(2, () -> store.createUpload(command)); - - assertThat(failures).hasSize(1); - assertThat(failures.getFirst()).isInstanceOf(FileAlreadyExistsException.class); -} -``` - -- [ ] **Step 2: Run the tests to verify they fail** - -```bash -./gradlew :modules:fileserver:fileserver-storage-local:test \ - --tests '*LocalCreateUploadTest' --tests '*LocalCreateUploadConcurrencyTest' -``` - -Expected: FAIL because local store is not implemented. - -- [ ] **Step 3: Implement safe staging creation** - -Open the staging file with: - -```java -Set options = Set.of( - StandardOpenOption.CREATE_NEW, - StandardOpenOption.WRITE, - LinkOption.NOFOLLOW_LINKS -); -``` - -Create parent directories from server-generated components only. Before and after open, verify that no parent is a symbolic link. Set owner-only permissions on POSIX providers. Convert `FileAlreadyExistsException`, `AccessDeniedException`, and `FileSystemException` into stable Fileserver errors. - -- [ ] **Step 4: Run local storage creation tests repeatedly** - -```bash -./gradlew :modules:fileserver:fileserver-storage-local:test \ - --tests '*LocalCreateUpload*' --rerun-tasks -``` - -Expected: PASS for 20 repeated runs; exactly one concurrent create succeeds. - -- [ ] **Step 5: Commit** - -```bash -git add modules/fileserver/fileserver-storage-local -git commit -m "feat: create safe local upload staging files" -``` - ---- - -### Task 10: Storage capability probe와 startup gate 구현 - -**Files:** -- Create: `modules/fileserver/fileserver-storage-local/src/main/java/io/backend/skeleton/fileserver/local/LocalStorageCapabilityProbe.java` -- Create: `modules/fileserver/fileserver-storage-local/src/main/java/io/backend/skeleton/fileserver/local/LocalStorageProbeResult.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/content/PublishMode.java` -- Create: `modules/fileserver/fileserver-spring-boot-starter/src/main/java/io/backend/skeleton/fileserver/autoconfigure/FileserverStartupValidator.java` -- Test: `modules/fileserver/fileserver-storage-local/src/test/java/io/backend/skeleton/fileserver/local/LocalStorageCapabilityProbeTest.java` -- Test: `modules/fileserver/fileserver-spring-boot-starter/src/test/java/io/backend/skeleton/fileserver/autoconfigure/FileserverStartupValidatorTest.java` - -**Interfaces:** -- Produces runtime `ContentStoreCapabilities` and selected `PublishMode`. -- Later finalize logic must consume this result instead of assuming atomic move. - -- [ ] **Step 1: Write failing same-FileStore and required-atomic tests** - -```java -@Test -void reportsAtomicCreateAndSameFileStore() { - LocalStorageProbeResult result = probe.run(); - - assertThat(result.atomicCreate()).isTrue(); - assertThat(result.sameFileStore()).isTrue(); - assertThat(result.symlinkNoFollow()).isTrue(); -} -``` - -```java -@Test -void requiredAtomicModeRejectsUnsupportedStorage() { - LocalStorageProbeResult result = fixture.resultWithAtomicMove(false); - - assertThatThrownBy(() -> validator.validate( - PublishMode.ATOMIC_MOVE_REQUIRED, result)) - .isInstanceOf(IllegalStateException.class) - .hasMessageContaining("atomic move"); -} -``` - -- [ ] **Step 2: Run the tests to verify they fail** - -```bash -./gradlew :modules:fileserver:fileserver-storage-local:test \ - :modules:fileserver:fileserver-spring-boot-starter:test \ - --tests '*LocalStorageCapabilityProbeTest' \ - --tests '*FileserverStartupValidatorTest' -``` - -Expected: FAIL because probe and validator do not exist. - -- [ ] **Step 3: Implement real filesystem probes** - -The probe must create files below `${root}/probe` and verify: - -```text -writable root -concurrent CREATE_NEW -staging/content/quarantine FileStore equality -ATOMIC_MOVE -replace semantics -NOFOLLOW_LINKS -open-delete behavior -capacity access -``` - -Delete all probe artifacts in `finally`. In `ATOMIC_MOVE_PREFERRED`, return `METADATA_POINTER` as fallback when atomic move is unavailable. In `ATOMIC_MOVE_REQUIRED`, fail startup. - -- [ ] **Step 4: Run probe tests and a local integration probe** - -```bash -./gradlew :modules:fileserver:fileserver-storage-local:test \ - :modules:fileserver:fileserver-spring-boot-starter:test -``` - -Expected: PASS; probe directory is empty after completion. - -- [ ] **Step 5: Commit** - -```bash -git add modules/fileserver/fileserver-storage-local \ - modules/fileserver/fileserver-core-api \ - modules/fileserver/fileserver-spring-boot-starter -git commit -m "feat: probe fileserver storage capabilities" -``` - ---- - -### Task 11: Streaming append, size 제한, SHA-256 계산 구현 - -**Files:** -- Create: `modules/fileserver/fileserver-storage-local/src/main/java/io/backend/skeleton/fileserver/local/LocalAppendEngine.java` -- Create: `modules/fileserver/fileserver-storage-local/src/main/java/io/backend/skeleton/fileserver/local/StreamingDigest.java` -- Create: `modules/fileserver/fileserver-storage-local/src/main/java/io/backend/skeleton/fileserver/local/TransferBufferPool.java` -- Modify: `modules/fileserver/fileserver-storage-local/src/main/java/io/backend/skeleton/fileserver/local/LocalBlockingContentStore.java` -- Test: `modules/fileserver/fileserver-storage-local/src/test/java/io/backend/skeleton/fileserver/local/LocalAppendEngineTest.java` -- Test: `modules/fileserver/fileserver-storage-local/src/test/java/io/backend/skeleton/fileserver/local/LocalAppendMemoryTest.java` - -**Interfaces:** -- Implements `BlockingContentStore#append`. -- Produces `AppendResult(committedOffset, appendedBytes, sha256Snapshot)`. -- Uses 128 KiB default buffer and never allocates proportional to file size. - -- [ ] **Step 1: Write failing offset, digest, and bounded-buffer tests** - -```java -@Test -void appendsAtExpectedOffsetAndCalculatesDigest() throws Exception { - UploadHandle handle = fixture.emptyUpload(); - byte[] payload = "fileserver".getBytes(StandardCharsets.UTF_8); - - AppendResult result = store.append( - handle, 0, Channels.newChannel(new ByteArrayInputStream(payload)), payload.length); - - assertThat(result.committedOffset()).isEqualTo(payload.length); - assertThat(result.appendedBytes()).isEqualTo(payload.length); - assertThat(result.sha256()).isEqualTo(sha256Hex(payload)); -} - -@Test -void rejectsOffsetMismatchWithoutWriting() throws Exception { - UploadHandle handle = fixture.uploadContaining("abc"); - - assertThatThrownBy(() -> store.append( - handle, 2, Channels.newChannel(new ByteArrayInputStream("d".getBytes())), 1)) - .isInstanceOf(UploadOffsetMismatchException.class); - - assertThat(fixture.readBytes(handle)).isEqualTo("abc".getBytes()); -} -``` - -```java -@Test -void maxObservedBufferDoesNotGrowWithPayload() throws Exception { - fixture.appendGeneratedBytes(256L * 1024 * 1024); - assertThat(bufferPool.maxBorrowedBytes()).isLessThanOrEqualTo(128 * 1024); -} -``` - -- [ ] **Step 2: Run the tests to verify they fail** - -```bash -./gradlew :modules:fileserver:fileserver-storage-local:test \ - --tests '*LocalAppendEngineTest' --tests '*LocalAppendMemoryTest' -``` - -Expected: FAIL because append engine and digest tracking do not exist. - -- [ ] **Step 3: Implement sequential channel append** - -```java -public AppendResult append( - Path staging, - long expectedOffset, - ReadableByteChannel source, - long contentLength, - long maximumFileSize -) { - try (FileChannel target = FileChannel.open( - staging, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS)) { - long actualOffset = target.size(); - if (actualOffset != expectedOffset) { - throw UploadOffsetMismatchException.of(expectedOffset, actualOffset); - } - target.position(expectedOffset); - return copyAndDigest(target, source, contentLength, maximumFileSize); - } -} -``` - -`copyAndDigest` must: - -```text -borrow one bounded buffer -update SHA-256 for every written byte -stop immediately when maximumFileSize would be exceeded -verify fixed contentLength when non-negative -return only after bytes are written to the channel -release the buffer in finally -``` - -- [ ] **Step 4: Run append tests and inspect heap allocation** - -```bash -./gradlew :modules:fileserver:fileserver-storage-local:test \ - --tests '*LocalAppend*' -``` - -Expected: PASS; 256 MiB test uses at most the configured transfer buffer plus test harness overhead. - -- [ ] **Step 5: Commit** - -```bash -git add modules/fileserver/fileserver-storage-local -git commit -m "feat: stream local file appends with sha256" -``` - ---- - -### Task 12: Quota reservation과 transfer admission control 구현 - -**Files:** -- Create: `modules/fileserver/fileserver-application/src/main/java/io/backend/skeleton/fileserver/application/quota/QuotaScope.java` -- Create: `modules/fileserver/fileserver-application/src/main/java/io/backend/skeleton/fileserver/application/quota/TransferAdmissionController.java` -- Create: `modules/fileserver/fileserver-application/src/main/java/io/backend/skeleton/fileserver/application/quota/DefaultTransferAdmissionController.java` -- Create: `modules/fileserver/fileserver-application/src/main/java/io/backend/skeleton/fileserver/application/quota/TransferPermit.java` -- Modify: `modules/fileserver/fileserver-metadata-jpa/src/main/java/io/backend/skeleton/fileserver/jpa/JpaFileQuotaService.java` -- Test: `modules/fileserver/fileserver-application/src/test/java/io/backend/skeleton/fileserver/application/quota/TransferAdmissionControllerTest.java` -- Test: `modules/fileserver/fileserver-metadata-jpa/src/test/java/io/backend/skeleton/fileserver/jpa/JpaFileQuotaServiceTest.java` - -**Interfaces:** -- Consumes `FileQuotaService` from Task 5. -- Produces `TransferPermit` required before create or append. -- Default standard profile: 100 MiB file, 16 instance uploads, 4 scope uploads, soft 70%, hard 85%. - -- [ ] **Step 1: Write failing quota and concurrency tests** - -```java -@Test -void rejectsWhenScopeConcurrencyIsExhausted() { - TransferPermit first = controller.acquire(scope("tenant-a"), 10); - TransferPermit second = controller.acquire(scope("tenant-a"), 10); - TransferPermit third = controller.acquire(scope("tenant-a"), 10); - TransferPermit fourth = controller.acquire(scope("tenant-a"), 10); - - assertThatThrownBy(() -> controller.acquire(scope("tenant-a"), 10)) - .isInstanceOf(QuotaExceededException.class); - - Stream.of(first, second, third, fourth).forEach(TransferPermit::close); -} -``` - -```java -@Test -void reservationCommitUsesActualBytesAndReleasesRemainder() { - QuotaReservation reservation = quota.reserve(scope, 1000, Duration.ofHours(1)); - quota.commit(reservation, 600); - - assertThat(fixture.committedBytes(scope)).isEqualTo(600); - assertThat(fixture.reservedBytes(scope)).isZero(); -} -``` - -- [ ] **Step 2: Run the tests to verify they fail** - -```bash -./gradlew :modules:fileserver:fileserver-application:test \ - :modules:fileserver:fileserver-metadata-jpa:test \ - --tests '*TransferAdmissionControllerTest' --tests '*JpaFileQuotaServiceTest' -``` - -Expected: FAIL because admission control is not implemented. - -- [ ] **Step 3: Implement reservation and bounded permits** - -Use DB conditional updates for quota bytes and JVM semaphores for per-instance transfer concurrency. A create request with unknown length reserves the configured initial chunk; append extends the reservation before writing additional bytes. On cancellation or failure, release the reservation in `finally` or cleanup recovery. - -```java -public interface TransferAdmissionController { - TransferPermit acquireUpload(QuotaScope scope, long requestedBytes); - TransferPermit acquireDirectDownload(QuotaScope scope); -} -``` - -A hard storage high-water condition maps to `StorageFullException`; scope limit maps to `QuotaExceededException`; temporary permit exhaustion maps to `TransferAdmissionRejectedException` with `retryable=true`. - -- [ ] **Step 4: Run quota and concurrency tests** - -```bash -./gradlew :modules:fileserver:fileserver-application:test \ - :modules:fileserver:fileserver-metadata-jpa:test -``` - -Expected: PASS; no permit or reservation remains after test cleanup. - -- [ ] **Step 5: Commit** - -```bash -git add modules/fileserver/fileserver-application \ - modules/fileserver/fileserver-metadata-jpa -git commit -m "feat: enforce fileserver quota and transfer admission" -``` - ---- - -### Task 13: Atomic move와 metadata pointer publish 전략 구현 - -**Files:** -- Create: `modules/fileserver/fileserver-storage-local/src/main/java/io/backend/skeleton/fileserver/local/ContentPublisher.java` -- Create: `modules/fileserver/fileserver-storage-local/src/main/java/io/backend/skeleton/fileserver/local/AtomicMoveContentPublisher.java` -- Create: `modules/fileserver/fileserver-storage-local/src/main/java/io/backend/skeleton/fileserver/local/MetadataPointerContentPublisher.java` -- Create: `modules/fileserver/fileserver-storage-local/src/main/java/io/backend/skeleton/fileserver/local/PublishResult.java` -- Modify: `modules/fileserver/fileserver-storage-local/src/main/java/io/backend/skeleton/fileserver/local/LocalBlockingContentStore.java` -- Test: `modules/fileserver/fileserver-storage-local/src/test/java/io/backend/skeleton/fileserver/local/AtomicMoveContentPublisherTest.java` -- Test: `modules/fileserver/fileserver-storage-local/src/test/java/io/backend/skeleton/fileserver/local/MetadataPointerContentPublisherTest.java` - -**Interfaces:** -- Consumes `PublishMode` and probe results from Task 10. -- Implements `BlockingContentStore#finalizeUpload`. -- Produces immutable `StoredContent` and never exposes a partial final target. - -- [ ] **Step 1: Write failing publish strategy tests** - -```java -@Test -void atomicPublisherMovesStagingToCreateOnlyTarget() throws Exception { - LocalUploadHandle handle = fixture.uploadContaining("ready"); - - PublishResult result = publisher.publish(handle, fixture.finalizeCommand()); - - assertThat(result.contentPath()).exists(); - assertThat(handle.stagingPath()).doesNotExist(); - assertThat(Files.readString(result.contentPath())).isEqualTo("ready"); -} -``` - -```java -@Test -void pointerPublisherKeepsImmutableObjectAndReturnsNewContentKey() throws Exception { - LocalUploadHandle handle = fixture.uploadContaining("ready"); - - PublishResult result = pointerPublisher.publish(handle, fixture.finalizeCommand()); - - assertThat(result.contentKey()).isNotNull(); - assertThat(result.contentPath()).exists(); - assertThat(result.atomicMoveUsed()).isFalse(); -} -``` - -- [ ] **Step 2: Run tests to verify they fail** - -```bash -./gradlew :modules:fileserver:fileserver-storage-local:test \ - --tests '*ContentPublisherTest' -``` - -Expected: FAIL because publishers do not exist. - -- [ ] **Step 3: Implement publish strategies** - -`AtomicMoveContentPublisher` must use `ATOMIC_MOVE` and omit `REPLACE_EXISTING` for create-only. `MetadataPointerContentPublisher` must complete an immutable physical object under a fresh `ContentKey`; public visibility remains false until the application commits metadata READY. - -Both implementations must: - -```text -verify expected length -verify SHA-256 -optionally force the channel according to durability profile -stat the final object -return actual size and content key -map uncertain filesystem results to AmbiguousCompletionException -``` - -- [ ] **Step 4: Run publish tests including process-visible observer checks** - -```bash -./gradlew :modules:fileserver:fileserver-storage-local:test \ - --tests '*ContentPublisherTest' --rerun-tasks -``` - -Expected: PASS; observers see no partial final target. - -- [ ] **Step 5: Commit** - -```bash -git add modules/fileserver/fileserver-storage-local -git commit -m "feat: publish files with atomic or pointer strategy" -``` - ---- - -### Task 14: Finalize orchestration과 READY invariant 구현 - -**Files:** -- Create: `modules/fileserver/fileserver-application/src/main/java/io/backend/skeleton/fileserver/application/FileVerificationService.java` -- Create: `modules/fileserver/fileserver-application/src/main/java/io/backend/skeleton/fileserver/application/FinalizeUploadService.java` -- Create: `modules/fileserver/fileserver-application/src/main/java/io/backend/skeleton/fileserver/application/DefaultFinalizeUploadService.java` -- Create: `modules/fileserver/fileserver-application/src/main/java/io/backend/skeleton/fileserver/application/FinalizeUploadRequest.java` -- Create: `modules/fileserver/fileserver-application/src/main/java/io/backend/skeleton/fileserver/application/FileView.java` -- Test: `modules/fileserver/fileserver-application/src/test/java/io/backend/skeleton/fileserver/application/FinalizeUploadServiceTest.java` - -**Interfaces:** -- Consumes metadata stores, content store, state machine, quota service. -- Consumes the `FileVerificationService` Port created in this Task; Task 16 provides its production coordinator implementation. Tests use a deterministic ACCEPT stub. -- Produces READY or non-public VERIFYING/REJECTED results. - -- [ ] **Step 1: Write failing READY and checksum mismatch tests** - -```java -@Test -void publishesAndTransitionsToReadyOnlyAfterPhysicalVerification() { - FileView result = service.finalizeUpload( - fixture.uploadedSession(), - new FinalizeUploadRequest(Optional.of(fixture.sha256()), false), - fixture.context()); - - assertThat(result.state()).isEqualTo(FileState.READY); - assertThat(fixture.metadata(result.fileId()).contentKey()).isPresent(); - assertThat(fixture.contentExists(result.fileId())).isTrue(); -} -``` - -```java -@Test -void digestMismatchNeverTransitionsToReady() { - assertThatThrownBy(() -> service.finalizeUpload( - fixture.uploadedSession(), - new FinalizeUploadRequest(Optional.of("0".repeat(64)), false), - fixture.context())) - .isInstanceOf(IntegrityMismatchException.class); - - assertThat(fixture.fileState()).isEqualTo(FileState.REJECTED); - assertThat(fixture.publicDownloadAvailable()).isFalse(); -} -``` - -- [ ] **Step 2: Run the test to verify it fails** - -```bash -./gradlew :modules:fileserver:fileserver-application:test \ - --tests '*FinalizeUploadServiceTest' -``` - -Expected: FAIL because finalize service does not exist. - -- [ ] **Step 3: Implement the finalize sequence** - -Implement this exact order: - -```text -load upload and file -validate expected length -transition UPLOADING → UPLOADED when final append is complete -compare client digest if supplied -transition UPLOADED → VERIFYING -run verifier coordinator -on ACCEPT call contentStore.finalizeUpload -stat published object -transition VERIFYING → READY with content key, size, digest, etag, publishedAt -commit quota with actual bytes -release writer lease -``` - -On REJECT, transition to REJECTED and enqueue cleanup. On QUARANTINE, transition to QUARANTINED. Do not return READY when metadata transition fails after physical publish; enqueue reconciliation and throw `AmbiguousCompletionException`. - -- [ ] **Step 4: Run finalize tests** - -```bash -./gradlew :modules:fileserver:fileserver-application:test \ - --tests '*FinalizeUploadServiceTest' -``` - -Expected: PASS; every READY fixture has readable content and matching size/digest. - -- [ ] **Step 5: Commit** - -```bash -git add modules/fileserver/fileserver-application -git commit -m "feat: finalize uploads with ready invariants" -``` - ---- - -### Task 15: Ambiguous completion과 파일 reconciliation 구현 - -**Files:** -- Create: `modules/fileserver/fileserver-application/src/main/java/io/backend/skeleton/fileserver/application/recovery/FileReconciliationService.java` -- Create: `modules/fileserver/fileserver-application/src/main/java/io/backend/skeleton/fileserver/application/recovery/DefaultFileReconciliationService.java` -- Create: `modules/fileserver/fileserver-application/src/main/java/io/backend/skeleton/fileserver/application/recovery/ReconciliationResult.java` -- Create: `modules/fileserver/fileserver-application/src/main/java/io/backend/skeleton/fileserver/application/recovery/ReconciliationStatus.java` -- Create: `modules/fileserver/fileserver-application/src/main/java/io/backend/skeleton/fileserver/application/recovery/RecoveryQueue.java` -- Test: `modules/fileserver/fileserver-application/src/test/java/io/backend/skeleton/fileserver/application/recovery/FileReconciliationServiceTest.java` - -**Interfaces:** -- Consumes content `stat`, metadata version/state, expected size/digest. -- Produces `CONFIRMED_SUCCESS`, `CONFIRMED_NOT_APPLIED`, `RECOVERABLE_PARTIAL`, `QUARANTINE_REQUIRED`, or `UNRESOLVED`. - -- [ ] **Step 1: Write failing ambiguous publish recovery tests** - -```java -@Test -void confirmsSuccessWhenPhysicalObjectAndMetadataMatch() { - fixture.preparePhysicalObjectAndVerifyingMetadata(); - - ReconciliationResult result = service.reconcile(fixture.fileId()); - - assertThat(result.status()).isEqualTo(ReconciliationStatus.CONFIRMED_SUCCESS); - assertThat(fixture.fileState()).isEqualTo(FileState.READY); -} -``` - -```java -@Test -void neverGuessesReadyWhenDigestCannotBeVerified() { - fixture.prepareUnknownPhysicalObject(); - - ReconciliationResult result = service.reconcile(fixture.fileId()); - - assertThat(result.status()).isEqualTo(ReconciliationStatus.UNRESOLVED); - assertThat(fixture.fileState()).isNotEqualTo(FileState.READY); -} -``` - -- [ ] **Step 2: Run the tests to verify they fail** - -```bash -./gradlew :modules:fileserver:fileserver-application:test \ - --tests '*FileReconciliationServiceTest' -``` - -Expected: FAIL because reconciliation is absent. - -- [ ] **Step 3: Implement deterministic reconciliation** - -Use the following decision rules: - -```text -metadata READY + physical size/digest match → CONFIRMED_SUCCESS -metadata pre-publish + no physical target → CONFIRMED_NOT_APPLIED -staging exists + known committed offset → RECOVERABLE_PARTIAL -physical exists + expected key/size/digest match + version unchanged → transition READY -physical exists but key/size/digest differ → QUARANTINE_REQUIRED -insufficient evidence → UNRESOLVED -``` - -Never perform blind write retry from this service. Store recovery attempts and reason codes in the cleanup/recovery queue. - -- [ ] **Step 4: Run recovery tests** - -```bash -./gradlew :modules:fileserver:fileserver-application:test \ - --tests '*FileReconciliationServiceTest' -``` - -Expected: PASS; no unresolved case changes the file to READY. - -- [ ] **Step 5: Commit** - -```bash -git add modules/fileserver/fileserver-application -git commit -m "feat: reconcile ambiguous fileserver operations" -``` - ---- - -### Task 16: Verification pipeline과 quarantine 구현 - -**Files:** -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/security/FileVerifier.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/security/VerificationRequest.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/security/VerificationResult.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/security/VerificationVerdict.java` -- Create: `modules/fileserver/fileserver-verification/src/main/java/io/backend/skeleton/fileserver/verification/VerificationCoordinator.java` -- Create: `modules/fileserver/fileserver-verification/src/main/java/io/backend/skeleton/fileserver/verification/Sha256Verifier.java` -- Create: `modules/fileserver/fileserver-verification/src/main/java/io/backend/skeleton/fileserver/verification/MediaTypeVerifier.java` -- Create: `modules/fileserver/fileserver-verification/src/main/java/io/backend/skeleton/fileserver/verification/VerificationPolicyCombiner.java` -- Test: `modules/fileserver/fileserver-verification/src/test/java/io/backend/skeleton/fileserver/verification/VerificationCoordinatorTest.java` - -**Interfaces:** -- Produces `VerificationCoordinator#verify(VerificationRequest)` consumed by Task 14. -- Verifiers return only safe metadata and stable reason codes. - -- [ ] **Step 1: Write failing accept, quarantine, and retry tests** - -```java -@Test -void rejectDominatesAccept() { - VerificationCoordinator coordinator = coordinator( - verifier("digest", VerificationVerdict.ACCEPT), - verifier("malware", VerificationVerdict.REJECT)); - - VerificationResult result = coordinator.verify(fixture.request()).toCompletableFuture().join(); - - assertThat(result.verdict()).isEqualTo(VerificationVerdict.REJECT); - assertThat(result.code()).isEqualTo("MALWARE_REJECTED"); -} - -@Test -void scannerTimeoutDoesNotBecomeAccept() { - VerificationCoordinator coordinator = coordinator(timeoutVerifier("scanner")); - - VerificationResult result = coordinator.verify(fixture.request()).toCompletableFuture().join(); - - assertThat(result.verdict()).isEqualTo(VerificationVerdict.RETRY); -} -``` - -- [ ] **Step 2: Run the tests to verify they fail** - -```bash -./gradlew :modules:fileserver:fileserver-verification:test \ - --tests '*VerificationCoordinatorTest' -``` - -Expected: FAIL because verification types do not exist. - -- [ ] **Step 3: Implement ordered verification and policy combination** - -Run verifiers in this order: - -```text -length -sha256 -filename policy -media-type detection -signature/parser -optional malware scanner -optional CDR -``` - -Combination precedence is `REJECT > QUARANTINE > RETRY > ACCEPT`. Apply per-verifier timeout and record started/completed timestamps through the metadata adapter. Never log content samples or scanner raw payloads. - -- [ ] **Step 4: Run verification tests** - -```bash -./gradlew :modules:fileserver:fileserver-verification:test -``` - -Expected: PASS; timeout, reject, quarantine, and accept paths are deterministic. - -- [ ] **Step 5: Commit** - -```bash -git add modules/fileserver/fileserver-core-api modules/fileserver/fileserver-verification -git commit -m "feat: add fileserver verification pipeline" -``` - ---- - -### Task 17: Authorization hook과 upload application service 구현 - -**Files:** -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/security/FileAccessPolicy.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/security/FileOperation.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/security/FileAccessSubject.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/security/RequestContext.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/transfer/UploadProtocol.java` -- Create: `modules/fileserver/fileserver-application/src/main/java/io/backend/skeleton/fileserver/application/UploadApplicationService.java` -- Create: `modules/fileserver/fileserver-application/src/main/java/io/backend/skeleton/fileserver/application/DefaultUploadApplicationService.java` -- Create: `modules/fileserver/fileserver-application/src/main/java/io/backend/skeleton/fileserver/application/CreateUploadRequest.java` -- Create: `modules/fileserver/fileserver-application/src/main/java/io/backend/skeleton/fileserver/application/UploadSessionView.java` -- Create: `modules/fileserver/fileserver-application/src/main/java/io/backend/skeleton/fileserver/application/AppendUploadResult.java` -- Test: `modules/fileserver/fileserver-application/src/test/java/io/backend/skeleton/fileserver/application/UploadApplicationServiceTest.java` - -**Interfaces:** -- Consumes metadata, content store, quota, state machine, filename policy, access policy. -- Produces create, append, status, cancel methods used by HTTP adapters. - -- [ ] **Step 1: Write failing authorization, create, append, cancel tests** - -```java -@Test -void authorizationRunsBeforeQuotaAndStorageMutation() { - accessPolicy.deny(FileOperation.CREATE); - - assertThatThrownBy(() -> service.create(fixture.createRequest(), fixture.context())) - .isInstanceOf(FileAccessDeniedException.class); - - assertThat(fixture.fileRecordCount()).isZero(); - assertThat(fixture.stagingFileCount()).isZero(); -} -``` - -```java -@Test -void createAppendAndCancelMaintainStateAndOffset() throws Exception { - UploadSessionView created = service.create(fixture.createRequest(), fixture.context()); - AppendUploadResult appended = service.append( - created.uploadId(), 0, fixture.channel("abc"), 3, fixture.context()); - service.cancel(created.uploadId(), fixture.context()); - - assertThat(appended.committedOffset()).isEqualTo(3); - assertThat(fixture.fileState(created.fileId())).isEqualTo(FileState.DELETING); - assertThat(fixture.publicDownloadAvailable(created.fileId())).isFalse(); -} -``` - -- [ ] **Step 2: Run the tests to verify they fail** - -```bash -./gradlew :modules:fileserver:fileserver-application:test \ - --tests '*UploadApplicationServiceTest' -``` - -Expected: FAIL because upload orchestration is absent. - -- [ ] **Step 3: Implement create, append, status, cancel** - -Create sequence: - -```text -authorize CREATE -sanitize original filename -validate expected length -acquire admission permit -reserve quota -insert CREATED file -insert upload session -create staging -transition CREATED → UPLOADING -return offset 0 and expiry -``` - -Append sequence: - -```text -authorize APPEND -load non-expired session -acquire writer lease -validate metadata offset and physical length -extend quota reservation if needed -stream append -commit offset with lease token -release lease and transfer permit -``` - -Cancel sequence transitions to DELETING first, then queues cleanup. It does not synchronously remove large content from the request thread. - -- [ ] **Step 4: Run upload application tests** - -```bash -./gradlew :modules:fileserver:fileserver-application:test \ - --tests '*UploadApplicationServiceTest' -``` - -Expected: PASS; authorization denial creates no side effect and offset commits are monotonic. - -- [ ] **Step 5: Commit** - -```bash -git add modules/fileserver/fileserver-core-api modules/fileserver/fileserver-application -git commit -m "feat: implement fileserver upload application flow" -``` - ---- - -### Task 18: HTTP Range, validator, header contract core 구현 - -**Files:** -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/transfer/HttpRangeResolver.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/transfer/DefaultHttpRangeResolver.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/transfer/RangeBudget.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/transfer/ResolvedRanges.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/transfer/ConditionalRequestEvaluator.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/transfer/DownloadDecision.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/transfer/ContentDispositionFactory.java` -- Test: `modules/fileserver/fileserver-core-api/src/test/java/io/backend/skeleton/fileserver/api/transfer/HttpRangeResolverTest.java` -- Test: `modules/fileserver/fileserver-core-api/src/test/java/io/backend/skeleton/fileserver/api/transfer/ConditionalRequestEvaluatorTest.java` - -**Interfaces:** -- Produces a framework-neutral `DownloadDecision` used by MVC, WebFlux, and Nginx. -- Default public budget is one range; optional multi-range budget is eight merged ranges. - -- [ ] **Step 1: Write failing Range and conditional tests** - -```java -@ParameterizedTest -@CsvSource({ - "bytes=0-9,0,9", - "bytes=90-,90,99", - "bytes=-10,90,99" -}) -void resolvesSingleRanges(String header, long start, long end) { - ResolvedRanges result = resolver.resolve(header, 100, RangeBudget.single()); - assertThat(result.ranges()).containsExactly(new ByteRange(start, end)); -} - -@Test -void unsatisfiableRangeCarriesRepresentationLength() { - assertThatThrownBy(() -> resolver.resolve("bytes=100-200", 100, RangeBudget.single())) - .isInstanceOf(RangeNotSatisfiableException.class) - .extracting("representationLength") - .isEqualTo(100L); -} -``` - -```java -@Test -void mismatchedIfRangeFallsBackToFullResponse() { - DownloadDecision result = evaluator.evaluate(fixture.requestWithIfRange("\"old\""), - fixture.representation("\"new\"", 100)); - - assertThat(result.status()).isEqualTo(200); - assertThat(result.ranges()).isEmpty(); -} -``` - -- [ ] **Step 2: Run tests to verify they fail** - -```bash -./gradlew :modules:fileserver:fileserver-core-api:test \ - --tests '*HttpRangeResolverTest' --tests '*ConditionalRequestEvaluatorTest' -``` - -Expected: FAIL because HTTP contract utilities do not exist. - -- [ ] **Step 3: Implement parsing and decision order** - -Implement: - -```text -If-Match / If-Unmodified-Since -If-None-Match / If-Modified-Since -Range syntax and budget -If-Range -200 / 206 / 304 / 412 / 416 -``` - -Merge overlapping ranges only when multi-range is enabled. Reject more than eight ranges or a total requested byte count above the configured budget. `ContentDispositionFactory` must emit sanitized ASCII `filename` and UTF-8 `filename*` without CR/LF. - -- [ ] **Step 4: Run all transfer contract tests** - -```bash -./gradlew :modules:fileserver:fileserver-core-api:test \ - --tests '*transfer*' -``` - -Expected: PASS for first, middle, suffix, open-ended, empty, invalid, conditional, and If-Range cases. - -- [ ] **Step 5: Commit** - -```bash -git add modules/fileserver/fileserver-core-api -git commit -m "feat: implement fileserver HTTP range contract" -``` - ---- - -### Task 19: Spring MVC raw·multipart upload adapter 구현 - -**Files:** -- Create: `modules/fileserver/fileserver-mvc/src/main/java/io/backend/skeleton/fileserver/mvc/FileUploadController.java` -- Create: `modules/fileserver/fileserver-mvc/src/main/java/io/backend/skeleton/fileserver/mvc/RawUploadRequestMapper.java` -- Create: `modules/fileserver/fileserver-mvc/src/main/java/io/backend/skeleton/fileserver/mvc/MultipartUploadRequestMapper.java` -- Create: `modules/fileserver/fileserver-mvc/src/main/java/io/backend/skeleton/fileserver/mvc/MvcTransferExecutorConfiguration.java` -- Create: `modules/fileserver/fileserver-mvc/src/main/java/io/backend/skeleton/fileserver/mvc/BatchUploadResponse.java` -- Test: `modules/fileserver/fileserver-mvc/src/test/java/io/backend/skeleton/fileserver/mvc/FileUploadControllerTest.java` -- Test: `modules/fileserver/fileserver-mvc/src/test/java/io/backend/skeleton/fileserver/mvc/MvcUploadExecutorSaturationTest.java` - -**Interfaces:** -- Consumes `UploadApplicationService` and `FinalizeUploadService`. -- Implements `POST /v1/files`, `POST /v1/files:raw`, `POST /v1/files:batch`. - -- [ ] **Step 1: Write failing MVC endpoint tests** - -```java -@Test -void rawUploadStreamsWithoutCallingReadAllBytes() throws Exception { - mockMvc.perform(post("/v1/files:raw") - .contentType(MediaType.APPLICATION_OCTET_STREAM) - .header("X-Filename", "report.bin") - .content("abc")) - .andExpect(status().isCreated()) - .andExpect(header().exists("Location")) - .andExpect(jsonPath("$.state").value("READY")); - - verify(uploadService).append(any(), eq(0L), any(ReadableByteChannel.class), eq(3L), any()); -} -``` - -```java -@Test -void batchReturnsPerPartResultsAndIsExplicitlyNonAtomic() throws Exception { - mockMvc.perform(multipart("/v1/files:batch") - .file(new MockMultipartFile("files", "a.txt", "text/plain", "a".getBytes())) - .file(new MockMultipartFile("files", "b.txt", "text/plain", "b".getBytes()))) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.results.length()").value(2)); -} -``` - -- [ ] **Step 2: Run MVC tests to verify they fail** - -```bash -./gradlew :modules:fileserver:fileserver-mvc:test \ - --tests '*FileUploadControllerTest' --tests '*MvcUploadExecutorSaturationTest' -``` - -Expected: FAIL because the controller and executor are absent. - -- [ ] **Step 3: Implement controllers with bounded streaming executor** - -Use `ServletInputStream` through `Channels.newChannel`. Do not call `getBytes()` on `MultipartFile`. Submit blocking transfer work to a `ThreadPoolTaskExecutor` configured with core 8, max 32, queue 64. Convert rejection to retryable `429` or `503` with `Retry-After`. - -Batch behavior: - -```text -maximum 16 parts -one independent upload per part -successes are retained when another part fails -return 200 with ordered result array -never expose container temp path -``` - -- [ ] **Step 4: Run MVC upload and saturation tests** - -```bash -./gradlew :modules:fileserver:fileserver-mvc:test -``` - -Expected: PASS; saturation does not create unbounded threads or queues. - -- [ ] **Step 5: Commit** - -```bash -git add modules/fileserver/fileserver-mvc -git commit -m "feat: add MVC streaming upload endpoints" -``` - ---- - -### Task 20: Spring MVC GET·HEAD·Range download adapter 구현 - -**Files:** -- Create: `modules/fileserver/fileserver-application/src/main/java/io/backend/skeleton/fileserver/application/DownloadApplicationService.java` -- Create: `modules/fileserver/fileserver-application/src/main/java/io/backend/skeleton/fileserver/application/DefaultDownloadApplicationService.java` -- Create: `modules/fileserver/fileserver-application/src/main/java/io/backend/skeleton/fileserver/application/DownloadDescriptor.java` -- Create: `modules/fileserver/fileserver-mvc/src/main/java/io/backend/skeleton/fileserver/mvc/FileDownloadController.java` -- Create: `modules/fileserver/fileserver-mvc/src/main/java/io/backend/skeleton/fileserver/mvc/MvcDownloadResponseWriter.java` -- Test: `modules/fileserver/fileserver-mvc/src/test/java/io/backend/skeleton/fileserver/mvc/FileDownloadControllerContractTest.java` - -**Interfaces:** -- Consumes authorization, metadata, `HttpRangeResolver`, conditional evaluator, content store. -- Produces identical headers for GET and HEAD and exact `200/206/304/412/416` behavior. - -- [ ] **Step 1: Write failing GET, HEAD, Range, and READY-gate tests** - -```java -@Test -void headMatchesGetHeadersWithoutBody() throws Exception { - MvcResult get = mockMvc.perform(get(contentUrl()).header("Authorization", token())) - .andExpect(status().isOk()) - .andReturn(); - - MvcResult head = mockMvc.perform(head(contentUrl()).header("Authorization", token())) - .andExpect(status().isOk()) - .andExpect(content().bytes(new byte[0])) - .andReturn(); - - assertThat(head.getResponse().getHeader("ETag")) - .isEqualTo(get.getResponse().getHeader("ETag")); - assertThat(head.getResponse().getHeader("Content-Length")) - .isEqualTo(get.getResponse().getHeader("Content-Length")); -} -``` - -```java -@Test -void returnsPartialContentForSingleRange() throws Exception { - mockMvc.perform(get(contentUrl()) - .header("Authorization", token()) - .header("Range", "bytes=2-4")) - .andExpect(status().isPartialContent()) - .andExpect(header().string("Content-Range", "bytes 2-4/10")) - .andExpect(content().bytes(new byte[]{2, 3, 4})); -} -``` - -```java -@Test -void nonReadyFileIsNeverOpened() throws Exception { - fixture.fileInState(FileState.VERIFYING); - - mockMvc.perform(get(contentUrl()).header("Authorization", token())) - .andExpect(status().isConflict()); - - verify(contentStore, never()).openRead(any(), any()); -} -``` - -- [ ] **Step 2: Run MVC download tests to verify they fail** - -```bash -./gradlew :modules:fileserver:fileserver-mvc:test \ - --tests '*FileDownloadControllerContractTest' -``` - -Expected: FAIL because download service and controller do not exist. - -- [ ] **Step 3: Implement application decision and MVC writer** - -`DefaultDownloadApplicationService` must authorize before opening content, require READY, evaluate validators and Range, then return a descriptor with status, headers, content key, and normalized ranges. `MvcDownloadResponseWriter` uses a `StreamingResponseBody` or repeatable file resource; it must not use `InputStreamResource` for Range. - -Add headers: - -```text -ETag -Last-Modified -Accept-Ranges -Content-Type -Content-Disposition -Cache-Control -Content-Length or Content-Range -``` - -For `416`, include `Content-Range: bytes */`. - -- [ ] **Step 4: Run full MVC HTTP contract tests** - -```bash -./gradlew :modules:fileserver:fileserver-mvc:test -``` - -Expected: PASS for full, HEAD, first, middle, suffix, unsatisfiable, ETag, If-Range, and non-READY cases. - -- [ ] **Step 5: Commit** - -```bash -git add modules/fileserver/fileserver-application modules/fileserver/fileserver-mvc -git commit -m "feat: add MVC fileserver download contract" -``` - ---- - -### Task 21: Spring WebFlux raw·multipart upload adapter 구현 - -**Files:** -- Create: `modules/fileserver/fileserver-webflux/src/main/java/io/backend/skeleton/fileserver/webflux/ReactiveUploadApplicationService.java` -- Create: `modules/fileserver/fileserver-webflux/src/main/java/io/backend/skeleton/fileserver/webflux/FileUploadHandler.java` -- Create: `modules/fileserver/fileserver-webflux/src/main/java/io/backend/skeleton/fileserver/webflux/PartEventUploadReader.java` -- Create: `modules/fileserver/fileserver-webflux/src/main/java/io/backend/skeleton/fileserver/webflux/DataBufferByteBufferPublisher.java` -- Create: `modules/fileserver/fileserver-webflux/src/main/java/io/backend/skeleton/fileserver/webflux/FileserverIoScheduler.java` -- Test: `modules/fileserver/fileserver-webflux/src/test/java/io/backend/skeleton/fileserver/webflux/FileUploadHandlerTest.java` -- Test: `modules/fileserver/fileserver-webflux/src/test/java/io/backend/skeleton/fileserver/webflux/DataBufferReleaseTest.java` -- Test: `modules/fileserver/fileserver-webflux/src/test/java/io/backend/skeleton/fileserver/webflux/WebFluxBlockingCallTest.java` - -**Interfaces:** -- Consumes `AsyncContentStore` when available or adapts the blocking application service on a dedicated bounded scheduler. -- Every received pooled `DataBuffer` is forwarded or released exactly once. - -- [ ] **Step 1: Write failing upload, cancellation, and buffer-release tests** - -```java -@Test -void rawUploadConsumesFluxWithoutJoiningWholeBody() { - webTestClient.post() - .uri("/v1/files:raw") - .contentType(MediaType.APPLICATION_OCTET_STREAM) - .header("X-Filename", "large.bin") - .body(Flux.just(buffer("abc"), buffer("def")), DataBuffer.class) - .exchange() - .expectStatus().isCreated() - .expectBody() - .jsonPath("$.state").isEqualTo("READY"); - - assertThat(testBufferFactory.joinInvocationCount()).isZero(); -} -``` - -```java -@Test -void cancellationReleasesAllObservedBuffers() { - StepVerifier.create(handler.consume(fixture.cancellableBuffers())) - .thenCancel() - .verify(); - - assertThat(fixture.allocatedBufferCount()).isEqualTo(fixture.releasedBufferCount()); -} -``` - -- [ ] **Step 2: Run WebFlux tests to verify they fail** - -```bash -./gradlew :modules:fileserver:fileserver-webflux:test \ - --tests '*FileUploadHandlerTest' --tests '*DataBufferReleaseTest' \ - --tests '*WebFluxBlockingCallTest' -``` - -Expected: FAIL because handlers and buffer adapters do not exist. - -- [ ] **Step 3: Implement streaming adapters and dedicated scheduler** - -`PartEventUploadReader` must process windowed multipart events sequentially and enforce part count and byte limits. Use `DataBufferUtils.release(buffer)` in every discard, error, and cancellation path. For a blocking local store, schedule filesystem work on a fixed bounded scheduler named `fileserver-io`; never use the Reactor Netty event loop. - -```java -public final class FileserverIoScheduler implements AutoCloseable { - private final Scheduler scheduler; - - public FileserverIoScheduler(int workers, int queueCapacity) { - this.scheduler = Schedulers.newBoundedElastic( - workers, queueCapacity, "fileserver-io", 60, false); - } - - public Scheduler scheduler() { - return scheduler; - } -} -``` - -- [ ] **Step 4: Run WebFlux tests with leak detection and BlockHound** - -```bash -./gradlew :modules:fileserver:fileserver-webflux:test -``` - -Expected: PASS; no unreleased buffers and no blocking call on event-loop threads. - -- [ ] **Step 5: Commit** - -```bash -git add modules/fileserver/fileserver-webflux -git commit -m "feat: add WebFlux streaming upload adapter" -``` - ---- - -### Task 22: Spring WebFlux download와 zero-copy capability 구현 - -**Files:** -- Create: `modules/fileserver/fileserver-webflux/src/main/java/io/backend/skeleton/fileserver/webflux/FileDownloadHandler.java` -- Create: `modules/fileserver/fileserver-webflux/src/main/java/io/backend/skeleton/fileserver/webflux/ReactiveDownloadResponseWriter.java` -- Create: `modules/fileserver/fileserver-webflux/src/main/java/io/backend/skeleton/fileserver/webflux/ZeroCopyEligibility.java` -- Test: `modules/fileserver/fileserver-webflux/src/test/java/io/backend/skeleton/fileserver/webflux/FileDownloadHandlerContractTest.java` -- Test: `modules/fileserver/fileserver-webflux/src/test/java/io/backend/skeleton/fileserver/webflux/SlowClientBackpressureTest.java` - -**Interfaces:** -- Reuses the exact `DownloadDecision` from Task 18. -- Produces HTTP parity with Task 20. - -- [ ] **Step 1: Write failing parity and backpressure tests** - -```java -@Test -void rangeHeadersMatchMvcContract() { - webTestClient.get() - .uri(contentUrl()) - .header("Authorization", token()) - .header("Range", "bytes=2-4") - .exchange() - .expectStatus().isEqualTo(206) - .expectHeader().valueEquals("Content-Range", "bytes 2-4/10") - .expectBody().isEqualTo(new byte[]{2, 3, 4}); -} -``` - -```java -@Test -void slowSubscriberDoesNotExceedInFlightBufferLimit() { - StepVerifier.withVirtualTime(() -> fixture.slowDownload()) - .thenAwait(Duration.ofSeconds(10)) - .thenCancel() - .verify(); - - assertThat(fixture.maxInFlightBuffers()).isLessThanOrEqualTo(8); -} -``` - -- [ ] **Step 2: Run tests to verify they fail** - -```bash -./gradlew :modules:fileserver:fileserver-webflux:test \ - --tests '*FileDownloadHandlerContractTest' --tests '*SlowClientBackpressureTest' -``` - -Expected: FAIL because download handler is absent. - -- [ ] **Step 3: Implement reactive write and optional zero-copy** - -For async stores, map `Flow.Publisher` to `Flux` with bounded demand. For local files, use zero-copy only when the response implementation supports it, no body transformation is required, and TLS/runtime constraints allow it. Zero-copy remains an optimization and does not alter the public contract. - -- [ ] **Step 4: Run WebFlux download contract tests** - -```bash -./gradlew :modules:fileserver:fileserver-webflux:test -``` - -Expected: PASS; MVC and WebFlux golden HTTP snapshots are equal for shared scenarios. - -- [ ] **Step 5: Commit** - -```bash -git add modules/fileserver/fileserver-webflux -git commit -m "feat: add WebFlux fileserver downloads" -``` - ---- - -### Task 23: Nginx `X-Accel-Redirect` 위임 구현 - -**Files:** -- Create: `modules/fileserver/fileserver-nginx/src/main/java/io/backend/skeleton/fileserver/nginx/NginxInternalUriMapper.java` -- Create: `modules/fileserver/fileserver-nginx/src/main/java/io/backend/skeleton/fileserver/nginx/DefaultNginxInternalUriMapper.java` -- Create: `modules/fileserver/fileserver-nginx/src/main/java/io/backend/skeleton/fileserver/nginx/NginxDownloadStrategy.java` -- Create: `modules/fileserver/fileserver-nginx/src/main/java/io/backend/skeleton/fileserver/nginx/NginxDelegationProperties.java` -- Create: `infra/fileserver/nginx/nginx.conf` -- Test: `modules/fileserver/fileserver-nginx/src/test/java/io/backend/skeleton/fileserver/nginx/NginxInternalUriMapperTest.java` -- Test: `modules/fileserver/fileserver-nginx/src/test/java/io/backend/skeleton/fileserver/nginx/NginxDownloadIntegrationTest.java` - -**Interfaces:** -- Consumes an authorized READY `DownloadDescriptor`. -- Produces a validated relative internal URI, never an absolute physical path. -- Default threshold is 16 MiB. - -- [ ] **Step 1: Write failing URI mapping and internal-path tests** - -```java -@Test -void mapsValidatedContentKeyWithoutExposingAbsolutePath() { - String internalUri = mapper.map(new ContentKey("ab/cd/0123456789abcdef")); - - assertThat(internalUri).isEqualTo("/__files/ab/cd/0123456789abcdef.bin"); - assertThat(internalUri).doesNotContain("/var/lib", "..", "\"); -} - -@Test -void rejectsMalformedContentKeyEvenWhenCalledInternally() { - assertThatThrownBy(() -> mapper.mapUnchecked("../../etc/passwd")) - .isInstanceOf(InvalidPathException.class); -} -``` - -```java -@Test -void directAccessToInternalLocationIsRejected() { - nginxClient.get("/__files/ab/cd/0123456789abcdef.bin") - .expectStatus(404); -} -``` - -- [ ] **Step 2: Run unit and integration tests to verify they fail** - -```bash -./gradlew :modules:fileserver:fileserver-nginx:test \ - --tests '*NginxInternalUriMapperTest' --tests '*NginxDownloadIntegrationTest' -``` - -Expected: FAIL because URI mapper and Nginx configuration do not exist. - -- [ ] **Step 3: Implement safe relative mapping and Nginx internal location** - -`DefaultNginxInternalUriMapper` accepts only a validated `ContentKey`, rebuilds the shard components, and returns a URI below `/__files/`. Configure Nginx: - -```nginx -location /__files/ { - internal; - alias /srv/files/content/; - sendfile on; - sendfile_max_chunk 2m; - add_header X-Content-Type-Options nosniff always; -} -``` - -The application response includes `X-Accel-Redirect` only after authorization and READY gate. Ensure the header is consumed by Nginx and not copied to the client. The resulting URI path after `/__files/` must map exactly to the local content layout. - -- [ ] **Step 4: Run direct-vs-Nginx HTTP parity tests** - -```bash -./gradlew :modules:fileserver:fileserver-nginx:test -``` - -Expected: PASS for full GET, HEAD, Range, ETag, Content-Disposition, private cache headers, and external internal-location rejection. - -- [ ] **Step 5: Commit** - -```bash -git add modules/fileserver/fileserver-nginx infra/fileserver/nginx -git commit -m "feat: delegate large downloads to nginx" -``` - ---- - -### Task 24: Delete, copy, move, cleanup lifecycle 구현 - -**Files:** -- Create: `modules/fileserver/fileserver-application/src/main/java/io/backend/skeleton/fileserver/application/FileLifecycleService.java` -- Create: `modules/fileserver/fileserver-application/src/main/java/io/backend/skeleton/fileserver/application/DefaultFileLifecycleService.java` -- Create: `modules/fileserver/fileserver-application/src/main/java/io/backend/skeleton/fileserver/application/cleanup/CleanupService.java` -- Create: `modules/fileserver/fileserver-application/src/main/java/io/backend/skeleton/fileserver/application/cleanup/DefaultCleanupService.java` -- Create: `modules/fileserver/fileserver-application/src/main/java/io/backend/skeleton/fileserver/application/cleanup/CleanupItem.java` -- Modify: `modules/fileserver/fileserver-storage-local/src/main/java/io/backend/skeleton/fileserver/local/LocalBlockingContentStore.java` -- Test: `modules/fileserver/fileserver-application/src/test/java/io/backend/skeleton/fileserver/application/FileLifecycleServiceTest.java` -- Test: `modules/fileserver/fileserver-application/src/test/java/io/backend/skeleton/fileserver/application/cleanup/CleanupServiceTest.java` - -**Interfaces:** -- Implements logical delete first, bounded asynchronous physical cleanup. -- Public move changes logical namespace metadata only. -- Copy defaults to create-only target. - -- [ ] **Step 1: Write failing delete and cleanup-race tests** - -```java -@Test -void logicalDeleteBlocksDownloadBeforePhysicalDeleteCompletes() { - fixture.readyFileWithSlowPhysicalDelete(); - - service.delete(fixture.fileId(), fixture.version(), fixture.context()); - - assertThat(fixture.fileState()).isEqualTo(FileState.DELETING); - assertThat(fixture.publicDownloadAvailable()).isFalse(); - assertThat(fixture.physicalObjectExists()).isTrue(); -} -``` - -```java -@Test -void cleanupDoesNotDeleteContentOwnedByAnActiveLease() { - fixture.cleanupItemForActiveUpload(); - - CleanupBatchResult result = cleanup.runBatch(100, 1L << 30); - - assertThat(result.skippedActiveLease()).isEqualTo(1); - assertThat(fixture.physicalObjectExists()).isTrue(); -} -``` - -- [ ] **Step 2: Run tests to verify they fail** - -```bash -./gradlew :modules:fileserver:fileserver-application:test \ - --tests '*FileLifecycleServiceTest' --tests '*CleanupServiceTest' -``` - -Expected: FAIL because lifecycle services do not exist. - -- [ ] **Step 3: Implement lifecycle operations** - -Delete: - -```text -authorize DELETE -validate If-Match/version -transition to DELETING -enqueue cleanup -return 202 or 204 -worker deletes physical content -release quota -transition to DELETED -``` - -Copy creates a new FileRecord and physical target; partial target is queued for cleanup on failure. Move changes logical namespace metadata without moving immutable physical content. Cleanup verifies state, version, lease, and content key before deleting. - -- [ ] **Step 4: Run lifecycle tests** - -```bash -./gradlew :modules:fileserver:fileserver-application:test \ - --tests '*FileLifecycleServiceTest' --tests '*CleanupServiceTest' -``` - -Expected: PASS; active content is never deleted and logical delete blocks reads immediately. - -- [ ] **Step 5: Commit** - -```bash -git add modules/fileserver/fileserver-application modules/fileserver/fileserver-storage-local -git commit -m "feat: implement fileserver lifecycle and cleanup" -``` - ---- - -### Task 25: 별도 Admin Plane 구현 - -**Files:** -- Create: `modules/fileserver/fileserver-admin/src/main/java/io/backend/skeleton/fileserver/admin/FileserverAdminController.java` -- Create: `modules/fileserver/fileserver-admin/src/main/java/io/backend/skeleton/fileserver/admin/StorageHealthView.java` -- Create: `modules/fileserver/fileserver-admin/src/main/java/io/backend/skeleton/fileserver/admin/OrphanAdminService.java` -- Create: `modules/fileserver/fileserver-admin/src/main/java/io/backend/skeleton/fileserver/admin/AdminAuditService.java` -- Test: `modules/fileserver/fileserver-admin/src/test/java/io/backend/skeleton/fileserver/admin/FileserverAdminControllerTest.java` -- Test: `modules/fileserver/fileserver-admin/src/test/java/io/backend/skeleton/fileserver/admin/OrphanAdminServiceTest.java` - -**Interfaces:** -- Exposes management-only health, capabilities, orphan dry-run/apply, reverify, force-delete, incomplete upload cleanup. -- Never returns physical root, filename, raw scanner data, or signed tokens. - -- [ ] **Step 1: Write failing management-isolation and dry-run tests** - -```java -@Test -void publicApplicationPortDoesNotExposeAdminEndpoints() { - publicWebClient.get().uri("/internal/fileserver/capabilities") - .exchange() - .expectStatus().isNotFound(); -} - -@Test -void orphanReconcileDefaultsToDryRun() { - managementWebClient.post().uri("/internal/fileserver/orphans:reconcile") - .bodyValue(Map.of("limit", 100)) - .exchange() - .expectStatus().isOk() - .expectBody() - .jsonPath("$.dryRun").isEqualTo(true); - - assertThat(fixture.deletedObjectCount()).isZero(); -} -``` - -- [ ] **Step 2: Run admin tests to verify they fail** - -```bash -./gradlew :modules:fileserver:fileserver-admin:test \ - --tests '*FileserverAdminControllerTest' --tests '*OrphanAdminServiceTest' -``` - -Expected: FAIL because the admin module is not implemented. - -- [ ] **Step 3: Implement management-only endpoints and audit** - -Implement endpoints from the design. `force-delete` requires an explicit reason and a second authorization predicate. Orphan apply requests require `dryRun=false`, expected object fingerprint, and bounded byte budget. Audit records operation, reason code, actor fingerprint, result, and trace ID without path or filename. - -- [ ] **Step 4: Run admin isolation and behavior tests** - -```bash -./gradlew :modules:fileserver:fileserver-admin:test -``` - -Expected: PASS; admin routes exist only on the management context and all mutating actions emit audit records. - -- [ ] **Step 5: Commit** - -```bash -git add modules/fileserver/fileserver-admin -git commit -m "feat: add isolated fileserver admin plane" -``` - ---- - -### Task 26: 다중 인스턴스 writer lease와 NFS ambiguity 처리 구현 - -**Files:** -- Create: `modules/fileserver/fileserver-application/src/main/java/io/backend/skeleton/fileserver/application/concurrency/WriterLeaseCoordinator.java` -- Create: `modules/fileserver/fileserver-application/src/main/java/io/backend/skeleton/fileserver/application/concurrency/DefaultWriterLeaseCoordinator.java` -- Create: `modules/fileserver/fileserver-application/src/main/java/io/backend/skeleton/fileserver/application/concurrency/LeaseHeartbeat.java` -- Create: `modules/fileserver/fileserver-storage-local/src/main/java/io/backend/skeleton/fileserver/local/AmbiguousFilesystemOperationDetector.java` -- Test: `modules/fileserver/fileserver-application/src/test/java/io/backend/skeleton/fileserver/application/concurrency/MultiInstanceWriterLeaseTest.java` -- Test: `modules/fileserver/fileserver-storage-local/src/test/java/io/backend/skeleton/fileserver/local/AmbiguousFilesystemOperationDetectorTest.java` - -**Interfaces:** -- Builds on DB lease methods from Task 7. -- A writer whose lease token expired or changed may not commit offset or READY state. -- Filesystem timeout with possible server-side completion becomes `AmbiguousCompletionException`. - -- [ ] **Step 1: Write failing two-node and expired-writer tests** - -```java -@Test -void onlyOneNodeCanAppendTheSameUpload() { - UploadId uploadId = fixture.activeUpload(); - - CompletableFuture nodeA = node("a").append(uploadId, 0, "abc"); - CompletableFuture nodeB = node("b").append(uploadId, 0, "xyz"); - - assertThat(successCount(nodeA, nodeB)).isEqualTo(1); - assertThat(conflictCount(nodeA, nodeB)).isEqualTo(1); - assertThat(fixture.committedOffset(uploadId)).isEqualTo(3); -} -``` - -```java -@Test -void pausedWriterCannotCommitAfterLeaseTakeover() { - WriterLease stale = coordinator.acquire(fixture.uploadId(), "node-a"); - clock.advance(Duration.ofMinutes(1)); - WriterLease current = coordinator.acquire(fixture.uploadId(), "node-b"); - - assertThatThrownBy(() -> coordinator.commitOffset(stale, 0, 3)) - .isInstanceOf(ConcurrentFileModificationException.class); - assertThat(current.owner()).isEqualTo("node-b"); -} -``` - -- [ ] **Step 2: Run tests to verify they fail** - -```bash -./gradlew :modules:fileserver:fileserver-application:test \ - :modules:fileserver:fileserver-storage-local:test \ - --tests '*MultiInstanceWriterLeaseTest' \ - --tests '*AmbiguousFilesystemOperationDetectorTest' -``` - -Expected: FAIL because coordinator and ambiguity classification are absent. - -- [ ] **Step 3: Implement lease heartbeat and ambiguity classification** - -Heartbeat renews at one third of the lease duration. Every commit validates upload ID, owner, token, expiry, expected offset, and metadata version. Do not use `FileLock` as a correctness dependency. - -Classify NFS-style outcomes: - -```text -request definitely not sent → retryable failure -server explicitly rejected → definite failure -response lost after possible rename/write → ambiguous completion -stale handle with physical evidence available → reconciliation required -``` - -- [ ] **Step 4: Run multi-instance tests with repeated scheduling jitter** - -```bash -./gradlew :modules:fileserver:fileserver-application:test \ - :modules:fileserver:fileserver-storage-local:test \ - --tests '*MultiInstanceWriterLeaseTest' \ - --tests '*AmbiguousFilesystemOperationDetectorTest' --rerun-tasks -``` - -Expected: PASS; no run commits bytes from a stale lease. - -- [ ] **Step 5: Commit** - -```bash -git add modules/fileserver/fileserver-application modules/fileserver/fileserver-storage-local -git commit -m "feat: enforce multi-instance fileserver leases" -``` - ---- - -### Task 27: tus 1.0 Stable 모듈 구현 - -**Files:** -- Create: `modules/fileserver/fileserver-tus/src/main/java/io/backend/skeleton/fileserver/tus/TusController.java` -- Create: `modules/fileserver/fileserver-tus/src/main/java/io/backend/skeleton/fileserver/tus/TusRequestParser.java` -- Create: `modules/fileserver/fileserver-tus/src/main/java/io/backend/skeleton/fileserver/tus/TusResponseHeaders.java` -- Create: `modules/fileserver/fileserver-tus/src/main/java/io/backend/skeleton/fileserver/tus/TusProperties.java` -- Create: `modules/fileserver/fileserver-tus/src/main/java/io/backend/skeleton/fileserver/tus/TusChecksumVerifier.java` -- Test: `modules/fileserver/fileserver-tus/src/test/java/io/backend/skeleton/fileserver/tus/TusProtocolContractTest.java` -- Test: `modules/fileserver/fileserver-tus/src/test/java/io/backend/skeleton/fileserver/tus/TusOffsetConcurrencyTest.java` - -**Interfaces:** -- Consumes `UploadApplicationService` create/status/append/cancel. -- Supports creation, HEAD, PATCH, checksum, expiration, termination. -- Concatenation is Beta and feature-flagged. - -- [ ] **Step 1: Write failing tus creation, HEAD, PATCH, mismatch tests** - -```java -@Test -void createsAndAppendsTusUpload() { - String location = client.post("/v1/uploads") - .header("Tus-Resumable", "1.0.0") - .header("Upload-Length", "6") - .expectStatus(201) - .returnHeader("Location"); - - client.patch(location) - .header("Tus-Resumable", "1.0.0") - .header("Upload-Offset", "0") - .contentType("application/offset+octet-stream") - .body("abc") - .expectStatus(204) - .expectHeader("Upload-Offset", "3"); - - client.head(location) - .header("Tus-Resumable", "1.0.0") - .expectStatus(204) - .expectHeader("Upload-Offset", "3"); -} -``` - -```java -@Test -void mismatchedOffsetReturns409WithoutMutation() { - fixture.uploadAtOffset(3); - - client.patch(fixture.location()) - .header("Tus-Resumable", "1.0.0") - .header("Upload-Offset", "1") - .contentType("application/offset+octet-stream") - .body("x") - .expectStatus(409); - - assertThat(fixture.offset()).isEqualTo(3); -} -``` - -- [ ] **Step 2: Run tus tests to verify they fail** - -```bash -./gradlew :modules:fileserver:fileserver-tus:test \ - --tests '*TusProtocolContractTest' --tests '*TusOffsetConcurrencyTest' -``` - -Expected: FAIL because tus endpoints do not exist. - -- [ ] **Step 3: Implement tus 1.0 protocol mapping** - -Implement: - -```text -POST creation with Location -HEAD with Upload-Offset and Upload-Length -PATCH application/offset+octet-stream -409 on offset mismatch without body mutation -Upload-Checksum validation -Upload-Expires -DELETE termination -Tus-Resumable validation on every protocol request -``` - -Use one writer lease per upload. Return `410` after expiration and release quota on termination. Concatenation uses independent part resources and verifies each part before final combine. - -- [ ] **Step 4: Run tus protocol suite** - -```bash -./gradlew :modules:fileserver:fileserver-tus:test -``` - -Expected: PASS for create, append, resume after restart, checksum, expiry, termination, and concurrent offset conflict. - -- [ ] **Step 5: Commit** - -```bash -git add modules/fileserver/fileserver-tus -git commit -m "feat: add tus 1.0 resumable uploads" -``` - ---- - -### Task 28: HTTPbis resumable upload draft-12 Experimental 모듈 구현 - -**Files:** -- Create: `modules/fileserver/fileserver-resumable-httpbis-draft12/src/main/java/io/backend/skeleton/fileserver/httpbisdraft12/Draft12UploadController.java` -- Create: `modules/fileserver/fileserver-resumable-httpbis-draft12/src/main/java/io/backend/skeleton/fileserver/httpbisdraft12/Draft12Headers.java` -- Create: `modules/fileserver/fileserver-resumable-httpbis-draft12/src/main/java/io/backend/skeleton/fileserver/httpbisdraft12/Draft12ProblemDetails.java` -- Create: `modules/fileserver/fileserver-resumable-httpbis-draft12/src/main/java/io/backend/skeleton/fileserver/httpbisdraft12/Draft12Properties.java` -- Test: `modules/fileserver/fileserver-resumable-httpbis-draft12/src/test/java/io/backend/skeleton/fileserver/httpbisdraft12/Draft12ProtocolTest.java` -- Test: `modules/fileserver/fileserver-resumable-httpbis-draft12/src/test/java/io/backend/skeleton/fileserver/httpbisdraft12/DraftIsolationTest.java` - -**Interfaces:** -- Reuses application upload services but has a distinct endpoint namespace and media types. -- Module is disabled by default and its package, properties, and docs include `draft12`. - -- [ ] **Step 1: Write failing draft protocol and isolation tests** - -```java -@Test -void disabledDraftDoesNotRegisterEndpoints() { - contextRunner.withPropertyValues("backend.fileserver.httpbis-draft12.enabled=false") - .run(context -> assertThat(context).doesNotHaveBean(Draft12UploadController.class)); -} -``` - -```java -@Test -void offsetMismatchReturnsDraftProblemDetail() { - fixture.uploadAtOffset(10); - - client.patch(fixture.draftLocation()) - .header("Upload-Offset", "5") - .contentType("application/partial-upload") - .body("abc") - .expectStatus(409) - .expectJsonPath("$.expectedOffset", 10) - .expectJsonPath("$.providedOffset", 5); -} -``` - -- [ ] **Step 2: Run tests to verify they fail** - -```bash -./gradlew :modules:fileserver:fileserver-resumable-httpbis-draft12:test -``` - -Expected: FAIL because the Experimental module is absent. - -- [ ] **Step 3: Implement draft-12 behind an explicit feature flag** - -Implement only the researched draft-12 contract: `Upload-Offset`, `Upload-Complete`, `application/partial-upload`, offset mismatch problem detail, and runtime capability for 104 interim response. Do not share controller paths or DTOs with tus. Add an `ExperimentalApi` marker annotation and runtime warning on enablement. - -- [ ] **Step 4: Run isolation and protocol tests** - -```bash -./gradlew :modules:fileserver:fileserver-resumable-httpbis-draft12:test -``` - -Expected: PASS; disabled mode registers no endpoints and Stable modules have no dependency on draft types. - -- [ ] **Step 5: Commit** - -```bash -git add modules/fileserver/fileserver-resumable-httpbis-draft12 -git commit -m "feat: add experimental HTTP resumable draft12" -``` - ---- - -### Task 29: HTTP Problem Detail과 보안 hardening 통합 구현 - -**Files:** -- Create: `modules/fileserver/fileserver-mvc/src/main/java/io/backend/skeleton/fileserver/mvc/FileserverMvcExceptionHandler.java` -- Create: `modules/fileserver/fileserver-webflux/src/main/java/io/backend/skeleton/fileserver/webflux/FileserverWebFluxExceptionHandler.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/error/FileserverProblem.java` -- Create: `modules/fileserver/fileserver-verification/src/main/java/io/backend/skeleton/fileserver/verification/ScriptableContentPolicy.java` -- Test: `modules/fileserver/fileserver-testkit/src/test/java/io/backend/skeleton/fileserver/testkit/security/PathTraversalSecurityTest.java` -- Test: `modules/fileserver/fileserver-testkit/src/test/java/io/backend/skeleton/fileserver/testkit/security/SymlinkRaceSecurityTest.java` -- Test: `modules/fileserver/fileserver-testkit/src/test/java/io/backend/skeleton/fileserver/testkit/security/FilenameInjectionSecurityTest.java` -- Test: `modules/fileserver/fileserver-testkit/src/test/java/io/backend/skeleton/fileserver/testkit/security/RangeBombSecurityTest.java` -- Test: `modules/fileserver/fileserver-testkit/src/test/java/io/backend/skeleton/fileserver/testkit/security/ScriptableContentSecurityTest.java` - -**Interfaces:** -- Maps the same core failure context to MVC and WebFlux `application/problem+json`. -- Security tests run against both adapters. - -- [ ] **Step 1: Write failing problem-detail and attack tests** - -```java -@Test -void offsetMismatchProblemDoesNotExposePath() { - ProblemResponse response = client.patchOffsetMismatch(); - - assertThat(response.status()).isEqualTo(409); - assertThat(response.json("code")).isEqualTo("UPLOAD_OFFSET_MISMATCH"); - assertThat(response.body()).doesNotContain("/var/lib", "staging", "java.nio.file"); -} -``` - -```java -@ParameterizedTest -@ValueSource(strings = {"../x", "%2e%2e%2fx", "/etc/passwd", "C:\\Windows\\system.ini"}) -void rejectsPathShapedInputs(String input) { - client.uploadWithFilename(input).expectNoStorageEscape(); -} -``` - -```java -@Test -void excessiveRangesAreRejectedBeforeContentOpen() { - client.getWithRange("bytes=0-0,2-2,4-4,6-6,8-8,10-10,12-12,14-14,16-16") - .expectClientError(); - assertThat(fixture.contentOpenCount()).isZero(); -} -``` - -- [ ] **Step 2: Run security tests to verify they fail** - -```bash -./gradlew :modules:fileserver:fileserver-testkit:test \ - --tests '*security*' -``` - -Expected: FAIL because unified error mapping and all guards are not connected. - -- [ ] **Step 3: Implement error mapping and hardening** - -Map every `FileserverErrorCode` to the design status code and emit: - -```json -{ - "type": "urn:fileserver:problem:", - "title": "stable title", - "status": 409, - "code": "UPLOAD_OFFSET_MISMATCH", - "retryable": true, - "traceId": "..." -} -``` - -Add `X-Content-Type-Options: nosniff`; default scriptable content to attachment; enforce range budget before content open; ensure symlink checks occur at open time, not only at path construction. - -- [ ] **Step 4: Run MVC, WebFlux, and security suites** - -```bash -./gradlew :modules:fileserver:fileserver-mvc:test \ - :modules:fileserver:fileserver-webflux:test \ - :modules:fileserver:fileserver-testkit:test \ - --tests '*security*' --tests '*ExceptionHandler*' -``` - -Expected: PASS; MVC and WebFlux problem JSON is equivalent and contains no sensitive path data. - -- [ ] **Step 5: Commit** - -```bash -git add modules/fileserver/fileserver-core-api \ - modules/fileserver/fileserver-mvc \ - modules/fileserver/fileserver-webflux \ - modules/fileserver/fileserver-verification \ - modules/fileserver/fileserver-testkit -git commit -m "feat: harden fileserver HTTP and error handling" -``` - ---- - -### Task 30: Metric, trace, audit와 민감정보 차단 구현 - -**Files:** -- Create: `modules/fileserver/fileserver-application/src/main/java/io/backend/skeleton/fileserver/application/observability/FileserverMetrics.java` -- Create: `modules/fileserver/fileserver-application/src/main/java/io/backend/skeleton/fileserver/application/observability/FileserverTracing.java` -- Create: `modules/fileserver/fileserver-application/src/main/java/io/backend/skeleton/fileserver/application/observability/SafeFileFingerprint.java` -- Create: `modules/fileserver/fileserver-application/src/main/java/io/backend/skeleton/fileserver/application/observability/FileserverAuditEvent.java` -- Test: `modules/fileserver/fileserver-application/src/test/java/io/backend/skeleton/fileserver/application/observability/FileserverObservabilityTest.java` -- Test: `modules/fileserver/fileserver-testkit/src/test/java/io/backend/skeleton/fileserver/testkit/security/SensitiveTelemetryLeakTest.java` - -**Interfaces:** -- Produces metric names and spans defined in the design. -- High-cardinality IDs and raw metadata are prohibited. - -- [ ] **Step 1: Write failing metric and leak tests** - -```java -@Test -void uploadMetricUsesBoundedTags() { - metrics.recordUpload( - UploadProtocol.RAW, - "LOCAL", - "READY", - SizeBucket.MEDIUM, - Duration.ofMillis(10), - 1024); - - Meter meter = registry.find("fileserver.upload.duration").meter(); - assertThat(meter.getId().getTags()) - .extracting(Tag::getKey) - .containsExactlyInAnyOrder("protocol", "storage", "result", "size_bucket"); -} -``` - -```java -@Test -void telemetryNeverContainsFilenamePathOrRawIds() { - fixture.runUpload("private-name.pdf", "/var/lib/backend/files", fixture.fileId()); - - assertThat(fixture.allTelemetryText()) - .doesNotContain("private-name.pdf", "/var/lib/backend/files", fixture.fileId().toString()); -} -``` - -- [ ] **Step 2: Run observability tests to verify they fail** - -```bash -./gradlew :modules:fileserver:fileserver-application:test \ - :modules:fileserver:fileserver-testkit:test \ - --tests '*FileserverObservabilityTest' --tests '*SensitiveTelemetryLeakTest' -``` - -Expected: FAIL because instrumentation is absent. - -- [ ] **Step 3: Implement bounded metrics, spans, and audit** - -Add timers/counters for upload, download, active transfer, interruption, offset mismatch, checksum, verification queue, temp/orphan, quota, cleanup, delegation, and access denial. Add spans named exactly as the design. When correlation is required, use a keyed HMAC fingerprint; never emit the raw file ID or checksum. - -- [ ] **Step 4: Run observability and sensitive-log tests** - -```bash -./gradlew :modules:fileserver:fileserver-application:test \ - :modules:fileserver:fileserver-testkit:test \ - --tests '*Observability*' --tests '*SensitiveTelemetryLeakTest' -``` - -Expected: PASS; all tags belong to the approved bounded vocabulary. - -- [ ] **Step 5: Commit** - -```bash -git add modules/fileserver/fileserver-application modules/fileserver/fileserver-testkit -git commit -m "feat: add safe fileserver observability" -``` - ---- - -### Task 31: Spring Boot properties와 auto-configuration 구현 - -**Files:** -- Create: `modules/fileserver/fileserver-spring-boot-starter/src/main/java/io/backend/skeleton/fileserver/autoconfigure/FileserverProperties.java` -- Create: `modules/fileserver/fileserver-spring-boot-starter/src/main/java/io/backend/skeleton/fileserver/autoconfigure/FileserverAutoConfiguration.java` -- Create: `modules/fileserver/fileserver-spring-boot-starter/src/main/java/io/backend/skeleton/fileserver/autoconfigure/FileserverMvcAutoConfiguration.java` -- Create: `modules/fileserver/fileserver-spring-boot-starter/src/main/java/io/backend/skeleton/fileserver/autoconfigure/FileserverWebFluxAutoConfiguration.java` -- Create: `modules/fileserver/fileserver-spring-boot-starter/src/main/java/io/backend/skeleton/fileserver/autoconfigure/FileserverNginxAutoConfiguration.java` -- Create: `modules/fileserver/fileserver-spring-boot-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports` -- Test: `modules/fileserver/fileserver-spring-boot-starter/src/test/java/io/backend/skeleton/fileserver/autoconfigure/FileserverAutoConfigurationTest.java` -- Test: `modules/fileserver/fileserver-spring-boot-starter/src/test/java/io/backend/skeleton/fileserver/autoconfigure/FileserverPropertiesValidationTest.java` - -**Interfaces:** -- Binds the exact `backend.fileserver.*` property tree from the design. -- Creates MVC or WebFlux adapters only when their runtime is present. -- Production startup must fail without a real `FileAccessPolicy`. - -- [ ] **Step 1: Write failing default-binding and invalid-startup tests** - -```java -@Test -void bindsStandardProfileDefaults() { - contextRunner.withPropertyValues( - "backend.fileserver.enabled=true", - "backend.fileserver.storage.root=" + tempDir) - .withUserConfiguration(TestAccessPolicyConfiguration.class) - .run(context -> { - FileserverProperties properties = context.getBean(FileserverProperties.class); - assertThat(properties.upload().maxFileSize()).isEqualTo(DataSize.ofMegabytes(100)); - assertThat(properties.storage().bufferSize()).isEqualTo(DataSize.ofKilobytes(128)); - assertThat(properties.upload().maxParts()).isEqualTo(16); - }); -} -``` - -```java -@Test -void productionRejectsNoOpAuthorizationPolicy() { - contextRunner.withPropertyValues( - "spring.profiles.active=prod", - "backend.fileserver.enabled=true", - "backend.fileserver.storage.root=" + tempDir) - .run(context -> assertThat(context).hasFailed()); -} -``` - -- [ ] **Step 2: Run starter tests to verify they fail** - -```bash -./gradlew :modules:fileserver:fileserver-spring-boot-starter:test \ - --tests '*FileserverAutoConfigurationTest' \ - --tests '*FileserverPropertiesValidationTest' -``` - -Expected: FAIL because properties and auto-configurations do not exist. - -- [ ] **Step 3: Implement typed properties and conditional beans** - -Bind these groups exactly: - -```text -storage -upload -download -nginx -verification -quota -cleanup -tus -httpbis-draft12 -mvc.executor -webflux -``` - -Validate: - -```text -root is absolute and outside configured webroot/config roots -maxRequestSize >= maxFileSize -soft limit < hard limit -maxRanges between 1 and 8 -ATOMIC_MOVE_REQUIRED matches probe -scanner-required has a verifier bean -nginx enabled has token service and internal prefix -tus and draft endpoints do not collide -``` - -Use `@ConditionalOnWebApplication` and `@ConditionalOnClass` so MVC and WebFlux adapters do not appear together accidentally unless an explicit dual-adapter test application requests both. - -- [ ] **Step 4: Run starter context tests** - -```bash -./gradlew :modules:fileserver:fileserver-spring-boot-starter:test -``` - -Expected: PASS; invalid property combinations fail during context startup with stable validation messages. - -- [ ] **Step 5: Commit** - -```bash -git add modules/fileserver/fileserver-spring-boot-starter -git commit -m "feat: add fileserver Spring Boot starter" -``` - ---- - -### Task 32: Filesystem, HTTP, fault, performance Testkit 구현 - -**Files:** -- Create: `modules/fileserver/fileserver-testkit/src/main/java/io/backend/skeleton/fileserver/testkit/ContentStoreContract.java` -- Create: `modules/fileserver/fileserver-testkit/src/main/java/io/backend/skeleton/fileserver/testkit/HttpDownloadContract.java` -- Create: `modules/fileserver/fileserver-testkit/src/main/java/io/backend/skeleton/fileserver/testkit/CrashPoint.java` -- Create: `modules/fileserver/fileserver-testkit/src/main/java/io/backend/skeleton/fileserver/testkit/ProcessCrashHarness.java` -- Create: `modules/fileserver/fileserver-testkit/src/main/java/io/backend/skeleton/fileserver/testkit/NfsTestEnvironment.java` -- Create: `modules/fileserver/fileserver-testkit/src/main/java/io/backend/skeleton/fileserver/testkit/PvcCertificationDescriptor.java` -- Create: `modules/fileserver/fileserver-testkit/src/test/java/io/backend/skeleton/fileserver/testkit/LocalContentStoreContractTest.java` -- Create: `modules/fileserver/fileserver-testkit/src/test/java/io/backend/skeleton/fileserver/testkit/CrashRecoveryMatrixTest.java` -- Create: `modules/fileserver/fileserver-testkit/src/test/java/io/backend/skeleton/fileserver/testkit/LargeFileBoundedMemoryTest.java` -- Create: `modules/fileserver/fileserver-testkit/src/test/java/io/backend/skeleton/fileserver/testkit/NfsAmbiguityIntegrationTest.java` -- Create: `infra/fileserver/nfs/compose.yml` -- Create: `infra/fileserver/kubernetes/pvc-certification-job.yaml` - -**Interfaces:** -- Produces reusable contracts for future Object Storage adapters. -- Provides crash points before/after append, publish, and metadata commit. -- Certification descriptors identify Kubernetes, CSI, StorageClass, access mode, backend, and mount options. - -- [ ] **Step 1: Write failing contract and crash-matrix tests** - -```java -abstract class ContentStoreContract { - protected abstract BlockingContentStore store(); - - @Test - void createAppendFinalizeStatReadDeleteRoundTrip() throws Exception { - UploadHandle handle = store().createUpload(fixture.createCommand()); - store().append(handle, 0, fixture.channel("abcdef"), 6); - StoredContent content = store().finalizeUpload(handle, fixture.finalizeCommand()); - - assertThat(store().stat(content.contentKey()).size()).isEqualTo(6); - assertThat(fixture.read(store().openRead(content.contentKey(), new ByteRange(1, 3)))) - .isEqualTo("bcd"); - assertThat(store().delete(content.contentKey(), DeletePrecondition.none()).deleted()) - .isTrue(); - } -} -``` - -```java -@ParameterizedTest -@EnumSource(CrashPoint.class) -void readyInvariantSurvivesEveryCrashPoint(CrashPoint crashPoint) { - harness.runUploadAndKillAt(crashPoint); - harness.restartAndReconcile(); - - assertThat(harness.readyFiles()) - .allSatisfy(file -> { - assertThat(file.physicalContentExists()).isTrue(); - assertThat(file.digestMatches()).isTrue(); - }); -} -``` - -- [ ] **Step 2: Run testkit tests to verify they fail** - -```bash -./gradlew :modules:fileserver:fileserver-testkit:test \ - --tests '*ContentStoreContract*' --tests '*CrashRecoveryMatrixTest' -``` - -Expected: FAIL because the testkit contracts and harness do not exist. - -- [ ] **Step 3: Implement reusable certification harnesses** - -Implement contract scenarios for: - -```text -create-only race -append offset -range read -checksum -finalize -logical and physical delete -symlink no-follow -disk full -permission denied -process kill at every crash point -slow client -network interruption -NFS rename ambiguity -large-file bounded heap and direct memory -``` - -The NFS environment must support server restart and a network cut. The PVC job writes a machine-readable result containing the full certification tuple and probe results. - -- [ ] **Step 4: Run local, NFS, and large-file suites** - -```bash -./gradlew :modules:fileserver:fileserver-testkit:test -``` - -Expected: PASS for local tests; NFS tests are tagged and run when `FILESERVER_NFS_TESTS=true`. Large-file test confirms heap does not scale with file size. - -- [ ] **Step 5: Commit** - -```bash -git add modules/fileserver/fileserver-testkit infra/fileserver/nfs infra/fileserver/kubernetes -git commit -m "test: add fileserver certification harness" -``` - ---- - -### Task 33: CI matrix, 지원 문서, 운영 Runbook, release gate 연결 - -**Files:** -- Create: `.github/workflows/fileserver-pr.yml` -- Create: `.github/workflows/fileserver-nightly.yml` -- Create: `.github/workflows/fileserver-release.yml` -- Create: `docs/fileserver/support-matrix.md` -- Create: `docs/fileserver/http-contract.md` -- Create: `docs/fileserver/storage-certification.md` -- Create: `docs/fileserver/security.md` -- Create: `docs/fileserver/operations.md` -- Create: `docs/fileserver/upgrade-guide.md` -- Create: `modules/fileserver/fileserver-testkit/src/test/java/io/backend/skeleton/fileserver/testkit/DocumentationCoverageTest.java` - -**Interfaces:** -- Connects every support claim to a CI job or certification artifact. -- Documents Stable, Beta, Limited, Compatibility, and Experimental levels. - -- [ ] **Step 1: Write a failing documentation coverage test** - -```java -class DocumentationCoverageTest { - @Test - void everyRuntimeProfileHasAReferencedCiJob() throws Exception { - SupportMatrix matrix = SupportMatrix.load(Path.of("docs/fileserver/support-matrix.md")); - WorkflowIndex workflows = WorkflowIndex.load(Path.of(".github/workflows")); - - assertThat(matrix.requiredProfiles()) - .allMatch(profile -> workflows.containsJob(profile.ciJob())); - } - - @Test - void everyPublicEndpointAppearsInHttpContract() throws Exception { - Set endpoints = EndpointScanner.scanPublicFileserverEndpoints(); - String contract = Files.readString(Path.of("docs/fileserver/http-contract.md")); - - assertThat(endpoints).allMatch(contract::contains); - } -} -``` - -- [ ] **Step 2: Run the coverage test to verify it fails** - -```bash -./gradlew :modules:fileserver:fileserver-testkit:test \ - --tests '*DocumentationCoverageTest' -``` - -Expected: FAIL because workflows and docs do not exist. - -- [ ] **Step 3: Add workflows and complete operational documentation** - -PR workflow runs: - -```text -unit and architecture tests -local ext4 contract -MVC Tomcat contract -WebFlux Reactor Netty contract -security suite -bounded-memory regression -``` - -Nightly runs: - -```text -XFS -NFSv4.1 and server restart -Windows NTFS compatibility -large-file performance -slow client -process-kill matrix -``` - -Release runs: - -```text -Spring Framework 6.2 and 7.0 compatible lines -Nginx stable -PVC RWO certification -optional PVC RWX certification -multi-instance lease -fault injection -sensitive telemetry scan -support matrix diff -``` - -`operations.md` must include storage-full, orphan growth, verification backlog, NFS ambiguity, PVC remount, Nginx delegation failure, and cleanup backlog runbooks with exact metric names and recovery commands. - -- [ ] **Step 4: Run documentation coverage and full release verification** - -```bash -./gradlew clean test -./gradlew :modules:fileserver:fileserver-testkit:test \ - --tests '*DocumentationCoverageTest' -``` - -Expected: PASS; every support claim maps to a concrete workflow job and every public endpoint is documented. - -- [ ] **Step 5: Commit** - -```bash -git add .github/workflows docs/fileserver modules/fileserver/fileserver-testkit -git commit -m "docs: connect fileserver support claims to CI" -``` - ---- - -## 3. 작업 간 의존 순서 - -```text -Task 1 -├─ Task 2 -│ ├─ Task 3 -│ ├─ Task 4 -│ └─ Task 5 -│ └─ Task 6 -│ └─ Task 7 -├─ Task 8 -│ └─ Task 9 -│ ├─ Task 10 -│ └─ Task 11 -├─ Task 12 -├─ Task 13 -│ └─ Task 14 -│ └─ Task 15 -├─ Task 16 -│ └─ Task 14 integration -├─ Task 17 -├─ Task 18 -│ ├─ Task 20 -│ ├─ Task 22 -│ └─ Task 23 -├─ Task 19 -├─ Task 21 -├─ Task 24 -│ └─ Task 25 -├─ Task 26 -│ ├─ Task 27 -│ └─ Task 28 -├─ Task 29 -├─ Task 30 -├─ Task 31 -├─ Task 32 -└─ Task 33 -``` - -권장 직렬 실행 순서는 Task 1부터 Task 33까지다. 병렬 실행은 다음 묶음에서만 허용한다. - -```text -Task 16 verification ↔ Task 18 HTTP contract -Task 19 MVC upload ↔ Task 21 WebFlux upload -Task 20 MVC download ↔ Task 22 WebFlux download -Task 27 tus ↔ Task 28 draft12, 단 Task 26 완료 후 -Task 29 security ↔ Task 30 observability, 공통 API가 안정된 후 -``` - ---- - -## 4. 단계별 Release 기준 - -### Milestone A — Core Alpha - -완료 작업: - -```text -Task 1~15 -``` - -Gate: - -- core module dependency boundary 통과 -- metadata migration·optimistic locking 통과 -- local create·append·digest·publish contract 통과 -- READY invariant와 ambiguous reconciliation 통과 -- 100 MiB upload에서 bounded memory 확인 - -### Milestone B — HTTP Beta - -완료 작업: - -```text -Task 16~22, Task 29 -``` - -Gate: - -- raw·multipart upload -- GET·HEAD·single Range -- conditional request -- MVC·WebFlux parity -- DataBuffer leak 0 -- path·symlink·filename·range security suite 통과 - -### Milestone C — Distributed RC - -완료 작업: - -```text -Task 23~26, Task 30~32 -``` - -Gate: - -- Nginx parity -- logical delete와 cleanup -- admin isolation -- two-node writer lease -- PVC RWO certification -- process-kill matrix -- sensitive telemetry scan - -### Milestone D — Extended Release - -완료 작업: - -```text -Task 27~28, Task 33 -``` - -Gate: - -- tus 1.0 protocol suite -- draft12 isolation -- NFS limited profile fault tests -- support matrix와 CI mapping -- operations runbook review - ---- - -## 5. 구현자가 임의로 변경하면 안 되는 결정 - -- `ContentStore`에 `Path` 또는 provider SDK 타입을 추가하지 않는다. -- public endpoint에 path query parameter를 추가하지 않는다. -- state 변경을 JPA entity setter로 우회하지 않는다. -- READY gate를 controller마다 복제하지 않고 application service에서 강제한다. -- create-only 기본을 overwrite 기본으로 바꾸지 않는다. -- atomic move 지원을 설정값만으로 가정하지 않는다. -- `Files.exists` 후 create하는 TOCTOU 패턴을 사용하지 않는다. -- WebFlux body를 `DataBufferUtils.join`으로 전체 적재하지 않는다. -- MVC에서 `MultipartFile#getBytes()`를 사용하지 않는다. -- filename 또는 client MIME을 physical key·보안 verdict로 사용하지 않는다. -- scanner timeout을 ACCEPT로 변환하지 않는다. -- multi-instance 정확성을 `FileLock` 또는 NFS lock에 맡기지 않는다. -- Nginx internal URI에 physical path를 넣지 않는다. -- tus와 HTTPbis draft DTO·endpoint를 공유하지 않는다. -- cleanup이 version·lease 확인 없이 삭제하지 않는다. -- `AmbiguousCompletionException`을 일반 retryable exception으로 낮추지 않는다. - ---- - -## 6. 계획 자체 검증 체크리스트 - -- [ ] 설계서의 포함 범위가 최소 하나의 Task에 매핑된다. -- [ ] 설계서의 비지원 범위를 구현하는 Task가 없다. -- [ ] Task 1~33 번호가 연속적이다. -- [ ] 모든 Task에 Files, Interfaces, 실패 테스트, 실패 확인, 구현, 통과 확인, commit이 있다. -- [ ] later Task가 사용하는 공개 타입은 earlier Task에서 정의된다. -- [ ] MVC·WebFlux·Nginx가 동일한 `DownloadDecision`을 사용한다. -- [ ] READY transition은 physical stat·digest 검증 뒤에만 실행된다. -- [ ] multi-instance append는 lease token과 expected offset을 요구한다. -- [ ] tus Stable과 draft Experimental이 분리돼 있다. -- [ ] security suite가 traversal, symlink, filename, Range, scriptable content를 포함한다. -- [ ] CI와 support matrix가 자동 coverage test로 연결된다. -- [ ] 문서에 미확정 표식, 빈 구현 지시, 무정의 type이 없다. - ---- - -## 7. 실행 인계 - -계획 실행 시 권장 방식은 `superpowers:subagent-driven-development`다. 각 Task마다 새 작업자를 사용하고 다음 두 단계 review를 적용한다. - -```text -1. 요구사항·설계 일치 review -2. 코드 품질·테스트 evidence review -``` - -동일 세션에서 실행할 경우 `superpowers:executing-plans`를 사용하고 Milestone A, B, C, D마다 전체 test·diff·문서 gate를 확인한다. diff --git a/fileserver-superpowers-package/validate_fileserver_docs.py b/fileserver-superpowers-package/validate_fileserver_docs.py deleted file mode 100644 index f52b18a..0000000 --- a/fileserver-superpowers-package/validate_fileserver_docs.py +++ /dev/null @@ -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) diff --git a/httpclient-superpowers-package/README.md b/httpclient-superpowers-package/README.md deleted file mode 100644 index 447d14d..0000000 --- a/httpclient-superpowers-package/README.md +++ /dev/null @@ -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에 맞춰 경로만 조정하고 공개 계약과 정책 의미론은 유지한다. diff --git a/httpclient-superpowers-package/VALIDATION.md b/httpclient-superpowers-package/VALIDATION.md deleted file mode 100644 index 4f53497..0000000 --- a/httpclient-superpowers-package/VALIDATION.md +++ /dev/null @@ -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는 명시된 구현 가정이다. diff --git a/httpclient-superpowers-package/docs/superpowers/plans/2026-08-08-httpclient-platform-implementation-plan.md b/httpclient-superpowers-package/docs/superpowers/plans/2026-08-08-httpclient-platform-implementation-plan.md deleted file mode 100644 index d00adfa..0000000 --- a/httpclient-superpowers-package/docs/superpowers/plans/2026-08-08-httpclient-platform-implementation-plan.md +++ /dev/null @@ -1,3635 +0,0 @@ -# HTTP Client Platform Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Spring 기반 Backend Skeleton에 Typed Service Client, Named Client Profile, 증거 기반 Retry, Blocking·Reactive 전송, OAuth2·TLS, Dynamic URL SSRF 방어, Streaming·SSE, 관측성을 제공하는 운영 가능한 외부 HTTP Client 플랫폼을 구현한다. - -**Architecture:** 일반 서비스 코드는 `@HttpExchange` 기반 H1 Typed Client를 사용하고, H2 Generic Gateway와 H3 Dynamic Target Gateway는 별도 권한 경계로 제공한다. 모든 호출은 immutable Named Client Profile에서 transport, pool, timeout, auth, resilience, security, observability 설정을 가져오며, Retry Coordinator가 `OperationIdempotency`, `BodyReplayability`, `ExecutionEvidence`, deadline, retry budget을 근거로 물리 시도를 통제한다. Blocking 경로는 RestClient와 Apache/JDK, Reactive 경로는 WebClient와 Reactor Netty를 사용한다. - -**Tech Stack:** Java 21, Gradle Kotlin DSL, Spring Framework 6.2 common baseline with Spring 7.0 compatibility tests, Spring RestClient, Spring WebClient, Spring HTTP Service Client, Apache HttpClient 5, JDK HttpClient, Reactor Netty, Resilience4j, Spring Security OAuth2 Client, Micrometer, OpenTelemetry, JUnit 5, AssertJ, ArchUnit, MockWebServer, WireMock, Testcontainers, Toxiproxy, BlockHound. - -## Global Constraints - -- 일반 업무 모듈의 기본 진입점은 H1 Typed Service Client다. -- H2 Generic Gateway는 등록된 profile의 scheme, host, port, TLS, credential, hard limit을 변경하지 못한다. -- H3 Dynamic Target Gateway는 Trusted profile의 credential, Cookie, default header를 상속하지 않는다. -- H4 Native engine API는 application-facing public API로 노출하지 않는다. -- 모든 upstream은 고유한 Named Client Profile을 가진다. -- Blocking 기본 전송은 RestClient + Apache HttpClient 5이며 JDK HttpClient는 경량 대안이다. -- Reactive·Streaming 기본 전송은 WebClient + Reactor Netty다. -- HTTP/1.1과 HTTP/2는 Stable, HTTP/3는 Experimental이다. -- RestTemplate은 migration module에서만 사용하고 신규 기능을 추가하지 않는다. -- production에서 Simple request factory를 허용하지 않는다. -- total deadline은 pool acquire, DNS, connect, TLS, request write, response read, retry backoff 전체를 감싼다. -- Retry는 method만으로 결정하지 않고 idempotency, idempotency key, body replayability, execution evidence, deadline, retry budget을 함께 판정한다. -- `NOT_SENT`는 전송되지 않았음을 증명할 수 있을 때만 사용한다. -- 비멱등 `SENT_NO_RESPONSE`는 자동 Retry하지 않고 `HttpAmbiguousExecutionException`으로 반환한다. -- first response byte가 application에 전달된 뒤 transparent Retry를 금지한다. -- Retry backoff 동안 connection과 attempt bulkhead permit을 보유하지 않는다. -- 물리 시도는 Circuit Breaker → Rate Limiter → Bulkhead → HTTP Call 순서를 사용한다. -- OAuth2 token refresh는 동일 cache key에 대해 single-flight다. -- 401 자동 재호출은 최대 한 번이며 replayable하고 안전한 operation에만 적용한다. -- TLS 1.2·1.3과 hostname verification을 강제하고 trust-all과 평문 fallback을 금지한다. -- Dynamic Target는 URI canonicalization, 모든 DNS 결과의 IP 검증, 실제 connection pinning, redirect 재검증을 수행한다. -- metric label에는 전체 URL, query value, path variable, user ID, tenant ID 원문, token, Cookie, idempotency key를 기록하지 않는다. -- Reactive event-loop에서 blocking DNS, file I/O, token load, JSON 변환을 실행하지 않는다. -- 모든 response lifecycle은 성공, 실패, decode error, size 초과, cancel에서 connection·buffer를 정리한다. -- 모든 작업은 실패 테스트 작성 → 실패 확인 → 최소 구현 → 통과 확인 → 커밋 순서로 수행한다. -- 각 Task는 독립 검토 가능한 하나의 커밋으로 종료한다. - ---- - -## 1. 확정 파일 구조 - -```text -backend-skeleton/ -├── settings.gradle.kts -├── build.gradle.kts -├── build-logic/ -│ └── src/main/kotlin/httpclient-library-conventions.gradle.kts -├── modules/httpclient/ -│ ├── httpclient-core-api/ -│ ├── httpclient-profile/ -│ ├── httpclient-transport-spi/ -│ ├── httpclient-transport-apache/ -│ ├── httpclient-transport-jdk/ -│ ├── httpclient-restclient/ -│ ├── httpclient-resilience/ -│ ├── httpclient-auth/ -│ ├── httpclient-security/ -│ ├── httpclient-observability/ -│ ├── httpclient-transport-reactor-netty/ -│ ├── httpclient-webclient/ -│ ├── httpclient-service-client/ -│ ├── httpclient-dynamic-target/ -│ ├── httpclient-resttemplate-migration/ -│ ├── httpclient-spring7-service-groups/ -│ ├── httpclient-jetty-http3-experimental/ -│ ├── httpclient-spring-boot-starter/ -│ └── httpclient-testkit/ -├── infra/httpclient/ -│ ├── proxy/ -│ ├── tls/ -│ ├── oauth2/ -│ └── toxiproxy/ -├── docs/httpclient/ -│ ├── support-matrix.md -│ ├── configuration-reference.md -│ ├── retry-and-ambiguity.md -│ ├── security.md -│ ├── streaming.md -│ ├── operations.md -│ └── migration-guide.md -└── docs/superpowers/specs/2026-08-08-httpclient-platform-design.md -``` - -## 2. 핵심 패키지 - -```text -io.backend.skeleton.httpclient.api -io.backend.skeleton.httpclient.api.body -io.backend.skeleton.httpclient.api.error -io.backend.skeleton.httpclient.api.operation -io.backend.skeleton.httpclient.api.result -io.backend.skeleton.httpclient.profile -io.backend.skeleton.httpclient.transport -io.backend.skeleton.httpclient.apache -io.backend.skeleton.httpclient.jdk -io.backend.skeleton.httpclient.restclient -io.backend.skeleton.httpclient.resilience -io.backend.skeleton.httpclient.auth -io.backend.skeleton.httpclient.security -io.backend.skeleton.httpclient.observation -io.backend.skeleton.httpclient.reactor -io.backend.skeleton.httpclient.webclient -io.backend.skeleton.httpclient.service -io.backend.skeleton.httpclient.dynamic -io.backend.skeleton.httpclient.migration -io.backend.skeleton.httpclient.spring7 -io.backend.skeleton.httpclient.http3 -io.backend.skeleton.httpclient.autoconfigure -io.backend.skeleton.httpclient.testkit -``` - ---- - -### Task 1: Gradle 멀티모듈과 공통 품질 규칙 구성 - -**Files:** -- Modify: `settings.gradle.kts` -- Create: `build-logic/src/main/kotlin/httpclient-library-conventions.gradle.kts` -- Create: `modules/httpclient/httpclient-core-api/build.gradle.kts` -- Create: `modules/httpclient/httpclient-profile/build.gradle.kts` -- Create: `modules/httpclient/httpclient-transport-spi/build.gradle.kts` -- Create: `modules/httpclient/httpclient-transport-apache/build.gradle.kts` -- Create: `modules/httpclient/httpclient-transport-jdk/build.gradle.kts` -- Create: `modules/httpclient/httpclient-restclient/build.gradle.kts` -- Create: `modules/httpclient/httpclient-resilience/build.gradle.kts` -- Create: `modules/httpclient/httpclient-auth/build.gradle.kts` -- Create: `modules/httpclient/httpclient-security/build.gradle.kts` -- Create: `modules/httpclient/httpclient-observability/build.gradle.kts` -- Create: `modules/httpclient/httpclient-transport-reactor-netty/build.gradle.kts` -- Create: `modules/httpclient/httpclient-webclient/build.gradle.kts` -- Create: `modules/httpclient/httpclient-service-client/build.gradle.kts` -- Create: `modules/httpclient/httpclient-dynamic-target/build.gradle.kts` -- Create: `modules/httpclient/httpclient-resttemplate-migration/build.gradle.kts` -- Create: `modules/httpclient/httpclient-spring7-service-groups/build.gradle.kts` -- Create: `modules/httpclient/httpclient-jetty-http3-experimental/build.gradle.kts` -- Create: `modules/httpclient/httpclient-spring-boot-starter/build.gradle.kts` -- Create: `modules/httpclient/httpclient-testkit/build.gradle.kts` -- Test: `modules/httpclient/httpclient-core-api/src/test/java/io/backend/skeleton/httpclient/api/ModuleSmokeTest.java` - -**Interfaces:** -- Produces every Gradle project path used by later tasks. -- `httpclient-core-api` has no Spring, Apache, Netty, Resilience4j dependency. -- Java toolchain is 21. - -- [ ] **Step 1: Write the failing core module smoke test** - -```java -package io.backend.skeleton.httpclient.api; - -import org.junit.jupiter.api.Test; - -import static org.assertj.core.api.Assertions.assertThat; - -class ModuleSmokeTest { - @Test - void coreApiModuleLoads() { - assertThat(ModuleSmokeTest.class.getPackageName()) - .isEqualTo("io.backend.skeleton.httpclient.api"); - } -} -``` - -- [ ] **Step 2: Register all module paths and verify the build fails before module build files exist** - -Add to `settings.gradle.kts`: - -```kotlin -include( - ":modules:httpclient:httpclient-core-api", - ":modules:httpclient:httpclient-profile", - ":modules:httpclient:httpclient-transport-spi", - ":modules:httpclient:httpclient-transport-apache", - ":modules:httpclient:httpclient-transport-jdk", - ":modules:httpclient:httpclient-restclient", - ":modules:httpclient:httpclient-resilience", - ":modules:httpclient:httpclient-auth", - ":modules:httpclient:httpclient-security", - ":modules:httpclient:httpclient-observability", - ":modules:httpclient:httpclient-transport-reactor-netty", - ":modules:httpclient:httpclient-webclient", - ":modules:httpclient:httpclient-service-client", - ":modules:httpclient:httpclient-dynamic-target", - ":modules:httpclient:httpclient-resttemplate-migration", - ":modules:httpclient:httpclient-spring7-service-groups", - ":modules:httpclient:httpclient-jetty-http3-experimental", - ":modules:httpclient:httpclient-spring-boot-starter", - ":modules:httpclient:httpclient-testkit" -) -``` - -Run: - -```bash -./gradlew :modules:httpclient:httpclient-core-api:test -``` - -Expected: FAIL because the registered module build files are absent. - -- [ ] **Step 3: Add the convention plugin and directed module dependencies** - -Create `httpclient-library-conventions.gradle.kts`: - -```kotlin -plugins { - `java-library` - id("java-test-fixtures") -} - -java { - toolchain { - languageVersion.set(JavaLanguageVersion.of(21)) - } -} - -tasks.withType().configureEach { - useJUnitPlatform() - failFast = false -} - -dependencies { - "testImplementation"(platform("org.junit:junit-bom:5.12.2")) - "testImplementation"("org.junit.jupiter:junit-jupiter") - "testImplementation"("org.assertj:assertj-core:3.27.3") -} -``` - -Apply the convention plugin to every module. Add only the dependencies listed in the design module table; in particular, `core-api` depends on no runtime framework and `testkit` is never an `implementation` dependency of production modules. - -- [ ] **Step 4: Run the core test and dependency report** - -```bash -./gradlew :modules:httpclient:httpclient-core-api:test \ - :modules:httpclient:httpclient-core-api:dependencies -``` - -Expected: PASS; the dependency report contains no Spring Web, Apache HC5, Netty, Reactor, Resilience4j, or Spring Security artifact. - -- [ ] **Step 5: Commit** - -```bash -git add settings.gradle.kts build-logic modules/httpclient -git commit -m "build: add http client module boundaries" -``` - ---- - -### Task 2: 핵심 식별자와 HTTP 의미론 타입 구현 - -**Files:** -- Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/ClientProfileName.java` -- Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/OperationName.java` -- Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/IdempotencyKey.java` -- Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/HttpMethod.java` -- Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/HttpStatus.java` -- Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/operation/OperationIdempotency.java` -- Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/operation/ExecutionEvidence.java` -- Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/operation/BodyReplayability.java` -- Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/operation/AttemptStage.java` -- Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/operation/FailureCategory.java` -- Test: `modules/httpclient/httpclient-core-api/src/test/java/io/backend/skeleton/httpclient/api/CoreValueTypeTest.java` - -**Interfaces:** -- Produces exact enum and record names consumed by every later module. -- `HttpMethod` excludes TRACE and provides `safe()` and `standardIdempotent()`. - -- [ ] **Step 1: Write failing validation and method semantic tests** - -```java -class CoreValueTypeTest { - @Test - void validatesStableNames() { - assertThat(new ClientProfileName("payment-api").value()) - .isEqualTo("payment-api"); - assertThatThrownBy(() -> new OperationName("Create Payment")) - .isInstanceOf(IllegalArgumentException.class); - } - - @Test - void exposesHttpMethodSemanticsWithoutTrace() { - assertThat(HttpMethod.GET.safe()).isTrue(); - assertThat(HttpMethod.PUT.standardIdempotent()).isTrue(); - assertThat(HttpMethod.POST.standardIdempotent()).isFalse(); - assertThat(Arrays.stream(HttpMethod.values()).map(Enum::name)) - .doesNotContain("TRACE"); - } -} -``` - -- [ ] **Step 2: Run the test to verify missing types fail compilation** - -```bash -./gradlew :modules:httpclient:httpclient-core-api:test \ - --tests '*CoreValueTypeTest' -``` - -Expected: FAIL with unresolved `ClientProfileName`, `OperationName`, and `HttpMethod` symbols. - -- [ ] **Step 3: Implement the records and enums** - -```java -public record ClientProfileName(String value) { - public ClientProfileName { - if (value == null || !value.matches("[a-z][a-z0-9-]{1,62}")) { - throw new IllegalArgumentException("invalid client profile name"); - } - } -} - -public enum HttpMethod { - GET(true, true), HEAD(true, true), POST(false, false), - PUT(false, true), PATCH(false, false), DELETE(false, true), - OPTIONS(true, true); - - private final boolean safe; - private final boolean standardIdempotent; - - HttpMethod(boolean safe, boolean standardIdempotent) { - this.safe = safe; - this.standardIdempotent = standardIdempotent; - } - - public boolean safe() { return safe; } - public boolean standardIdempotent() { return standardIdempotent; } -} -``` - -Implement the remaining records with non-null validation and the exact enum constants from the design. - -- [ ] **Step 4: Run the core test** - -```bash -./gradlew :modules:httpclient:httpclient-core-api:test \ - --tests '*CoreValueTypeTest' -``` - -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add modules/httpclient/httpclient-core-api -git commit -m "feat: define http client core semantics" -``` - ---- - -### Task 3: Request Body와 Response 타입 계약 구현 - -**Files:** -- Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/body/BodySource.java` -- Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/body/EmptyBody.java` -- Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/body/ObjectBody.java` -- Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/body/ByteArrayBody.java` -- Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/body/ReopenableStreamBody.java` -- Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/body/OneShotStreamBody.java` -- Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/body/IOSupplier.java` -- Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/result/ResponseType.java` -- Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/result/ClassResponseType.java` -- Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/result/GenericResponseType.java` -- Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/result/EmptyResponseType.java` -- Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/result/BlockingStreamingResponse.java` -- Test: `modules/httpclient/httpclient-core-api/src/test/java/io/backend/skeleton/httpclient/api/body/BodyReplayabilityTest.java` - -**Interfaces:** -- Produces `BodySource.replayability()` and `knownLength()`. -- Retry tasks consume these exact methods. -- Blocking streaming response is `AutoCloseable`. - -- [ ] **Step 1: Write failing replayability and lifecycle tests** - -```java -class BodyReplayabilityTest { - @Test - void classifiesBodySources() { - assertThat(new ByteArrayBody(new byte[] {1, 2}, "application/octet-stream") - .replayability()).isEqualTo(BodyReplayability.REPLAYABLE); - - ReopenableStreamBody body = new ReopenableStreamBody( - () -> new ByteArrayInputStream(new byte[] {1}), - OptionalLong.of(1), - "application/octet-stream"); - assertThat(body.replayability()).isEqualTo(BodyReplayability.REOPENABLE); - } - - @Test - void oneShotBodyRejectsNullStream() { - assertThatThrownBy(() -> new OneShotStreamBody( - null, OptionalLong.empty(), "application/octet-stream")) - .isInstanceOf(NullPointerException.class); - } -} -``` - -- [ ] **Step 2: Run the failing test** - -```bash -./gradlew :modules:httpclient:httpclient-core-api:test \ - --tests '*BodyReplayabilityTest' -``` - -Expected: FAIL because body and response contracts do not exist. - -- [ ] **Step 3: Implement the sealed body and response contracts** - -```java -public sealed interface BodySource permits EmptyBody, ObjectBody, - ByteArrayBody, ReopenableStreamBody, OneShotStreamBody { - BodyReplayability replayability(); - OptionalLong knownLength(); - String mediaType(); -} - -public record ReopenableStreamBody( - IOSupplier opener, - OptionalLong knownLength, - String mediaType) implements BodySource { - public ReopenableStreamBody { - Objects.requireNonNull(opener); - Objects.requireNonNull(knownLength); - Objects.requireNonNull(mediaType); - } - @Override public BodyReplayability replayability() { - return BodyReplayability.REOPENABLE; - } -} -``` - -Implement `ByteArrayBody` with a defensive copy and `BlockingStreamingResponse` with `status()`, `headers()`, `body()`, and `close()`. - -- [ ] **Step 4: Run the core body tests** - -```bash -./gradlew :modules:httpclient:httpclient-core-api:test \ - --tests '*BodyReplayabilityTest' -``` - -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add modules/httpclient/httpclient-core-api -git commit -m "feat: add replayable body and response contracts" -``` - ---- - -### Task 4: HttpOperation과 HttpCallResult 구현 - -**Files:** -- Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/operation/HttpOperation.java` -- Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/result/HttpCallResult.java` -- Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/result/RemoteProblem.java` -- Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/result/IdempotencyKeyRequirement.java` -- Test: `modules/httpclient/httpclient-core-api/src/test/java/io/backend/skeleton/httpclient/api/operation/HttpOperationTest.java` - -**Interfaces:** -- Produces the immutable operation model consumed by H2/H3 and retry. -- `IDEMPOTENCY_KEY_REQUIRED` cannot be built without a key. - -- [ ] **Step 1: Write failing operation invariant tests** - -```java -class HttpOperationTest { - @Test - void requiresIdempotencyKeyWhenPolicyRequiresIt() { - assertThatThrownBy(() -> new HttpOperation( - new OperationName("create-payment"), - HttpMethod.POST, - "/payments", - Map.of(), - Map.of(), - new EmptyBody(), - OperationIdempotency.IDEMPOTENCY_KEY_REQUIRED, - Optional.empty(), - Optional.empty())) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("idempotency key"); - } - - @Test - void storesUriTemplateRatherThanExpandedUrl() { - HttpOperation operation = HttpOperation.get( - new OperationName("get-user"), "/users/{id}", Map.of("id", "42")); - assertThat(operation.uriTemplate()).isEqualTo("/users/{id}"); - } -} -``` - -- [ ] **Step 2: Run the test and confirm failure** - -```bash -./gradlew :modules:httpclient:httpclient-core-api:test \ - --tests '*HttpOperationTest' -``` - -Expected: FAIL because `HttpOperation` and `HttpCallResult` are missing. - -- [ ] **Step 3: Implement immutable invariants** - -```java -public record HttpOperation( - OperationName operationName, - HttpMethod method, - String uriTemplate, - Map uriVariables, - Map> headers, - BodySource body, - OperationIdempotency idempotency, - Optional idempotencyKey, - Optional deadline) { - - public HttpOperation { - Objects.requireNonNull(operationName); - Objects.requireNonNull(method); - Objects.requireNonNull(uriTemplate); - Objects.requireNonNull(body); - if (idempotency == OperationIdempotency.IDEMPOTENCY_KEY_REQUIRED - && idempotencyKey.isEmpty()) { - throw new IllegalArgumentException("idempotency key is required"); - } - uriVariables = Map.copyOf(uriVariables); - headers = headers.entrySet().stream().collect(Collectors.toUnmodifiableMap( - Map.Entry::getKey, entry -> List.copyOf(entry.getValue()))); - } -} -``` - -Implement `HttpCallResult` with immutable headers and `attempts >= 1` validation. - -- [ ] **Step 4: Run core operation tests** - -```bash -./gradlew :modules:httpclient:httpclient-core-api:test \ - --tests '*HttpOperationTest' -``` - -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add modules/httpclient/httpclient-core-api -git commit -m "feat: add immutable http operation result model" -``` - ---- - -### Task 5: 안정 예외 계층과 실패 Metadata 구현 - -**Files:** -- Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/error/HttpFailureMetadata.java` -- Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/error/HttpClientException.java` -- Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/error/HttpConfigurationException.java` -- Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/error/HttpTargetRejectedException.java` -- Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/error/HttpDnsException.java` -- Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/error/HttpPoolAcquireTimeoutException.java` -- Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/error/HttpConnectException.java` -- Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/error/HttpProxyException.java` -- Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/error/HttpTlsException.java` -- Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/error/HttpRequestWriteException.java` -- Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/error/HttpResponseTimeoutException.java` -- Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/error/HttpResponseTruncatedException.java` -- Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/error/HttpRemoteErrorException.java` -- Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/error/HttpProblemDetailException.java` -- Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/error/HttpRedirectRejectedException.java` -- Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/error/HttpAuthenticationException.java` -- Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/error/HttpSerializationException.java` -- Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/error/HttpResponseTooLargeException.java` -- Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/error/HttpDeadlineExceededException.java` -- Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/error/HttpCircuitOpenException.java` -- Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/error/HttpBulkheadRejectedException.java` -- Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/error/HttpRateLimitRejectedException.java` -- Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/error/HttpAmbiguousExecutionException.java` -- Test: `modules/httpclient/httpclient-core-api/src/test/java/io/backend/skeleton/httpclient/api/error/StableExceptionTest.java` - -**Interfaces:** -- Every public failure extends `HttpClientException` and exposes `metadata()`. -- No exception message contains full URL, body, token, or idempotency key. - -- [ ] **Step 1: Write failing stable metadata and redaction tests** - -```java -class StableExceptionTest { - @Test - void ambiguousFailurePreservesEvidenceWithoutSecrets() { - HttpFailureMetadata metadata = Fixtures.ambiguousMetadata(); - HttpAmbiguousExecutionException exception = - new HttpAmbiguousExecutionException("remote outcome is unknown", metadata); - - assertThat(exception.metadata().evidence()) - .isEqualTo(ExecutionEvidence.SENT_NO_RESPONSE); - assertThat(exception.getMessage()) - .doesNotContain("Authorization", "secret", "https://payment.example.com/42"); - } -} -``` - -- [ ] **Step 2: Run the failing test** - -```bash -./gradlew :modules:httpclient:httpclient-core-api:test \ - --tests '*StableExceptionTest' -``` - -Expected: FAIL because the stable exception hierarchy does not exist. - -- [ ] **Step 3: Implement the root and typed subclasses** - -```java -public abstract class HttpClientException extends RuntimeException { - private final HttpFailureMetadata metadata; - - protected HttpClientException(String safeMessage, HttpFailureMetadata metadata, - Throwable cause) { - super(safeMessage, cause); - this.metadata = Objects.requireNonNull(metadata); - } - - public final HttpFailureMetadata metadata() { - return metadata; - } -} -``` - -Each concrete subclass has constructors `(String safeMessage, HttpFailureMetadata metadata)` and `(String safeMessage, HttpFailureMetadata metadata, Throwable cause)`. Do not include raw URI or body in any constructor formatting. - -- [ ] **Step 4: Run exception tests** - -```bash -./gradlew :modules:httpclient:httpclient-core-api:test \ - --tests '*StableExceptionTest' -``` - -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add modules/httpclient/httpclient-core-api -git commit -m "feat: add stable http client failures" -``` - ---- - -### Task 6: Named Client Profile 모델과 startup validation 구현 - -**Files:** -- Create: `modules/httpclient/httpclient-profile/src/main/java/io/backend/skeleton/httpclient/profile/ClientMode.java` -- Create: `modules/httpclient/httpclient-profile/src/main/java/io/backend/skeleton/httpclient/profile/TransportType.java` -- Create: `modules/httpclient/httpclient-profile/src/main/java/io/backend/skeleton/httpclient/profile/HttpProtocol.java` -- Create: `modules/httpclient/httpclient-profile/src/main/java/io/backend/skeleton/httpclient/profile/ClientApiType.java` -- Create: `modules/httpclient/httpclient-profile/src/main/java/io/backend/skeleton/httpclient/profile/PoolSettings.java` -- Create: `modules/httpclient/httpclient-profile/src/main/java/io/backend/skeleton/httpclient/profile/TimeoutSettings.java` -- Create: `modules/httpclient/httpclient-profile/src/main/java/io/backend/skeleton/httpclient/profile/RedirectSettings.java` -- Create: `modules/httpclient/httpclient-profile/src/main/java/io/backend/skeleton/httpclient/profile/RequestLimits.java` -- Create: `modules/httpclient/httpclient-profile/src/main/java/io/backend/skeleton/httpclient/profile/ResponseLimits.java` -- Create: `modules/httpclient/httpclient-profile/src/main/java/io/backend/skeleton/httpclient/profile/AuthenticationSettings.java` -- Create: `modules/httpclient/httpclient-profile/src/main/java/io/backend/skeleton/httpclient/profile/RetrySettings.java` -- Create: `modules/httpclient/httpclient-profile/src/main/java/io/backend/skeleton/httpclient/profile/ClientObservabilitySettings.java` -- Create: `modules/httpclient/httpclient-profile/src/main/java/io/backend/skeleton/httpclient/profile/ClientProfile.java` -- Create: `modules/httpclient/httpclient-profile/src/main/java/io/backend/skeleton/httpclient/profile/ClientProfileValidator.java` -- Create: `modules/httpclient/httpclient-profile/src/main/java/io/backend/skeleton/httpclient/profile/ClientProfileViolation.java` -- Test: `modules/httpclient/httpclient-profile/src/test/java/io/backend/skeleton/httpclient/profile/ClientProfileValidatorTest.java` - -**Interfaces:** -- Produces immutable `ClientProfile` and `ClientProfileValidator.validate(profile, environment)`. -- Later auto-configuration and transport tasks consume this exact profile model. - -- [ ] **Step 1: Write failing unsafe configuration tests** - -```java -class ClientProfileValidatorTest { - private final ClientProfileValidator validator = new ClientProfileValidator(); - - @Test - void rejectsPlainHttpInProduction() { - ClientProfile profile = ClientProfiles.trusted("payment", URI.create("http://payment.test")); - assertThat(validator.validate(profile, RuntimeEnvironment.PRODUCTION)) - .extracting(ClientProfileViolation::code) - .contains("PLAINTEXT_PRODUCTION_TARGET"); - } - - @Test - void rejectsDynamicCredentialInheritance() { - ClientProfile profile = ClientProfiles.dynamicWithOAuth("webhook-checker"); - assertThat(validator.validate(profile, RuntimeEnvironment.PRODUCTION)) - .extracting(ClientProfileViolation::code) - .contains("DYNAMIC_DEFAULT_CREDENTIAL_FORBIDDEN"); - } - - @Test - void rejectsTotalTimeoutShorterThanConnectBudget() { - ClientProfile profile = ClientProfiles.withTimeouts( - Duration.ofSeconds(2), Duration.ofMillis(500)); - assertThat(validator.validate(profile, RuntimeEnvironment.PRODUCTION)) - .extracting(ClientProfileViolation::code) - .contains("INVALID_TIMEOUT_BUDGET"); - } -} -``` - -- [ ] **Step 2: Run the tests and confirm failure** - -```bash -./gradlew :modules:httpclient:httpclient-profile:test \ - --tests '*ClientProfileValidatorTest' -``` - -Expected: FAIL because the profile records and validator are missing. - -- [ ] **Step 3: Implement immutable settings and deterministic validation** - -```java -public record ClientProfile( - ClientProfileName name, - ClientMode mode, - URI baseUrl, - Set allowedHosts, - Set allowedPorts, - ClientApiType api, - TransportType transport, - Set protocols, - PoolSettings pool, - TimeoutSettings timeout, - RedirectSettings redirect, - RequestLimits request, - ResponseLimits response, - AuthenticationSettings authentication, - RetrySettings retry, - ClientObservabilitySettings observability) { -} -``` - -`ClientProfileValidator` must emit stable violation codes for every startup guard in the design: base URL, userinfo, allowed host/port, production plaintext, Dynamic credential, HTTP/3 Stable, Simple factory, timeout relationships, hard size maximum, redirect policy, and unsafe POST retry. - -- [ ] **Step 4: Run the profile tests** - -```bash -./gradlew :modules:httpclient:httpclient-profile:test -``` - -Expected: PASS; violation order is deterministic and sorted by code. - -- [ ] **Step 5: Commit** - -```bash -git add modules/httpclient/httpclient-profile -git commit -m "feat: add named http client profiles" -``` - ---- - -### Task 7: Immutable ClientRuntime Registry와 generation 교체 구현 - -**Files:** -- Create: `modules/httpclient/httpclient-profile/src/main/java/io/backend/skeleton/httpclient/profile/ClientRuntime.java` -- Create: `modules/httpclient/httpclient-profile/src/main/java/io/backend/skeleton/httpclient/profile/ClientRuntimeState.java` -- Create: `modules/httpclient/httpclient-profile/src/main/java/io/backend/skeleton/httpclient/profile/ClientRuntimeFactory.java` -- Create: `modules/httpclient/httpclient-profile/src/main/java/io/backend/skeleton/httpclient/profile/ClientRuntimeRegistry.java` -- Create: `modules/httpclient/httpclient-profile/src/main/java/io/backend/skeleton/httpclient/profile/ClientRuntimeLease.java` -- Create: `modules/httpclient/httpclient-profile/src/main/java/io/backend/skeleton/httpclient/profile/RuntimeGeneration.java` -- Test: `modules/httpclient/httpclient-profile/src/test/java/io/backend/skeleton/httpclient/profile/ClientRuntimeRegistryTest.java` - -**Interfaces:** -- Produces `ClientRuntimeRegistry.acquire(ClientProfileName)` returning `ClientRuntimeLease`. -- Produces `swap(profileName, newRuntime, drainTimeout)` for secret, certificate, pool, or endpoint rotation. - -- [ ] **Step 1: Write failing atomic swap and drain tests** - -```java -class ClientRuntimeRegistryTest { - @Test - void newCallsUseNewGenerationWhileOldCallDrains() { - ClientRuntime first = FakeRuntime.running(1); - ClientRuntime second = FakeRuntime.running(2); - ClientRuntimeRegistry registry = new ClientRuntimeRegistry(Map.of(first.name(), first)); - - ClientRuntimeLease oldLease = registry.acquire(first.name()); - registry.swap(first.name(), second, Duration.ofSeconds(1)); - - try (ClientRuntimeLease newLease = registry.acquire(first.name())) { - assertThat(newLease.runtime().generation().value()).isEqualTo(2); - } - assertThat(first.state()).isEqualTo(ClientRuntimeState.DRAINING); - oldLease.close(); - assertThat(first.state()).isEqualTo(ClientRuntimeState.CLOSED); - } -} -``` - -- [ ] **Step 2: Run the test and verify failure** - -```bash -./gradlew :modules:httpclient:httpclient-profile:test \ - --tests '*ClientRuntimeRegistryTest' -``` - -Expected: FAIL because runtime lifecycle types are absent. - -- [ ] **Step 3: Implement reference-counted runtime generations** - -```java -public final class ClientRuntimeRegistry { - private final ConcurrentMap> runtimes; - - public ClientRuntimeLease acquire(ClientProfileName name) { - ClientRuntime runtime = requireRuntime(name); - if (!runtime.tryAcquire()) { - return acquire(name); - } - return new ClientRuntimeLease(runtime, runtime::release); - } - - public void swap(ClientProfileName name, ClientRuntime replacement, - Duration drainTimeout) { - ClientRuntime previous = runtimes.get(name).getAndSet(replacement); - previous.beginDrain(drainTimeout); - } -} -``` - -`ClientRuntime` closes immediately after the last lease when draining, and forcibly closes at drain timeout. It rejects new retry attempts after state becomes `DRAINING`. - -- [ ] **Step 4: Run runtime lifecycle tests** - -```bash -./gradlew :modules:httpclient:httpclient-profile:test \ - --tests '*ClientRuntimeRegistryTest' -``` - -Expected: PASS with no leaked scheduled executor thread. - -- [ ] **Step 5: Commit** - -```bash -git add modules/httpclient/httpclient-profile -git commit -m "feat: add immutable client runtime generations" -``` - ---- - -### Task 8: Blocking·Reactive Transport SPI와 capability validation 구현 - -**Files:** -- Create: `modules/httpclient/httpclient-transport-spi/src/main/java/io/backend/skeleton/httpclient/transport/TransportId.java` -- Create: `modules/httpclient/httpclient-transport-spi/src/main/java/io/backend/skeleton/httpclient/transport/BlockingTransportProvider.java` -- Create: `modules/httpclient/httpclient-transport-spi/src/main/java/io/backend/skeleton/httpclient/transport/ReactiveTransportProvider.java` -- Create: `modules/httpclient/httpclient-transport-spi/src/main/java/io/backend/skeleton/httpclient/transport/BlockingTransportCapabilities.java` -- Create: `modules/httpclient/httpclient-transport-spi/src/main/java/io/backend/skeleton/httpclient/transport/ReactiveTransportCapabilities.java` -- Create: `modules/httpclient/httpclient-transport-spi/src/main/java/io/backend/skeleton/httpclient/transport/TransportFailureClassifier.java` -- Create: `modules/httpclient/httpclient-transport-spi/src/main/java/io/backend/skeleton/httpclient/transport/TransportLifecycleListener.java` -- Create: `modules/httpclient/httpclient-transport-spi/src/main/java/io/backend/skeleton/httpclient/transport/TransportCapabilityValidator.java` -- Create: `modules/httpclient/httpclient-transport-spi/src/main/java/io/backend/skeleton/httpclient/transport/TransportFailure.java` -- Test: `modules/httpclient/httpclient-transport-spi/src/test/java/io/backend/skeleton/httpclient/transport/TransportCapabilityValidatorTest.java` - -**Interfaces:** -- Blocking provider produces Spring `ClientHttpRequestFactory`. -- Reactive provider produces Spring `ClientHttpConnector`. -- Public application modules never receive native engine clients. - -- [ ] **Step 1: Write failing capability mismatch tests** - -```java -class TransportCapabilityValidatorTest { - @Test - void rejectsHttp3OnNonHttp3Provider() { - ClientProfile profile = ClientProfiles.http3Experimental("edge"); - BlockingTransportCapabilities capabilities = - BlockingTransportCapabilities.http11AndHttp2(); - - assertThatThrownBy(() -> new TransportCapabilityValidator() - .validate(profile, capabilities)) - .isInstanceOf(HttpConfigurationException.class) - .hasMessageContaining("HTTP_3"); - } -} -``` - -- [ ] **Step 2: Run the SPI tests and verify failure** - -```bash -./gradlew :modules:httpclient:httpclient-transport-spi:test \ - --tests '*TransportCapabilityValidatorTest' -``` - -Expected: FAIL because provider and capability contracts are missing. - -- [ ] **Step 3: Implement the provider contracts** - -```java -public interface BlockingTransportProvider { - TransportId id(); - BlockingTransportCapabilities capabilities(); - ClientHttpRequestFactory create( - ClientProfile profile, - TransportLifecycleListener listener); - TransportFailureClassifier failureClassifier(); -} - -public interface TransportFailureClassifier { - TransportFailure classify(Throwable failure, AttemptStage lastObservedStage); -} -``` - -`TransportCapabilityValidator` checks protocol, proxy, mTLS, route pool, pending queue, DNS pinning, and dynamic target capability. Error messages use profile and capability names only. - -- [ ] **Step 4: Run the SPI tests** - -```bash -./gradlew :modules:httpclient:httpclient-transport-spi:test -``` - -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add modules/httpclient/httpclient-transport-spi -git commit -m "feat: define http transport provider spi" -``` - ---- - -### Task 9: HTTP Client Testkit 기반 구성 - -**Files:** -- Create: `modules/httpclient/httpclient-testkit/src/main/java/io/backend/skeleton/httpclient/testkit/MockHttpServer.java` -- Create: `modules/httpclient/httpclient-testkit/src/main/java/io/backend/skeleton/httpclient/testkit/RecordedHttpRequest.java` -- Create: `modules/httpclient/httpclient-testkit/src/main/java/io/backend/skeleton/httpclient/testkit/HttpClientContract.java` -- Create: `modules/httpclient/httpclient-testkit/src/main/java/io/backend/skeleton/httpclient/testkit/TlsFixture.java` -- Create: `modules/httpclient/httpclient-testkit/src/main/java/io/backend/skeleton/httpclient/testkit/ProxyFixture.java` -- Create: `modules/httpclient/httpclient-testkit/src/main/java/io/backend/skeleton/httpclient/testkit/OAuth2Fixture.java` -- Create: `modules/httpclient/httpclient-testkit/src/main/java/io/backend/skeleton/httpclient/testkit/ToxiproxyFixture.java` -- Create: `modules/httpclient/httpclient-testkit/src/test/java/io/backend/skeleton/httpclient/testkit/MockHttpServerTest.java` -- Create: `infra/httpclient/toxiproxy/compose.yaml` - -**Interfaces:** -- Produces deterministic HTTP/1.1 fixtures used from Task 13 onward. -- Later tasks extend the testkit with HTTP/2, TLS, OAuth2, proxy, and network failure behavior. - -- [ ] **Step 1: Write a failing server recording test** - -```java -class MockHttpServerTest { - @Test - void recordsMethodPathHeadersAndBody() throws Exception { - try (MockHttpServer server = MockHttpServer.start()) { - server.enqueueJson(200, "{\"ok\":true}"); - HttpURLConnection connection = (HttpURLConnection) - server.uri("/items/42").toURL().openConnection(); - connection.setRequestMethod("POST"); - connection.setDoOutput(true); - connection.setRequestProperty("X-Test", "value"); - connection.getOutputStream().write("body".getBytes(UTF_8)); - assertThat(connection.getResponseCode()).isEqualTo(200); - - RecordedHttpRequest request = server.takeRequest(Duration.ofSeconds(1)); - assertThat(request.method()).isEqualTo("POST"); - assertThat(request.path()).isEqualTo("/items/42"); - assertThat(request.firstHeader("X-Test")).contains("value"); - assertThat(request.bodyUtf8()).isEqualTo("body"); - } - } -} -``` - -- [ ] **Step 2: Run the test and confirm failure** - -```bash -./gradlew :modules:httpclient:httpclient-testkit:test \ - --tests '*MockHttpServerTest' -``` - -Expected: FAIL because the fixture classes are missing. - -- [ ] **Step 3: Implement MockWebServer-backed fixtures** - -```java -public final class MockHttpServer implements AutoCloseable { - private final MockWebServer server; - - public static MockHttpServer start() throws IOException { - MockWebServer delegate = new MockWebServer(); - delegate.start(); - return new MockHttpServer(delegate); - } - - public void enqueueJson(int status, String body) { - server.enqueue(new MockResponse() - .setResponseCode(status) - .setHeader("Content-Type", "application/json") - .setBody(body)); - } -} -``` - -Implement `takeRequest` with a finite timeout and immutable header/body copies. Add Testcontainers and Toxiproxy dependencies only to `httpclient-testkit`. - -- [ ] **Step 4: Run the testkit suite** - -```bash -./gradlew :modules:httpclient:httpclient-testkit:test -``` - -Expected: PASS and no listening socket remains after the test. - -- [ ] **Step 5: Commit** - -```bash -git add modules/httpclient/httpclient-testkit infra/httpclient/toxiproxy -git commit -m "test: add http client contract fixtures" -``` - ---- - -### Task 10: Effective Deadline과 단계별 시간 예산 구현 - -**Files:** -- Create: `modules/httpclient/httpclient-resilience/src/main/java/io/backend/skeleton/httpclient/resilience/Deadline.java` -- Create: `modules/httpclient/httpclient-resilience/src/main/java/io/backend/skeleton/httpclient/resilience/DeadlineCalculator.java` -- Create: `modules/httpclient/httpclient-resilience/src/main/java/io/backend/skeleton/httpclient/resilience/AttemptBudget.java` -- Create: `modules/httpclient/httpclient-resilience/src/main/java/io/backend/skeleton/httpclient/resilience/AttemptBudgetCalculator.java` -- Create: `modules/httpclient/httpclient-resilience/src/main/java/io/backend/skeleton/httpclient/resilience/DeadlineGuard.java` -- Test: `modules/httpclient/httpclient-resilience/src/test/java/io/backend/skeleton/httpclient/resilience/DeadlineCalculatorTest.java` - -**Interfaces:** -- Produces `DeadlineCalculator.effective(parent, totalCall, clock)`. -- Produces `AttemptBudgetCalculator.nextAttempt(deadline, backoff, minimumAttempt, cleanupReserve)`. - -- [ ] **Step 1: Write failing parent deadline and backoff tests** - -```java -class DeadlineCalculatorTest { - private final Clock clock = Clock.fixed(Instant.parse("2026-08-08T00:00:00Z"), UTC); - - @Test - void usesShorterParentDeadline() { - Deadline deadline = new DeadlineCalculator().effective( - Optional.of(Instant.parse("2026-08-08T00:00:02Z")), - Duration.ofSeconds(5), clock); - assertThat(deadline.at()).isEqualTo(Instant.parse("2026-08-08T00:00:02Z")); - } - - @Test - void refusesAttemptWhenBackoffConsumesRemainingBudget() { - Deadline deadline = new Deadline(Instant.parse("2026-08-08T00:00:01Z")); - Optional result = new AttemptBudgetCalculator(clock) - .nextAttempt(deadline, Duration.ofMillis(700), - Duration.ofMillis(250), Duration.ofMillis(100)); - assertThat(result).isEmpty(); - } -} -``` - -- [ ] **Step 2: Run the test and confirm failure** - -```bash -./gradlew :modules:httpclient:httpclient-resilience:test \ - --tests '*DeadlineCalculatorTest' -``` - -Expected: FAIL because deadline types are absent. - -- [ ] **Step 3: Implement monotonic budget calculations** - -```java -public final class DeadlineCalculator { - public Deadline effective(Optional parent, Duration totalCall, Clock clock) { - Instant local = clock.instant().plus(totalCall); - return new Deadline(parent.map(p -> p.isBefore(local) ? p : local).orElse(local)); - } -} -``` - -`AttemptBudgetCalculator` subtracts backoff, minimum attempt duration, and cleanup reserve. It never returns a negative duration and `DeadlineGuard` throws `HttpDeadlineExceededException` before a new attempt starts. - -- [ ] **Step 4: Run deadline tests** - -```bash -./gradlew :modules:httpclient:httpclient-resilience:test \ - --tests '*DeadlineCalculatorTest' -``` - -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add modules/httpclient/httpclient-resilience -git commit -m "feat: enforce end to end http deadlines" -``` - ---- - -### Task 11: Trusted URI, Header ownership, Body limit 정책 구현 - -**Files:** -- Create: `modules/httpclient/httpclient-security/src/main/java/io/backend/skeleton/httpclient/security/TrustedTargetPolicy.java` -- Create: `modules/httpclient/httpclient-security/src/main/java/io/backend/skeleton/httpclient/security/UriTemplateExpander.java` -- Create: `modules/httpclient/httpclient-security/src/main/java/io/backend/skeleton/httpclient/security/HeaderPolicy.java` -- Create: `modules/httpclient/httpclient-security/src/main/java/io/backend/skeleton/httpclient/security/BodyLimitPolicy.java` -- Create: `modules/httpclient/httpclient-security/src/main/java/io/backend/skeleton/httpclient/security/RedirectPolicy.java` -- Create: `modules/httpclient/httpclient-security/src/main/java/io/backend/skeleton/httpclient/security/PreparedTarget.java` -- Create: `modules/httpclient/httpclient-security/src/main/java/io/backend/skeleton/httpclient/security/PreparedOperation.java` -- Test: `modules/httpclient/httpclient-security/src/test/java/io/backend/skeleton/httpclient/security/TrustedRequestPolicyTest.java` - -**Interfaces:** -- Produces a `PreparedOperation` with canonical target, sanitized headers, and hard size budgets. -- H2 cannot supply an absolute URI. - -- [ ] **Step 1: Write failing absolute URI, CRLF, and body size tests** - -```java -class TrustedRequestPolicyTest { - @Test - void rejectsAbsoluteUriInTrustedGenericGateway() { - TrustedTargetPolicy policy = Policies.payment(); - assertThatThrownBy(() -> policy.prepare(OperationFixtures.absoluteTarget())) - .isInstanceOf(HttpTargetRejectedException.class); - } - - @Test - void rejectsHeaderInjection() { - HeaderPolicy policy = HeaderPolicy.defaultPolicy(); - assertThatThrownBy(() -> policy.validate(Map.of("X-Test", List.of("ok\r\nBad: x")))) - .isInstanceOf(HttpTargetRejectedException.class); - } - - @Test - void rejectsKnownBodyLargerThanProfileLimit() { - assertThatThrownBy(() -> BodyLimitPolicy.maxRequestBytes(4) - .validate(new ByteArrayBody(new byte[5], "application/octet-stream"))) - .isInstanceOf(HttpConfigurationException.class); - } -} -``` - -- [ ] **Step 2: Run the failing security tests** - -```bash -./gradlew :modules:httpclient:httpclient-security:test \ - --tests '*TrustedRequestPolicyTest' -``` - -Expected: FAIL because the request policy pipeline is missing. - -- [ ] **Step 3: Implement strict preparation rules** - -```java -public final class HeaderPolicy { - private static final Set PLATFORM_OWNED = Set.of( - "authorization", "proxy-authorization", "host", "content-length", - "transfer-encoding", "traceparent", "tracestate", "baggage", "cookie"); - - public Map> validate(Map> input) { - input.forEach((name, values) -> { - if (name.indexOf('\r') >= 0 || name.indexOf('\n') >= 0) reject(name); - values.forEach(value -> { - if (value.indexOf('\r') >= 0 || value.indexOf('\n') >= 0) reject(name); - }); - if (PLATFORM_OWNED.contains(name.toLowerCase(Locale.ROOT))) reject(name); - }); - return immutableCopy(input); - } -} -``` - -`UriTemplateExpander` uses Spring URI components in this integration module, encodes path and query components separately, and records the original template for observability. - -- [ ] **Step 4: Run security policy tests** - -```bash -./gradlew :modules:httpclient:httpclient-security:test -``` - -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add modules/httpclient/httpclient-security -git commit -m "feat: enforce trusted http request policy" -``` - ---- - -### Task 12: Low-cardinality 관측성과 Redaction primitive 구현 - -**Files:** -- Create: `modules/httpclient/httpclient-observability/src/main/java/io/backend/skeleton/httpclient/observation/HttpClientObservationNames.java` -- Create: `modules/httpclient/httpclient-observability/src/main/java/io/backend/skeleton/httpclient/observation/LogicalCallObservation.java` -- Create: `modules/httpclient/httpclient-observability/src/main/java/io/backend/skeleton/httpclient/observation/AttemptObservation.java` -- Create: `modules/httpclient/httpclient-observability/src/main/java/io/backend/skeleton/httpclient/observation/HttpClientTagPolicy.java` -- Create: `modules/httpclient/httpclient-observability/src/main/java/io/backend/skeleton/httpclient/observation/SensitiveValueRedactor.java` -- Create: `modules/httpclient/httpclient-observability/src/main/java/io/backend/skeleton/httpclient/observation/SafeHttpLogEvent.java` -- Test: `modules/httpclient/httpclient-observability/src/test/java/io/backend/skeleton/httpclient/observation/HttpClientTagPolicyTest.java` - -**Interfaces:** -- Produces standard low-cardinality tags consumed by RestClient, WebClient, Retry, Auth, and Dynamic modules. -- Rejects full URL and arbitrary labels rather than silently accepting them. - -- [ ] **Step 1: Write failing forbidden tag and redaction tests** - -```java -class HttpClientTagPolicyTest { - @Test - void rejectsFullUrlAsLowCardinalityTag() { - HttpClientTagPolicy policy = HttpClientTagPolicy.standard(); - assertThatThrownBy(() -> policy.tag("url", "https://api.test/users/42?q=secret")) - .isInstanceOf(IllegalArgumentException.class); - } - - @Test - void redactsCredentialsAndQueryValues() { - SensitiveValueRedactor redactor = SensitiveValueRedactor.standard(); - assertThat(redactor.header("Authorization", "Bearer abc")).isEqualTo("[REDACTED]"); - assertThat(redactor.uri(URI.create("https://api.test/a?q=secret")).toString()) - .isEqualTo("https://api.test/a"); - } -} -``` - -- [ ] **Step 2: Run observability tests and confirm failure** - -```bash -./gradlew :modules:httpclient:httpclient-observability:test \ - --tests '*HttpClientTagPolicyTest' -``` - -Expected: FAIL because tag policy and redactor are missing. - -- [ ] **Step 3: Implement bounded vocabularies and safe events** - -```java -public final class HttpClientTagPolicy { - private static final Set ALLOWED = Set.of( - "clientName", "operationName", "method", "uriTemplate", "status", - "outcome", "transport", "protocol", "timeoutType", "retryReason", - "evidence", "circuitState"); - - public KeyValue tag(String name, String value) { - if (!ALLOWED.contains(name)) { - throw new IllegalArgumentException("forbidden low-cardinality tag: " + name); - } - return KeyValue.of(name, value); - } -} -``` - -`SafeHttpLogEvent` stores profile, operation, template, status, evidence, stage, attempt, elapsed, and trace ID only. It has no fields for body, authorization, Cookie, query, or expanded URL. - -- [ ] **Step 4: Run observability tests** - -```bash -./gradlew :modules:httpclient:httpclient-observability:test -``` - -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add modules/httpclient/httpclient-observability -git commit -m "feat: add safe http client observability" -``` - ---- - -### Task 13: Apache HttpClient 5 Blocking Transport 구현 - -**Files:** -- Create: `modules/httpclient/httpclient-transport-apache/src/main/java/io/backend/skeleton/httpclient/apache/ApacheBlockingTransportProvider.java` -- Create: `modules/httpclient/httpclient-transport-apache/src/main/java/io/backend/skeleton/httpclient/apache/ApacheClientFactory.java` -- Create: `modules/httpclient/httpclient-transport-apache/src/main/java/io/backend/skeleton/httpclient/apache/ApacheFailureClassifier.java` -- Create: `modules/httpclient/httpclient-transport-apache/src/main/java/io/backend/skeleton/httpclient/apache/ApachePoolMetricsBinder.java` -- Create: `modules/httpclient/httpclient-transport-apache/src/main/java/io/backend/skeleton/httpclient/apache/ApacheDnsResolverFactory.java` -- Create: `modules/httpclient/httpclient-transport-apache/src/test/java/io/backend/skeleton/httpclient/apache/ApacheBlockingTransportProviderTest.java` -- Create: `modules/httpclient/httpclient-transport-apache/src/test/java/io/backend/skeleton/httpclient/apache/ApachePoolSaturationTest.java` - -**Interfaces:** -- Implements `BlockingTransportProvider` with ID `apache`. -- Supports route pool, pending acquire, proxy, custom TLS, HTTP/1.1·2, validated DNS resolver. - -- [ ] **Step 1: Write failing pool and request contract tests** - -```java -class ApacheBlockingTransportProviderTest { - @Test - void sendsRequestThroughConfiguredFactory() throws Exception { - try (MockHttpServer server = MockHttpServer.start()) { - server.enqueueJson(200, "{\"value\":1}"); - ClientProfile profile = ClientProfiles.apache(server.uri("/")); - ApacheBlockingTransportProvider provider = new ApacheBlockingTransportProvider(); - - ClientHttpRequestFactory factory = provider.create(profile, NoopLifecycleListener.INSTANCE); - RestClient client = RestClient.builder().requestFactory(factory).build(); - String body = client.get().uri(server.uri("/value")).retrieve().body(String.class); - - assertThat(body).contains("value"); - } - } -} - -class ApachePoolSaturationTest { - @Test - void poolAcquireTimeoutIsClassifiedAsNotSent() { - // server holds the first response; second request must exhaust a one-connection pool - TransportFailure failure = ApacheFixtures.saturateAndCaptureFailure(); - assertThat(failure.stage()).isEqualTo(AttemptStage.POOL_ACQUIRE); - assertThat(failure.evidence()).isEqualTo(ExecutionEvidence.NOT_SENT); - } -} -``` - -- [ ] **Step 2: Run Apache transport tests and confirm failure** - -```bash -./gradlew :modules:httpclient:httpclient-transport-apache:test \ - --tests '*ApacheBlockingTransportProviderTest' \ - --tests '*ApachePoolSaturationTest' -``` - -Expected: FAIL because the provider does not exist. - -- [ ] **Step 3: Implement Apache pool, lifecycle, and failure classification** - -```java -public final class ApacheBlockingTransportProvider implements BlockingTransportProvider { - @Override public TransportId id() { return new TransportId("apache"); } - - @Override - public ClientHttpRequestFactory create(ClientProfile profile, - TransportLifecycleListener listener) { - CloseableHttpClient client = new ApacheClientFactory().create(profile, listener); - HttpComponentsClientHttpRequestFactory factory = - new HttpComponentsClientHttpRequestFactory(client); - factory.setConnectionRequestTimeout(profile.pool().pendingAcquireTimeout()); - factory.setConnectTimeout(profile.timeout().connect()); - return factory; - } -} -``` - -`ApacheClientFactory` creates a `PoolingHttpClientConnectionManager` with total·route limits, connection lifetime, validation after inactivity, idle eviction, proxy, TLS strategy, and profile-scoped DNS resolver. `ApacheFailureClassifier` maps pool timeout to `NOT_SENT`, connect and pre-request TLS failures to `NOT_SENT`, and request write or response timeout to conservative `SENT_NO_RESPONSE`. - -- [ ] **Step 4: Run Apache transport and pool tests** - -```bash -./gradlew :modules:httpclient:httpclient-transport-apache:test -``` - -Expected: PASS; after every test the connection manager reports zero leased connections. - -- [ ] **Step 5: Commit** - -```bash -git add modules/httpclient/httpclient-transport-apache -git commit -m "feat: add apache blocking http transport" -``` - ---- - -### Task 14: JDK HttpClient Blocking Transport 구현 - -**Files:** -- Create: `modules/httpclient/httpclient-transport-jdk/src/main/java/io/backend/skeleton/httpclient/jdk/JdkBlockingTransportProvider.java` -- Create: `modules/httpclient/httpclient-transport-jdk/src/main/java/io/backend/skeleton/httpclient/jdk/JdkClientFactory.java` -- Create: `modules/httpclient/httpclient-transport-jdk/src/main/java/io/backend/skeleton/httpclient/jdk/JdkFailureClassifier.java` -- Create: `modules/httpclient/httpclient-transport-jdk/src/main/java/io/backend/skeleton/httpclient/jdk/JdkTransportCapabilityPolicy.java` -- Test: `modules/httpclient/httpclient-transport-jdk/src/test/java/io/backend/skeleton/httpclient/jdk/JdkBlockingTransportProviderTest.java` -- Test: `modules/httpclient/httpclient-transport-jdk/src/test/java/io/backend/skeleton/httpclient/jdk/JdkTransportCapabilityPolicyTest.java` - -**Interfaces:** -- Implements `BlockingTransportProvider` with ID `jdk`. -- Rejects profiles that require route-level pool, bounded pending queue, or Dynamic Target DNS pinning. - -- [ ] **Step 1: Write failing request and capability tests** - -```java -class JdkTransportCapabilityPolicyTest { - @Test - void rejectsFineGrainedRoutePoolRequirement() { - ClientProfile profile = ClientProfiles.requiresRoutePool("inventory"); - assertThatThrownBy(() -> new JdkTransportCapabilityPolicy().validate(profile)) - .isInstanceOf(HttpConfigurationException.class) - .hasMessageContaining("route pool"); - } -} - -class JdkBlockingTransportProviderTest { - @Test - void performsHttp2CapableBlockingRequest() throws Exception { - try (MockHttpServer server = MockHttpServer.start()) { - server.enqueueJson(200, "{\"ok\":true}"); - ClientProfile profile = ClientProfiles.jdk(server.uri("/")); - ClientHttpRequestFactory factory = new JdkBlockingTransportProvider() - .create(profile, NoopLifecycleListener.INSTANCE); - String body = RestClient.builder().requestFactory(factory).build() - .get().uri(server.uri("/ok")).retrieve().body(String.class); - assertThat(body).contains("ok"); - } - } -} -``` - -- [ ] **Step 2: Run tests and verify failure** - -```bash -./gradlew :modules:httpclient:httpclient-transport-jdk:test -``` - -Expected: FAIL because JDK transport classes are absent. - -- [ ] **Step 3: Implement JDK transport with conservative capabilities** - -```java -public final class JdkClientFactory { - public java.net.http.HttpClient create(ClientProfile profile) { - return java.net.http.HttpClient.newBuilder() - .connectTimeout(profile.timeout().connect()) - .followRedirects(HttpClient.Redirect.NEVER) - .version(profile.protocols().contains(HttpProtocol.HTTP_2) - ? HttpClient.Version.HTTP_2 : HttpClient.Version.HTTP_1_1) - .sslContext(JdkTlsSupport.sslContext(profile)) - .build(); - } -} -``` - -Wrap it with Spring `JdkClientHttpRequestFactory`, set response read timeout, and classify `HttpConnectTimeoutException` as `NOT_SENT`. Other generic I/O failures after request creation remain conservative. - -- [ ] **Step 4: Run JDK transport tests** - -```bash -./gradlew :modules:httpclient:httpclient-transport-jdk:test -``` - -Expected: PASS; unsupported capability profiles fail before a network call. - -- [ ] **Step 5: Commit** - -```bash -git add modules/httpclient/httpclient-transport-jdk -git commit -m "feat: add jdk blocking http transport" -``` - ---- - -### Task 15: RestClient Runtime과 H2 Generic Blocking Gateway 구현 - -**Files:** -- Create: `modules/httpclient/httpclient-restclient/src/main/java/io/backend/skeleton/httpclient/restclient/GenericHttpGateway.java` -- Create: `modules/httpclient/httpclient-restclient/src/main/java/io/backend/skeleton/httpclient/restclient/DefaultGenericHttpGateway.java` -- Create: `modules/httpclient/httpclient-restclient/src/main/java/io/backend/skeleton/httpclient/restclient/RestClientRuntimeFactory.java` -- Create: `modules/httpclient/httpclient-restclient/src/main/java/io/backend/skeleton/httpclient/restclient/BlockingAttemptExecutor.java` -- Create: `modules/httpclient/httpclient-restclient/src/main/java/io/backend/skeleton/httpclient/restclient/RestClientBodyWriter.java` -- Create: `modules/httpclient/httpclient-restclient/src/main/java/io/backend/skeleton/httpclient/restclient/RestClientResponseReader.java` -- Create: `modules/httpclient/httpclient-restclient/src/main/java/io/backend/skeleton/httpclient/restclient/BlockingOperationContext.java` -- Test: `modules/httpclient/httpclient-restclient/src/test/java/io/backend/skeleton/httpclient/restclient/DefaultGenericHttpGatewayTest.java` - -**Interfaces:** -- Produces ` HttpCallResult exchange(ClientProfileName, HttpOperation, ResponseType)`. -- Uses only registered profile-relative URI templates. - -- [ ] **Step 1: Write a failing end-to-end Generic Gateway test** - -```java -class DefaultGenericHttpGatewayTest { - @Test - void expandsRelativeTemplateAndReturnsTypedResult() throws Exception { - try (MockHttpServer server = MockHttpServer.start()) { - server.enqueueJson(200, "{\"id\":42}"); - GenericHttpGateway gateway = TestGateways.apache(server.uri("/")); - HttpOperation operation = HttpOperation.get( - new OperationName("get-user"), "/users/{id}", Map.of("id", 42)); - - HttpCallResult result = gateway.exchange( - new ClientProfileName("users"), operation, - ResponseType.of(UserResponse.class)); - - assertThat(result.status().value()).isEqualTo(200); - assertThat(result.body().id()).isEqualTo(42); - assertThat(server.takeRequest(Duration.ofSeconds(1)).path()) - .isEqualTo("/users/42"); - } - } -} -``` - -- [ ] **Step 2: Run the gateway test and confirm failure** - -```bash -./gradlew :modules:httpclient:httpclient-restclient:test \ - --tests '*DefaultGenericHttpGatewayTest' -``` - -Expected: FAIL because the gateway and runtime factory are missing. - -- [ ] **Step 3: Implement the blocking gateway pipeline skeleton** - -```java -public final class DefaultGenericHttpGateway implements GenericHttpGateway { - private final ClientRuntimeRegistry runtimes; - private final TrustedTargetPolicy targetPolicy; - private final BlockingAttemptExecutor executor; - - @Override - public HttpCallResult exchange(ClientProfileName profileName, - HttpOperation operation, - ResponseType responseType) { - try (ClientRuntimeLease lease = runtimes.acquire(profileName)) { - PreparedOperation prepared = targetPolicy.prepare( - lease.runtime().profile(), operation); - return executor.execute(lease.runtime(), prepared, responseType); - } - } -} -``` - -`RestClientRuntimeFactory` selects Apache or JDK provider, constructs an immutable RestClient, registers platform-owned interceptors, and stores the provider failure classifier in `ClientRuntime`. - -- [ ] **Step 4: Run gateway tests with both blocking transports** - -```bash -./gradlew :modules:httpclient:httpclient-restclient:test \ - -Phttpclient.contract.transports=apache,jdk -``` - -Expected: PASS for Apache and JDK contract variants. - -- [ ] **Step 5: Commit** - -```bash -git add modules/httpclient/httpclient-restclient -git commit -m "feat: add generic blocking http gateway" -``` - ---- - -### Task 16: H1 Blocking Typed Service Client Registry 구현 - -**Files:** -- Create: `modules/httpclient/httpclient-service-client/src/main/java/io/backend/skeleton/httpclient/service/HttpServiceRegistry.java` -- Create: `modules/httpclient/httpclient-service-client/src/main/java/io/backend/skeleton/httpclient/service/DefaultHttpServiceRegistry.java` -- Create: `modules/httpclient/httpclient-service-client/src/main/java/io/backend/skeleton/httpclient/service/HttpClientProfile.java` -- Create: `modules/httpclient/httpclient-service-client/src/main/java/io/backend/skeleton/httpclient/service/HttpOperationPolicy.java` -- Create: `modules/httpclient/httpclient-service-client/src/main/java/io/backend/skeleton/httpclient/service/ServiceOperationDescriptor.java` -- Create: `modules/httpclient/httpclient-service-client/src/main/java/io/backend/skeleton/httpclient/service/ServiceOperationDescriptorScanner.java` -- Create: `modules/httpclient/httpclient-service-client/src/main/java/io/backend/skeleton/httpclient/service/BlockingServiceInvocationHandler.java` -- Create: `modules/httpclient/httpclient-service-client/src/main/java/io/backend/skeleton/httpclient/service/OperationContextHolder.java` -- Test: `modules/httpclient/httpclient-service-client/src/test/java/io/backend/skeleton/httpclient/service/BlockingHttpServiceRegistryTest.java` -- Test: `modules/httpclient/httpclient-service-client/src/test/java/io/backend/skeleton/httpclient/service/ServiceSignatureValidationTest.java` - -**Interfaces:** -- Produces ` T client(ClientProfileName, Class)`. -- Operation descriptors use exact `operationName`, idempotency, retry policy, timeout policy, and streaming flag. - -- [ ] **Step 1: Write failing proxy and signature validation tests** - -```java -@HttpClientProfile("users") -@HttpExchange("/users") -interface UsersClient { - @GetExchange("/{id}") - @HttpOperationPolicy(name = "get-user", - idempotency = OperationIdempotency.STANDARD_IDEMPOTENT) - UserResponse get(@PathVariable long id); -} - -class BlockingHttpServiceRegistryTest { - @Test - void createsTypedProxyBoundToNamedProfile() throws Exception { - try (MockHttpServer server = MockHttpServer.start()) { - server.enqueueJson(200, "{\"id\":7}"); - HttpServiceRegistry registry = TestServiceRegistries.apache(server.uri("/")); - assertThat(registry.client(new ClientProfileName("users"), UsersClient.class) - .get(7).id()).isEqualTo(7); - } - } -} - -class ServiceSignatureValidationTest { - @Test - void rejectsPostWithoutOperationPolicy() { - assertThatThrownBy(() -> new ServiceOperationDescriptorScanner() - .scan(InvalidPostClient.class)) - .isInstanceOf(HttpConfigurationException.class); - } -} -``` - -- [ ] **Step 2: Run service client tests and confirm failure** - -```bash -./gradlew :modules:httpclient:httpclient-service-client:test \ - --tests '*BlockingHttpServiceRegistryTest' \ - --tests '*ServiceSignatureValidationTest' -``` - -Expected: FAIL because annotations, scanner, and registry are missing. - -- [ ] **Step 3: Implement descriptor scanning and wrapper proxy** - -```java -public final class DefaultHttpServiceRegistry implements HttpServiceRegistry { - @Override - public T client(ClientProfileName profileName, Class serviceType) { - List descriptors = scanner.scan(serviceType); - Object springProxy = proxyFactory.create(profileName, serviceType); - InvocationHandler handler = new BlockingServiceInvocationHandler( - springProxy, descriptors, OperationContextHolder.instance()); - return serviceType.cast(Proxy.newProxyInstance( - serviceType.getClassLoader(), new Class[] {serviceType}, handler)); - } -} -``` - -The invocation handler sets the descriptor in a ThreadLocal only for the synchronous call and removes it in `finally`. Principal and user token are never loaded implicitly from this context. - -- [ ] **Step 4: Run blocking typed client tests** - -```bash -./gradlew :modules:httpclient:httpclient-service-client:test \ - -Phttpclient.contract.transports=apache,jdk -``` - -Expected: PASS; operation context is empty after successful and failed invocations. - -- [ ] **Step 5: Commit** - -```bash -git add modules/httpclient/httpclient-service-client -git commit -m "feat: add typed blocking http service clients" -``` - ---- - -### Task 17: Attempt progress와 Execution Evidence 분류 구현 - -**Files:** -- Create: `modules/httpclient/httpclient-resilience/src/main/java/io/backend/skeleton/httpclient/resilience/AttemptProgress.java` -- Create: `modules/httpclient/httpclient-resilience/src/main/java/io/backend/skeleton/httpclient/resilience/AttemptProgressTracker.java` -- Create: `modules/httpclient/httpclient-resilience/src/main/java/io/backend/skeleton/httpclient/resilience/ExecutionEvidenceClassifier.java` -- Create: `modules/httpclient/httpclient-resilience/src/main/java/io/backend/skeleton/httpclient/resilience/DefaultExecutionEvidenceClassifier.java` -- Create: `modules/httpclient/httpclient-resilience/src/main/java/io/backend/skeleton/httpclient/resilience/ProtocolEvidence.java` -- Test: `modules/httpclient/httpclient-resilience/src/test/java/io/backend/skeleton/httpclient/resilience/ExecutionEvidenceClassifierTest.java` - -**Interfaces:** -- Produces evidence from last observed stage, byte progress, response header, and optional protocol evidence. -- Never guesses `NOT_SENT` after request write begins. - -- [ ] **Step 1: Write failing conservative classification tests** - -```java -class ExecutionEvidenceClassifierTest { - private final ExecutionEvidenceClassifier classifier = - new DefaultExecutionEvidenceClassifier(); - - @Test - void poolTimeoutIsNotSent() { - AttemptProgress progress = AttemptProgress.failedAt(AttemptStage.POOL_ACQUIRE); - assertThat(classifier.classify(progress, ProtocolEvidence.none())) - .isEqualTo(ExecutionEvidence.NOT_SENT); - } - - @Test - void responseHeaderTimeoutAfterBodyWriteIsAmbiguous() { - AttemptProgress progress = new AttemptProgress( - AttemptStage.RESPONSE_HEADERS, true, 128, false, 0, false); - assertThat(classifier.classify(progress, ProtocolEvidence.none())) - .isEqualTo(ExecutionEvidence.SENT_NO_RESPONSE); - } - - @Test - void emittedBodyByteIsPartialResponse() { - AttemptProgress progress = new AttemptProgress( - AttemptStage.RESPONSE_BODY, true, 0, true, 64, true); - assertThat(classifier.classify(progress, ProtocolEvidence.none())) - .isEqualTo(ExecutionEvidence.PARTIAL_RESPONSE); - } -} -``` - -- [ ] **Step 2: Run tests and verify failure** - -```bash -./gradlew :modules:httpclient:httpclient-resilience:test \ - --tests '*ExecutionEvidenceClassifierTest' -``` - -Expected: FAIL because progress and classifier types are missing. - -- [ ] **Step 3: Implement stage monotonicity and conservative evidence rules** - -```java -public final class DefaultExecutionEvidenceClassifier - implements ExecutionEvidenceClassifier { - @Override - public ExecutionEvidence classify(AttemptProgress p, ProtocolEvidence protocol) { - if (protocol.peerDidNotProcess()) return ExecutionEvidence.NOT_SENT; - if (p.responseBytesDelivered() > 0 || p.firstByteDelivered()) - return ExecutionEvidence.PARTIAL_RESPONSE; - if (p.responseHeadersReceived()) return ExecutionEvidence.RESPONSE_RECEIVED; - if (p.requestWriteStarted()) return ExecutionEvidence.SENT_NO_RESPONSE; - return switch (p.stage()) { - case VALIDATION, AUTHENTICATION, POOL_ACQUIRE, DNS, CONNECT, - TLS_HANDSHAKE, PROXY_CONNECT -> ExecutionEvidence.NOT_SENT; - default -> ExecutionEvidence.SENT_NO_RESPONSE; - }; - } -} -``` - -`AttemptProgressTracker` forbids stage regression and records first-byte delivery exactly once. - -- [ ] **Step 4: Run evidence tests** - -```bash -./gradlew :modules:httpclient:httpclient-resilience:test \ - --tests '*ExecutionEvidenceClassifierTest' -``` - -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add modules/httpclient/httpclient-resilience -git commit -m "feat: classify http execution evidence" -``` - ---- - -### Task 18: HTTP-specific Retry Eligibility Engine 구현 - -**Files:** -- Create: `modules/httpclient/httpclient-resilience/src/main/java/io/backend/skeleton/httpclient/resilience/RetryContext.java` -- Create: `modules/httpclient/httpclient-resilience/src/main/java/io/backend/skeleton/httpclient/resilience/RetryDecision.java` -- Create: `modules/httpclient/httpclient-resilience/src/main/java/io/backend/skeleton/httpclient/resilience/RetryAllowed.java` -- Create: `modules/httpclient/httpclient-resilience/src/main/java/io/backend/skeleton/httpclient/resilience/RetryDenied.java` -- Create: `modules/httpclient/httpclient-resilience/src/main/java/io/backend/skeleton/httpclient/resilience/AmbiguousFailure.java` -- Create: `modules/httpclient/httpclient-resilience/src/main/java/io/backend/skeleton/httpclient/resilience/RetryEligibilityEngine.java` -- Create: `modules/httpclient/httpclient-resilience/src/main/java/io/backend/skeleton/httpclient/resilience/DefaultRetryEligibilityEngine.java` -- Test: `modules/httpclient/httpclient-resilience/src/test/java/io/backend/skeleton/httpclient/resilience/RetryEligibilityEngineTest.java` - -**Interfaces:** -- Produces a pure deterministic decision without sleeping or issuing requests. -- Consumes idempotency, key presence, replayability, evidence, status, failure, deadline, attempt, and budget. - -- [ ] **Step 1: Write failing safety matrix tests** - -```java -class RetryEligibilityEngineTest { - private final RetryEligibilityEngine engine = new DefaultRetryEligibilityEngine(); - - @Test - void allowsGetAfterConnectFailure() { - assertThat(engine.decide(RetryContexts.getConnectFailure())) - .isInstanceOf(RetryAllowed.class); - } - - @Test - void marksPostWithoutKeyAmbiguousAfterSend() { - assertThat(engine.decide(RetryContexts.postSentNoResponseWithoutKey())) - .isInstanceOf(AmbiguousFailure.class); - } - - @Test - void deniesOneShotBodyEvenForPut() { - assertThat(engine.decide(RetryContexts.putOneShotNotSent())) - .isInstanceOf(RetryDenied.class); - } - - @Test - void honorsRetryAfterOnlyInsideDeadline() { - assertThat(engine.decide(RetryContexts.rateLimitedBeyondDeadline())) - .isInstanceOf(RetryDenied.class); - } -} -``` - -- [ ] **Step 2: Run tests and verify failure** - -```bash -./gradlew :modules:httpclient:httpclient-resilience:test \ - --tests '*RetryEligibilityEngineTest' -``` - -Expected: FAIL because retry decision types are absent. - -- [ ] **Step 3: Implement the complete ordered decision table** - -```java -public final class DefaultRetryEligibilityEngine implements RetryEligibilityEngine { - @Override - public RetryDecision decide(RetryContext c) { - if (c.attempt() >= c.maxAttempts()) return RetryDenied.maxAttempts(); - if (!c.budget().available()) return RetryDenied.budgetExhausted(); - if (!c.replayability().canReplay()) return RetryDenied.bodyNotReplayable(); - if (c.firstByteDelivered()) return RetryDenied.responseAlreadyDelivered(); - if (c.remainingDeadline().compareTo(c.minimumAttemptBudget()) <= 0) - return RetryDenied.deadline(); - if (c.evidence() == ExecutionEvidence.SENT_NO_RESPONSE - && !isSafelyIdempotent(c)) { - return AmbiguousFailure.remoteOutcomeUnknown(); - } - return statusOrFailureDecision(c); - } -} -``` - -Implement explicit branches for 408, 425, 429, 500, 502, 503, 504, 401-refresh-once, TLS permanent errors, pool/DNS/connect errors, response truncation, and `Retry-After`. - -- [ ] **Step 4: Run retry eligibility tests** - -```bash -./gradlew :modules:httpclient:httpclient-resilience:test \ - --tests '*RetryEligibilityEngineTest' -``` - -Expected: PASS; test parameterization covers all documented status and evidence combinations. - -- [ ] **Step 5: Commit** - -```bash -git add modules/httpclient/httpclient-resilience -git commit -m "feat: decide safe http retries" -``` - ---- - -### Task 19: Retry Coordinator, Backoff, Jitter, Retry Budget 구현 - -**Files:** -- Create: `modules/httpclient/httpclient-resilience/src/main/java/io/backend/skeleton/httpclient/resilience/RetryCoordinator.java` -- Create: `modules/httpclient/httpclient-resilience/src/main/java/io/backend/skeleton/httpclient/resilience/BlockingRetryCoordinator.java` -- Create: `modules/httpclient/httpclient-resilience/src/main/java/io/backend/skeleton/httpclient/resilience/BackoffStrategy.java` -- Create: `modules/httpclient/httpclient-resilience/src/main/java/io/backend/skeleton/httpclient/resilience/ExponentialFullJitterBackoff.java` -- Create: `modules/httpclient/httpclient-resilience/src/main/java/io/backend/skeleton/httpclient/resilience/RetryBudget.java` -- Create: `modules/httpclient/httpclient-resilience/src/main/java/io/backend/skeleton/httpclient/resilience/TokenBucketRetryBudget.java` -- Create: `modules/httpclient/httpclient-resilience/src/main/java/io/backend/skeleton/httpclient/resilience/Sleeper.java` -- Test: `modules/httpclient/httpclient-resilience/src/test/java/io/backend/skeleton/httpclient/resilience/BlockingRetryCoordinatorTest.java` -- Test: `modules/httpclient/httpclient-resilience/src/test/java/io/backend/skeleton/httpclient/resilience/RetryBudgetTest.java` - -**Interfaces:** -- Produces a blocking coordinator used by RestClient. -- Later reactive task implements the same semantic without blocking sleep. - -- [ ] **Step 1: Write failing attempt-count, backoff, and budget tests** - -```java -class BlockingRetryCoordinatorTest { - @Test - void retriesOnceThenReturnsSuccessWithoutHoldingAttemptResourcesDuringBackoff() { - FakeAttemptExecutor executor = FakeAttemptExecutor.failThenSucceed(); - RecordingSleeper sleeper = new RecordingSleeper(); - BlockingRetryCoordinator coordinator = Coordinators.blocking(executor, sleeper); - - HttpCallResult result = coordinator.execute(RetryFixtures.safeGet()); - - assertThat(result.attempts()).isEqualTo(2); - assertThat(sleeper.durations()).hasSize(1); - assertThat(executor.activeResourcesDuringSleep()).isZero(); - } -} - -class RetryBudgetTest { - @Test - void rejectsRetryWhenTokensAreExhausted() { - RetryBudget budget = new TokenBucketRetryBudget(1, Duration.ofMinutes(1), Clock.systemUTC()); - assertThat(budget.tryConsume()).isTrue(); - assertThat(budget.tryConsume()).isFalse(); - } -} -``` - -- [ ] **Step 2: Run tests and confirm failure** - -```bash -./gradlew :modules:httpclient:httpclient-resilience:test \ - --tests '*BlockingRetryCoordinatorTest' \ - --tests '*RetryBudgetTest' -``` - -Expected: FAIL because coordinator and budget are missing. - -- [ ] **Step 3: Implement coordinator around physical attempts** - -```java -public final class BlockingRetryCoordinator implements RetryCoordinator { - public HttpCallResult execute(BlockingLogicalCall call) { - for (int attempt = 1; ; attempt++) { - AttemptOutcome outcome = call.attempt(attempt); - RetryDecision decision = eligibility.decide(call.context(outcome, attempt)); - if (decision instanceof RetryAllowed allowed) { - if (!budget.tryConsume()) throw call.retryExhausted(attempt); - sleeper.sleep(backoff.delay(attempt, allowed.retryAfter(), call.deadline())); - continue; - } - if (decision instanceof AmbiguousFailure) throw call.ambiguous(outcome, attempt); - return call.finish(outcome, attempt); - } - } -} -``` - -Use an injectable `Sleeper` and `RandomGenerator` for deterministic tests. Never sleep past the effective deadline. - -- [ ] **Step 4: Run coordinator and budget tests** - -```bash -./gradlew :modules:httpclient:httpclient-resilience:test \ - --tests '*BlockingRetryCoordinatorTest' \ - --tests '*RetryBudgetTest' -``` - -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add modules/httpclient/httpclient-resilience -git commit -m "feat: coordinate bounded http retries" -``` - ---- - -### Task 20: Circuit Breaker·Rate Limiter·Bulkhead 물리 시도 Pipeline 구현 - -**Files:** -- Create: `modules/httpclient/httpclient-resilience/src/main/java/io/backend/skeleton/httpclient/resilience/AttemptResiliencePipeline.java` -- Create: `modules/httpclient/httpclient-resilience/src/main/java/io/backend/skeleton/httpclient/resilience/ResilienceRegistry.java` -- Create: `modules/httpclient/httpclient-resilience/src/main/java/io/backend/skeleton/httpclient/resilience/LogicalAdmissionLimiter.java` -- Create: `modules/httpclient/httpclient-resilience/src/main/java/io/backend/skeleton/httpclient/resilience/BlockingAttemptBulkhead.java` -- Create: `modules/httpclient/httpclient-resilience/src/main/java/io/backend/skeleton/httpclient/resilience/AttemptRateLimiter.java` -- Create: `modules/httpclient/httpclient-resilience/src/main/java/io/backend/skeleton/httpclient/resilience/AttemptCircuitBreaker.java` -- Test: `modules/httpclient/httpclient-resilience/src/test/java/io/backend/skeleton/httpclient/resilience/AttemptResiliencePipelineTest.java` - -**Interfaces:** -- Retry Coordinator invokes `AttemptResiliencePipeline.execute(attemptSupplier)` for every physical attempt. -- Pipeline order is Circuit → Rate Limiter → Bulkhead → HTTP call. - -- [ ] **Step 1: Write a failing decorator-order test** - -```java -class AttemptResiliencePipelineTest { - @Test - void appliesCircuitThenRateLimiterThenBulkheadPerAttempt() { - RecordingResilienceComponents components = new RecordingResilienceComponents(); - AttemptResiliencePipeline pipeline = components.pipeline(); - - assertThat(pipeline.execute(() -> "ok")).isEqualTo("ok"); - assertThat(components.events()).containsExactly( - "circuit-enter", "rate-enter", "bulkhead-enter", - "call", "bulkhead-exit", "rate-exit", "circuit-exit"); - } - - @Test - void openCircuitDoesNotConsumeRateOrBulkheadPermit() { - RecordingResilienceComponents components = RecordingResilienceComponents.openCircuit(); - assertThatThrownBy(() -> components.pipeline().execute(() -> "never")) - .isInstanceOf(HttpCircuitOpenException.class); - assertThat(components.events()).containsExactly("circuit-reject"); - } -} -``` - -- [ ] **Step 2: Run tests and confirm failure** - -```bash -./gradlew :modules:httpclient:httpclient-resilience:test \ - --tests '*AttemptResiliencePipelineTest' -``` - -Expected: FAIL because the physical attempt pipeline is missing. - -- [ ] **Step 3: Implement fixed decorator order using Resilience4j primitives** - -```java -public final class AttemptResiliencePipeline { - public T execute(CheckedSupplier call) { - if (!circuit.tryAcquirePermission()) throw circuitOpen(); - long started = System.nanoTime(); - try { - rateLimiter.acquirePermission(); - T result = bulkhead.execute(call); - circuit.onSuccess(System.nanoTime() - started, NANOSECONDS); - return result; - } catch (Throwable failure) { - circuit.onError(System.nanoTime() - started, NANOSECONDS, failure); - throw translate(failure); - } - } -} -``` - -Use adapter classes around Resilience4j rather than leaking its exception types. `LogicalAdmissionLimiter` runs once before creating the Retry Coordinator; attempt rate and bulkhead run for every physical attempt. - -- [ ] **Step 4: Run resilience pipeline tests** - -```bash -./gradlew :modules:httpclient:httpclient-resilience:test \ - --tests '*AttemptResiliencePipelineTest' -``` - -Expected: PASS; no rate or bulkhead permit is consumed when the circuit is open. - -- [ ] **Step 5: Commit** - -```bash -git add modules/httpclient/httpclient-resilience -git commit -m "feat: enforce http attempt resilience order" -``` - ---- - -### Task 21: Response 크기 제한, RFC 9457, 안정 오류 변환 구현 - -**Files:** -- Create: `modules/httpclient/httpclient-restclient/src/main/java/io/backend/skeleton/httpclient/restclient/ResponseSizeLimiter.java` -- Create: `modules/httpclient/httpclient-restclient/src/main/java/io/backend/skeleton/httpclient/restclient/BlockingResponseMapper.java` -- Create: `modules/httpclient/httpclient-restclient/src/main/java/io/backend/skeleton/httpclient/restclient/RemoteProblemDecoder.java` -- Create: `modules/httpclient/httpclient-restclient/src/main/java/io/backend/skeleton/httpclient/restclient/StableBlockingExceptionMapper.java` -- Create: `modules/httpclient/httpclient-restclient/src/main/java/io/backend/skeleton/httpclient/restclient/BoundedErrorBody.java` -- Modify: `modules/httpclient/httpclient-restclient/src/main/java/io/backend/skeleton/httpclient/restclient/BlockingAttemptExecutor.java` -- Test: `modules/httpclient/httpclient-restclient/src/test/java/io/backend/skeleton/httpclient/restclient/BlockingResponseMapperTest.java` -- Test: `modules/httpclient/httpclient-restclient/src/test/java/io/backend/skeleton/httpclient/restclient/ResponseSizeLimiterTest.java` - -**Interfaces:** -- Maps all non-success responses and transport failures to `HttpClientException` subclasses. -- Preserves RFC 9457 fields under a byte and extension allowlist. - -- [ ] **Step 1: Write failing problem and oversized response tests** - -```java -class BlockingResponseMapperTest { - @Test - void mapsProblemJsonWithoutTrustingBodyStatus() { - RemoteProblem problem = new RemoteProblemDecoder(4096, Set.of("code")) - .decode(503, "application/problem+json", - """{"type":"urn:test","title":"busy","status":400,"detail":"later","code":"UPSTREAM_BUSY"}""" - .getBytes(UTF_8)); - assertThat(problem.httpStatus().value()).isEqualTo(503); - assertThat(problem.extensions()).containsEntry("code", "UPSTREAM_BUSY"); - } -} - -class ResponseSizeLimiterTest { - @Test - void abortsWhenDecodedBytesExceedLimit() { - ResponseSizeLimiter limiter = new ResponseSizeLimiter(10, 20); - assertThatThrownBy(() -> limiter.recordDecodedBytes(21)) - .isInstanceOf(HttpResponseTooLargeException.class); - } -} -``` - -- [ ] **Step 2: Run response mapping tests and verify failure** - -```bash -./gradlew :modules:httpclient:httpclient-restclient:test \ - --tests '*BlockingResponseMapperTest' \ - --tests '*ResponseSizeLimiterTest' -``` - -Expected: FAIL because response mapping components are absent. - -- [ ] **Step 3: Implement bounded response and stable exception mapping** - -```java -public final class RemoteProblemDecoder { - public RemoteProblem decode(int actualStatus, String contentType, byte[] body) { - if (!"application/problem+json".equalsIgnoreCase(contentType)) { - return RemoteProblem.empty(new HttpStatus(actualStatus)); - } - byte[] bounded = body.length <= maxBytes ? body : Arrays.copyOf(body, maxBytes); - ProblemPayload payload = objectMapper.readValue(bounded, ProblemPayload.class); - return new RemoteProblem( - optionalUri(payload.type()), payload.title(), new HttpStatus(actualStatus), - payload.detail(), payload.instance(), allowedExtensions(payload.extensions())); - } -} -``` - -`BlockingResponseMapper` counts wire and decoded bytes, closes the body on every branch, and creates `HttpRemoteErrorException` or `HttpProblemDetailException` with sanitized metadata. It never stores the raw error body in the exception. - -- [ ] **Step 4: Run response mapping tests** - -```bash -./gradlew :modules:httpclient:httpclient-restclient:test \ - --tests '*BlockingResponseMapperTest' \ - --tests '*ResponseSizeLimiterTest' -``` - -Expected: PASS; pool contract tests show zero leased connections after decode failure and size rejection. - -- [ ] **Step 5: Commit** - -```bash -git add modules/httpclient/httpclient-restclient -git commit -m "feat: map bounded remote http failures" -``` - ---- - -### Task 22: Static Credential과 OAuth2 Client 통합 구현 - -**Files:** -- Create: `modules/httpclient/httpclient-auth/src/main/java/io/backend/skeleton/httpclient/auth/CredentialType.java` -- Create: `modules/httpclient/httpclient-auth/src/main/java/io/backend/skeleton/httpclient/auth/RequestCredentials.java` -- Create: `modules/httpclient/httpclient-auth/src/main/java/io/backend/skeleton/httpclient/auth/CredentialRequest.java` -- Create: `modules/httpclient/httpclient-auth/src/main/java/io/backend/skeleton/httpclient/auth/RequestCredentialProvider.java` -- Create: `modules/httpclient/httpclient-auth/src/main/java/io/backend/skeleton/httpclient/auth/NoAuthCredentialProvider.java` -- Create: `modules/httpclient/httpclient-auth/src/main/java/io/backend/skeleton/httpclient/auth/BasicCredentialProvider.java` -- Create: `modules/httpclient/httpclient-auth/src/main/java/io/backend/skeleton/httpclient/auth/ApiKeyHeaderCredentialProvider.java` -- Create: `modules/httpclient/httpclient-auth/src/main/java/io/backend/skeleton/httpclient/auth/StaticBearerCredentialProvider.java` -- Create: `modules/httpclient/httpclient-auth/src/main/java/io/backend/skeleton/httpclient/auth/OAuth2CredentialProvider.java` -- Create: `modules/httpclient/httpclient-auth/src/main/java/io/backend/skeleton/httpclient/auth/OAuth2TokenCacheKey.java` -- Create: `modules/httpclient/httpclient-auth/src/main/java/io/backend/skeleton/httpclient/auth/SingleFlightTokenLoader.java` -- Create: `modules/httpclient/httpclient-auth/src/main/java/io/backend/skeleton/httpclient/auth/UnauthorizedRetryPolicy.java` -- Test: `modules/httpclient/httpclient-auth/src/test/java/io/backend/skeleton/httpclient/auth/SingleFlightTokenLoaderTest.java` -- Test: `modules/httpclient/httpclient-auth/src/test/java/io/backend/skeleton/httpclient/auth/UnauthorizedRetryPolicyTest.java` - -**Interfaces:** -- Produces blocking credential materialization for RestClient. -- Reactive credential provider is added with the WebClient task. -- Token cache key includes registration, principal class, scopes, audience, tenant boundary, and mTLS identity. - -- [ ] **Step 1: Write failing concurrent refresh and 401 safety tests** - -```java -class SingleFlightTokenLoaderTest { - @Test - void concurrentRequestsShareOneTokenRefresh() throws Exception { - AtomicInteger loads = new AtomicInteger(); - SingleFlightTokenLoader loader = new SingleFlightTokenLoader(key -> { - loads.incrementAndGet(); - return AccessTokens.validFor(Duration.ofMinutes(5)); - }); - - ExecutorService pool = Executors.newFixedThreadPool(20); - List> futures = IntStream.range(0, 20) - .mapToObj(i -> pool.submit(() -> loader.load(TokenKeys.payment()))) - .toList(); - for (Future future : futures) future.get(); - - assertThat(loads).hasValue(1); - pool.shutdownNow(); - } -} - -class UnauthorizedRetryPolicyTest { - @Test - void denies401ReplayForOneShotPost() { - assertThat(new UnauthorizedRetryPolicy().mayRetry( - AuthRetryFixtures.oneShotPost401())).isFalse(); - } -} -``` - -- [ ] **Step 2: Run auth tests and confirm failure** - -```bash -./gradlew :modules:httpclient:httpclient-auth:test \ - --tests '*SingleFlightTokenLoaderTest' \ - --tests '*UnauthorizedRetryPolicyTest' -``` - -Expected: FAIL because credential providers and token loader are missing. - -- [ ] **Step 3: Implement provider registry and Spring Security OAuth2 delegation** - -```java -public final class SingleFlightTokenLoader { - private final ConcurrentMap> inFlight = - new ConcurrentHashMap<>(); - - public AccessToken load(OAuth2TokenCacheKey key) { - CompletableFuture future = inFlight.computeIfAbsent(key, - ignored -> CompletableFuture.supplyAsync(() -> delegate.load(key))); - try { - return future.join(); - } finally { - if (future.isDone()) inFlight.remove(key, future); - } - } -} -``` - -`OAuth2CredentialProvider` calls `OAuth2AuthorizedClientManager`, applies expiry skew, and returns only an immutable Authorization header. Token endpoint calls use a separate Named Client Profile. `UnauthorizedRetryPolicy` allows at most one refresh-and-replay for a replayable safe or explicitly contract-idempotent operation. - -- [ ] **Step 4: Run authentication tests** - -```bash -./gradlew :modules:httpclient:httpclient-auth:test -``` - -Expected: PASS; test logs contain no access token, client secret, or authorization code. - -- [ ] **Step 5: Commit** - -```bash -git add modules/httpclient/httpclient-auth -git commit -m "feat: add bounded http client authentication" -``` - ---- - -### Task 23: TLS·mTLS Policy와 Certificate Runtime Rotation 구현 - -**Files:** -- Create: `modules/httpclient/httpclient-security/src/main/java/io/backend/skeleton/httpclient/security/TlsProfileId.java` -- Create: `modules/httpclient/httpclient-security/src/main/java/io/backend/skeleton/httpclient/security/TlsProfile.java` -- Create: `modules/httpclient/httpclient-security/src/main/java/io/backend/skeleton/httpclient/security/TlsPolicyValidator.java` -- Create: `modules/httpclient/httpclient-security/src/main/java/io/backend/skeleton/httpclient/security/TlsMaterialProvider.java` -- Create: `modules/httpclient/httpclient-security/src/main/java/io/backend/skeleton/httpclient/security/ClientCertificateIdentity.java` -- Create: `modules/httpclient/httpclient-security/src/main/java/io/backend/skeleton/httpclient/security/TlsRuntimeRotationCoordinator.java` -- Create: `modules/httpclient/httpclient-security/src/main/java/io/backend/skeleton/httpclient/security/SslContextMaterial.java` -- Modify: `modules/httpclient/httpclient-transport-apache/src/main/java/io/backend/skeleton/httpclient/apache/ApacheClientFactory.java` -- Modify: `modules/httpclient/httpclient-transport-jdk/src/main/java/io/backend/skeleton/httpclient/jdk/JdkClientFactory.java` -- Test: `modules/httpclient/httpclient-security/src/test/java/io/backend/skeleton/httpclient/security/TlsPolicyValidatorTest.java` -- Test: `modules/httpclient/httpclient-security/src/test/java/io/backend/skeleton/httpclient/security/TlsRuntimeRotationCoordinatorTest.java` - -**Interfaces:** -- Produces verified SSL material for Apache, JDK, Reactor, and Jetty providers. -- Rotation builds a new `ClientRuntime` generation and drains the old generation. - -- [ ] **Step 1: Write failing unsafe TLS and rotation tests** - -```java -class TlsPolicyValidatorTest { - @Test - void rejectsTrustAllAndHostnameVerificationDisablement() { - TlsProfile unsafe = TlsProfiles.trustAllWithoutHostnameVerification(); - assertThat(new TlsPolicyValidator().validate(unsafe)) - .extracting(TlsViolation::code) - .contains("TRUST_ALL_FORBIDDEN", "HOSTNAME_VERIFICATION_REQUIRED"); - } -} - -class TlsRuntimeRotationCoordinatorTest { - @Test - void swapsRuntimeWhenCertificateIdentityChanges() { - ClientRuntimeRegistry registry = RuntimeFixtures.registryWithCertificate("cert-v1"); - TlsRuntimeRotationCoordinator coordinator = RotationFixtures.coordinator(registry); - coordinator.rotate(new ClientCertificateIdentity("cert-v2")); - try (ClientRuntimeLease lease = registry.acquire(new ClientProfileName("partner"))) { - assertThat(lease.runtime().generation().value()).isEqualTo(2); - } - } -} -``` - -- [ ] **Step 2: Run TLS tests and confirm failure** - -```bash -./gradlew :modules:httpclient:httpclient-security:test \ - --tests '*TlsPolicyValidatorTest' \ - --tests '*TlsRuntimeRotationCoordinatorTest' -``` - -Expected: FAIL because TLS profile and rotation components are missing. - -- [ ] **Step 3: Implement strict TLS profiles and generation swap** - -```java -public record TlsProfile( - TlsProfileId id, - Set protocols, - boolean hostnameVerification, - TrustMaterialRef trustMaterial, - Optional clientKeyMaterial, - boolean allowPlainHttp) { -} -``` - -`TlsPolicyValidator` permits only TLS 1.2 and 1.3 in production, requires hostname verification, and has no representation for trust-all. `TlsRuntimeRotationCoordinator` loads new material, builds and validates a replacement runtime, swaps it atomically, then drains the old pool. - -- [ ] **Step 4: Run TLS security and transport integration tests** - -```bash -./gradlew :modules:httpclient:httpclient-security:test \ - :modules:httpclient:httpclient-transport-apache:test \ - :modules:httpclient:httpclient-transport-jdk:test -``` - -Expected: PASS; a hostname mismatch fails without a second network attempt. - -- [ ] **Step 5: Commit** - -```bash -git add modules/httpclient/httpclient-security \ - modules/httpclient/httpclient-transport-apache \ - modules/httpclient/httpclient-transport-jdk -git commit -m "feat: enforce tls and mtls runtime policy" -``` - ---- - -### Task 24: Redirect 실행과 Credential stripping 구현 - -**Files:** -- Create: `modules/httpclient/httpclient-security/src/main/java/io/backend/skeleton/httpclient/security/RedirectDecision.java` -- Create: `modules/httpclient/httpclient-security/src/main/java/io/backend/skeleton/httpclient/security/RedirectEvaluator.java` -- Create: `modules/httpclient/httpclient-security/src/main/java/io/backend/skeleton/httpclient/security/SensitiveHeaderStripper.java` -- Create: `modules/httpclient/httpclient-restclient/src/main/java/io/backend/skeleton/httpclient/restclient/BlockingRedirectCoordinator.java` -- Modify: `modules/httpclient/httpclient-restclient/src/main/java/io/backend/skeleton/httpclient/restclient/BlockingAttemptExecutor.java` -- Test: `modules/httpclient/httpclient-security/src/test/java/io/backend/skeleton/httpclient/security/RedirectEvaluatorTest.java` -- Test: `modules/httpclient/httpclient-restclient/src/test/java/io/backend/skeleton/httpclient/restclient/BlockingRedirectCoordinatorTest.java` - -**Interfaces:** -- Engine automatic redirect remains disabled. -- Platform coordinator evaluates every hop and rebuilds request headers explicitly. - -- [ ] **Step 1: Write failing method-preservation and header-leak tests** - -```java -class RedirectEvaluatorTest { - @Test - void rejects307WhenBodyIsOneShot() { - RedirectContext context = RedirectFixtures.oneShotPost307(); - assertThat(new RedirectEvaluator().evaluate(context)) - .isInstanceOf(RedirectDecision.Reject.class); - } - - @Test - void stripsCredentialsOnCrossOriginRedirect() { - Map> result = SensitiveHeaderStripper.standard() - .stripForCrossOrigin(Map.of( - "Authorization", List.of("Bearer secret"), - "Cookie", List.of("sid=x"), - "Accept", List.of("application/json"))); - assertThat(result).containsOnlyKeys("Accept"); - } -} -``` - -- [ ] **Step 2: Run redirect tests and confirm failure** - -```bash -./gradlew :modules:httpclient:httpclient-security:test \ - --tests '*RedirectEvaluatorTest' \ - :modules:httpclient:httpclient-restclient:test \ - --tests '*BlockingRedirectCoordinatorTest' -``` - -Expected: FAIL because redirect components are absent. - -- [ ] **Step 3: Implement bounded hop evaluation** - -```java -public final class RedirectEvaluator { - public RedirectDecision evaluate(RedirectContext c) { - if (!c.policy().enabled()) return RedirectDecision.reject("REDIRECT_DISABLED"); - if (c.hop() >= c.policy().maxHops()) return RedirectDecision.reject("MAX_HOPS"); - if ((c.status() == 307 || c.status() == 308) && !c.body().replayability().canReplay()) - return RedirectDecision.reject("BODY_NOT_REPLAYABLE"); - if (c.crossOrigin() && !c.policy().allowCrossOrigin()) - return RedirectDecision.reject("CROSS_ORIGIN_FORBIDDEN"); - return RedirectDecision.follow(c.target(), c.crossOrigin()); - } -} -``` - -`BlockingRedirectCoordinator` counts every redirect request as a physical attempt for rate and bulkhead purposes but not as a Retry caused by failure. It re-applies target security before each hop. - -- [ ] **Step 4: Run redirect contract tests** - -```bash -./gradlew :modules:httpclient:httpclient-security:test \ - :modules:httpclient:httpclient-restclient:test \ - --tests '*Redirect*Test' -``` - -Expected: PASS; cross-origin recorded requests contain no Authorization, Cookie, or API key header. - -- [ ] **Step 5: Commit** - -```bash -git add modules/httpclient/httpclient-security \ - modules/httpclient/httpclient-restclient -git commit -m "feat: control outbound http redirects" -``` - ---- - -### Task 25: H3 Dynamic Target SSRF 방어와 DNS/IP Pinning 구현 - -**Files:** -- Create: `modules/httpclient/httpclient-dynamic-target/src/main/java/io/backend/skeleton/httpclient/dynamic/DynamicTargetGateway.java` -- Create: `modules/httpclient/httpclient-dynamic-target/src/main/java/io/backend/skeleton/httpclient/dynamic/DynamicTargetPolicyName.java` -- Create: `modules/httpclient/httpclient-dynamic-target/src/main/java/io/backend/skeleton/httpclient/dynamic/DynamicTargetPolicy.java` -- Create: `modules/httpclient/httpclient-dynamic-target/src/main/java/io/backend/skeleton/httpclient/dynamic/CanonicalTarget.java` -- Create: `modules/httpclient/httpclient-dynamic-target/src/main/java/io/backend/skeleton/httpclient/dynamic/TargetCanonicalizer.java` -- Create: `modules/httpclient/httpclient-dynamic-target/src/main/java/io/backend/skeleton/httpclient/dynamic/IpAddressClassifier.java` -- Create: `modules/httpclient/httpclient-dynamic-target/src/main/java/io/backend/skeleton/httpclient/dynamic/ValidatedDnsResolver.java` -- Create: `modules/httpclient/httpclient-dynamic-target/src/main/java/io/backend/skeleton/httpclient/dynamic/PinnedTarget.java` -- Create: `modules/httpclient/httpclient-dynamic-target/src/main/java/io/backend/skeleton/httpclient/dynamic/DefaultDynamicTargetGateway.java` -- Create: `modules/httpclient/httpclient-dynamic-target/src/main/java/io/backend/skeleton/httpclient/dynamic/DynamicCredentialBinding.java` -- Test: `modules/httpclient/httpclient-dynamic-target/src/test/java/io/backend/skeleton/httpclient/dynamic/TargetCanonicalizerTest.java` -- Test: `modules/httpclient/httpclient-dynamic-target/src/test/java/io/backend/skeleton/httpclient/dynamic/DynamicTargetSecurityTest.java` - -**Interfaces:** -- Supports Apache first; Reactor integration is added after its transport task. -- JDK and Jetty are rejected for H3 Stable until validated pinning capability exists. - -- [ ] **Step 1: Write failing SSRF matrix tests** - -```java -class DynamicTargetSecurityTest { - @ParameterizedTest - @ValueSource(strings = { - "http://127.0.0.1/a", - "https://[::1]/a", - "https://169.254.169.254/latest/meta-data", - "file:///etc/passwd", - "https://user:pass@example.com/a" - }) - void rejectsForbiddenTargets(String raw) { - DynamicTargetPolicy policy = DynamicPolicies.publicHttpsOnly(); - assertThatThrownBy(() -> DynamicTargets.prepare(policy, URI.create(raw))) - .isInstanceOf(HttpTargetRejectedException.class); - } - - @Test - void rejectsDnsAnswerWhenAnyAddressIsPrivate() { - ValidatedDnsResolver resolver = DnsFixtures.resolvesTo( - "mixed.test", "203.0.113.10", "10.0.0.4"); - assertThatThrownBy(() -> resolver.resolve("mixed.test")) - .isInstanceOf(HttpTargetRejectedException.class); - } -} -``` - -- [ ] **Step 2: Run Dynamic Target tests and confirm failure** - -```bash -./gradlew :modules:httpclient:httpclient-dynamic-target:test \ - --tests '*TargetCanonicalizerTest' \ - --tests '*DynamicTargetSecurityTest' -``` - -Expected: FAIL because canonicalization and IP policy are missing. - -- [ ] **Step 3: Implement canonicalization, all-answer validation, and pinning** - -```java -public final class TargetCanonicalizer { - public CanonicalTarget canonicalize(DynamicTargetPolicy policy, URI input) { - if (input.getUserInfo() != null) reject("USERINFO_FORBIDDEN"); - String scheme = input.getScheme().toLowerCase(Locale.ROOT); - if (!policy.allowedSchemes().contains(scheme)) reject("SCHEME_FORBIDDEN"); - String host = IDN.toASCII(stripTrailingDot(input.getHost()), IDN.USE_STD3_ASCII_RULES) - .toLowerCase(Locale.ROOT); - int port = effectivePort(input); - if (!policy.allowedPorts().contains(port)) reject("PORT_FORBIDDEN"); - return new CanonicalTarget(scheme, host, port, normalizedPath(input), input.getRawQuery()); - } -} -``` - -`ValidatedDnsResolver` validates every A and AAAA answer, normalizes IPv4-mapped IPv6, and returns a `PinnedTarget` containing the canonical host and exact approved addresses. Apache uses this resolver for the actual connection. Redirects restart the full validation flow. - -- [ ] **Step 4: Run the Dynamic Target security suite** - -```bash -./gradlew :modules:httpclient:httpclient-dynamic-target:test -``` - -Expected: PASS for loopback, link-local, private, ULA, metadata, IDNA, mapped IPv6, mixed DNS answer, and redirect fixtures. - -- [ ] **Step 5: Commit** - -```bash -git add modules/httpclient/httpclient-dynamic-target -git commit -m "feat: secure dynamic outbound http targets" -``` - ---- - -### Task 26: Reactor Netty Reactive Transport 구현 - -**Files:** -- Create: `modules/httpclient/httpclient-transport-reactor-netty/src/main/java/io/backend/skeleton/httpclient/reactor/ReactorNettyTransportProvider.java` -- Create: `modules/httpclient/httpclient-transport-reactor-netty/src/main/java/io/backend/skeleton/httpclient/reactor/ReactorConnectionProviderFactory.java` -- Create: `modules/httpclient/httpclient-transport-reactor-netty/src/main/java/io/backend/skeleton/httpclient/reactor/ReactorHttpClientFactory.java` -- Create: `modules/httpclient/httpclient-transport-reactor-netty/src/main/java/io/backend/skeleton/httpclient/reactor/ReactorFailureClassifier.java` -- Create: `modules/httpclient/httpclient-transport-reactor-netty/src/main/java/io/backend/skeleton/httpclient/reactor/ReactorPoolMetricsBinder.java` -- Create: `modules/httpclient/httpclient-transport-reactor-netty/src/main/java/io/backend/skeleton/httpclient/reactor/ValidatedAddressResolverGroup.java` -- Test: `modules/httpclient/httpclient-transport-reactor-netty/src/test/java/io/backend/skeleton/httpclient/reactor/ReactorNettyTransportProviderTest.java` -- Test: `modules/httpclient/httpclient-transport-reactor-netty/src/test/java/io/backend/skeleton/httpclient/reactor/ReactorCancellationTest.java` - -**Interfaces:** -- Implements `ReactiveTransportProvider` with ID `reactor-netty`. -- Supports profile-scoped pool, pending acquire, DNS pinning, proxy, TLS, HTTP/1.1·2, cancellation. - -- [ ] **Step 1: Write failing reactive request and cancellation tests** - -```java -class ReactorCancellationTest { - @Test - void cancellationReleasesConnection() { - ReactorTransportFixture fixture = ReactorTransportFixture.slowBody(); - StepVerifier.create(fixture.webClient().get().uri(fixture.uri()).retrieve() - .bodyToFlux(DataBuffer.class).take(1)) - .expectNextCount(1) - .verifyComplete(); - await().atMost(Duration.ofSeconds(2)) - .untilAsserted(() -> assertThat(fixture.leasedConnections()).isZero()); - } -} -``` - -- [ ] **Step 2: Run Reactor transport tests and confirm failure** - -```bash -./gradlew :modules:httpclient:httpclient-transport-reactor-netty:test -``` - -Expected: FAIL because the provider and pool factory are missing. - -- [ ] **Step 3: Implement profile-scoped Reactor Netty runtime** - -```java -public final class ReactorConnectionProviderFactory { - public ConnectionProvider create(ClientProfile profile) { - return ConnectionProvider.builder(profile.name().value()) - .maxConnections(profile.pool().maxTotalConnections()) - .pendingAcquireMaxCount(profile.pool().maxPendingAcquires()) - .pendingAcquireTimeout(profile.pool().pendingAcquireTimeout()) - .maxIdleTime(profile.pool().maxIdleTime()) - .maxLifeTime(profile.pool().maxLifeTime()) - .evictInBackground(profile.pool().evictionInterval()) - .metrics(true) - .build(); - } -} -``` - -Configure connect, response, TLS handshake, proxy, DNS resolver, protocol, and wire/decoded byte hooks. `doOnDiscard(DataBuffer.class, DataBufferUtils::release)` is registered in the WebClient integration rather than the transport provider. - -- [ ] **Step 4: Run Reactor transport and cancellation tests** - -```bash -./gradlew :modules:httpclient:httpclient-transport-reactor-netty:test -``` - -Expected: PASS; cancellation, timeout, and decode error return the pool to zero leased connections. - -- [ ] **Step 5: Commit** - -```bash -git add modules/httpclient/httpclient-transport-reactor-netty -git commit -m "feat: add reactor netty http transport" -``` - ---- - -### Task 27: WebClient Reactive Gateway와 Non-blocking Retry Coordinator 구현 - -**Files:** -- Create: `modules/httpclient/httpclient-webclient/src/main/java/io/backend/skeleton/httpclient/webclient/ReactiveHttpGateway.java` -- Create: `modules/httpclient/httpclient-webclient/src/main/java/io/backend/skeleton/httpclient/webclient/DefaultReactiveHttpGateway.java` -- Create: `modules/httpclient/httpclient-webclient/src/main/java/io/backend/skeleton/httpclient/webclient/WebClientRuntimeFactory.java` -- Create: `modules/httpclient/httpclient-webclient/src/main/java/io/backend/skeleton/httpclient/webclient/ReactiveAttemptExecutor.java` -- Create: `modules/httpclient/httpclient-webclient/src/main/java/io/backend/skeleton/httpclient/webclient/WebClientBodyWriter.java` -- Create: `modules/httpclient/httpclient-webclient/src/main/java/io/backend/skeleton/httpclient/webclient/WebClientResponseMapper.java` -- Create: `modules/httpclient/httpclient-webclient/src/main/java/io/backend/skeleton/httpclient/webclient/ReactiveBodySource.java` -- Create: `modules/httpclient/httpclient-resilience/src/main/java/io/backend/skeleton/httpclient/resilience/ReactiveRetryCoordinator.java` -- Create: `modules/httpclient/httpclient-auth/src/main/java/io/backend/skeleton/httpclient/auth/ReactiveRequestCredentialProvider.java` -- Test: `modules/httpclient/httpclient-webclient/src/test/java/io/backend/skeleton/httpclient/webclient/DefaultReactiveHttpGatewayTest.java` -- Test: `modules/httpclient/httpclient-resilience/src/test/java/io/backend/skeleton/httpclient/resilience/ReactiveRetryCoordinatorTest.java` - -**Interfaces:** -- Produces `Mono> exchange(...)`. -- Uses Reactor delay for backoff and never calls `Thread.sleep()` or `.block()`. - -- [ ] **Step 1: Write failing reactive retry and context tests** - -```java -class DefaultReactiveHttpGatewayTest { - @Test - void returnsTypedResultWithoutBlocking() { - try (MockHttpServer server = MockHttpServer.start()) { - server.enqueueJson(200, "{\"id\":9}"); - ReactiveHttpGateway gateway = TestGateways.reactor(server.uri("/")); - Mono> result = gateway.exchange( - new ClientProfileName("users"), - HttpOperation.get(new OperationName("get-user"), "/users/9", Map.of()), - ResponseType.of(UserResponse.class)); - - StepVerifier.create(result) - .assertNext(value -> assertThat(value.body().id()).isEqualTo(9)) - .verifyComplete(); - } - } -} - -class ReactiveRetryCoordinatorTest { - @Test - void backoffDoesNotBlockCallingThread() { - VirtualTimeScheduler.getOrSet(); - Mono> call = ReactiveRetryFixtures.failThenSucceed(); - StepVerifier.withVirtualTime(() -> call) - .thenAwait(Duration.ofMillis(100)) - .assertNext(result -> assertThat(result.attempts()).isEqualTo(2)) - .verifyComplete(); - } -} -``` - -- [ ] **Step 2: Run reactive gateway tests and confirm failure** - -```bash -./gradlew :modules:httpclient:httpclient-webclient:test \ - :modules:httpclient:httpclient-resilience:test \ - --tests '*ReactiveRetryCoordinatorTest' -``` - -Expected: FAIL because reactive gateway and coordinator are missing. - -- [ ] **Step 3: Implement Reactor-context-aware non-blocking pipeline** - -```java -public final class ReactiveRetryCoordinator { - public Mono> execute(ReactiveLogicalCall call) { - return attempt(call, 1); - } - - private Mono> attempt(ReactiveLogicalCall call, int number) { - return call.attempt(number).flatMap(outcome -> { - RetryDecision decision = eligibility.decide(call.context(outcome, number)); - if (decision instanceof RetryAllowed allowed) { - if (!budget.tryConsume()) return Mono.error(call.retryExhausted(number)); - return Mono.delay(backoff.delay(number, allowed.retryAfter(), call.deadline())) - .then(attempt(call, number + 1)); - } - if (decision instanceof AmbiguousFailure) return Mono.error(call.ambiguous(outcome, number)); - return call.finish(outcome, number); - }); - } -} -``` - -`DefaultReactiveHttpGateway` acquires and releases runtime leases with `Mono.usingWhen`, applies Reactor Context operation metadata, and registers buffer discard hooks. - -- [ ] **Step 4: Run reactive tests with BlockHound enabled** - -```bash -./gradlew :modules:httpclient:httpclient-webclient:test \ - :modules:httpclient:httpclient-resilience:test \ - -Pblockhound.enabled=true -``` - -Expected: PASS with no blocking call detected on Reactor event-loop threads. - -- [ ] **Step 5: Commit** - -```bash -git add modules/httpclient/httpclient-webclient \ - modules/httpclient/httpclient-resilience \ - modules/httpclient/httpclient-auth -git commit -m "feat: add reactive http gateway and retries" -``` - ---- - -### Task 28: H1 Reactive Typed Service Client Registry 구현 - -**Files:** -- Create: `modules/httpclient/httpclient-service-client/src/main/java/io/backend/skeleton/httpclient/service/ReactiveHttpServiceRegistry.java` -- Create: `modules/httpclient/httpclient-service-client/src/main/java/io/backend/skeleton/httpclient/service/DefaultReactiveHttpServiceRegistry.java` -- Create: `modules/httpclient/httpclient-service-client/src/main/java/io/backend/skeleton/httpclient/service/ReactiveServiceInvocationHandler.java` -- Create: `modules/httpclient/httpclient-service-client/src/main/java/io/backend/skeleton/httpclient/service/ReactiveOperationContext.java` -- Modify: `modules/httpclient/httpclient-service-client/src/main/java/io/backend/skeleton/httpclient/service/ServiceOperationDescriptorScanner.java` -- Test: `modules/httpclient/httpclient-service-client/src/test/java/io/backend/skeleton/httpclient/service/ReactiveHttpServiceRegistryTest.java` -- Test: `modules/httpclient/httpclient-service-client/src/test/java/io/backend/skeleton/httpclient/service/BlockingReactiveSignatureSeparationTest.java` - -**Interfaces:** -- Produces typed proxies returning `Mono`, `Flux`, and SSE types. -- A service interface is classified as blocking or reactive at startup; mixed ambiguous signatures are rejected. - -- [ ] **Step 1: Write failing reactive proxy and mixed-signature tests** - -```java -@HttpClientProfile("events") -@HttpExchange("/events") -interface ReactiveEventsClient { - @GetExchange("/{id}") - @HttpOperationPolicy(name = "get-event", - idempotency = OperationIdempotency.STANDARD_IDEMPOTENT) - Mono get(@PathVariable String id); -} - -class ReactiveHttpServiceRegistryTest { - @Test - void propagatesOperationDescriptorThroughReactorContext() { - ReactiveHttpServiceRegistry registry = ReactiveServiceFixtures.registry(); - StepVerifier.create(registry.client( - new ClientProfileName("events"), ReactiveEventsClient.class).get("e1")) - .expectNextMatches(event -> event.id().equals("e1")) - .verifyComplete(); - assertThat(ReactiveServiceFixtures.lastOperationName()).isEqualTo("get-event"); - } -} -``` - -- [ ] **Step 2: Run reactive service client tests and verify failure** - -```bash -./gradlew :modules:httpclient:httpclient-service-client:test \ - --tests '*ReactiveHttpServiceRegistryTest' \ - --tests '*BlockingReactiveSignatureSeparationTest' -``` - -Expected: FAIL because reactive registry and handler are missing. - -- [ ] **Step 3: Implement Reactor Context wrapper proxy** - -```java -public final class ReactiveServiceInvocationHandler implements InvocationHandler { - @Override - public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { - ServiceOperationDescriptor descriptor = descriptors.require(method); - Object result = method.invoke(delegate, args); - if (result instanceof Mono mono) { - return mono.contextWrite(ctx -> ctx.put(ReactiveOperationContext.KEY, descriptor)); - } - if (result instanceof Flux flux) { - return flux.contextWrite(ctx -> ctx.put(ReactiveOperationContext.KEY, descriptor)); - } - throw new HttpConfigurationException("reactive service method must return Mono or Flux", metadata); - } -} -``` - -Reject a single interface that combines synchronous values with `Mono`/`Flux`, and reject `.block()` adapters in the generated registry. - -- [ ] **Step 4: Run service client tests with context-loss tracking** - -```bash -./gradlew :modules:httpclient:httpclient-service-client:test \ - -Dreactor.trace.operatorStacktrace=true -``` - -Expected: PASS; operation descriptor is visible at subscription time and absent from unrelated subscriptions. - -- [ ] **Step 5: Commit** - -```bash -git add modules/httpclient/httpclient-service-client -git commit -m "feat: add typed reactive http service clients" -``` - ---- - -### Task 29: Streaming Upload·Download Lifecycle과 First-byte Boundary 구현 - -**Files:** -- Create: `modules/httpclient/httpclient-restclient/src/main/java/io/backend/skeleton/httpclient/restclient/BlockingStreamingGateway.java` -- Create: `modules/httpclient/httpclient-restclient/src/main/java/io/backend/skeleton/httpclient/restclient/DefaultBlockingStreamingResponse.java` -- Create: `modules/httpclient/httpclient-restclient/src/main/java/io/backend/skeleton/httpclient/restclient/CountingBoundedInputStream.java` -- Create: `modules/httpclient/httpclient-webclient/src/main/java/io/backend/skeleton/httpclient/webclient/ReactiveStreamingGateway.java` -- Create: `modules/httpclient/httpclient-webclient/src/main/java/io/backend/skeleton/httpclient/webclient/FirstByteDeliveryGuard.java` -- Create: `modules/httpclient/httpclient-webclient/src/main/java/io/backend/skeleton/httpclient/webclient/BoundedDataBufferFlux.java` -- Create: `modules/httpclient/httpclient-webclient/src/main/java/io/backend/skeleton/httpclient/webclient/MultipartReplayability.java` -- Test: `modules/httpclient/httpclient-restclient/src/test/java/io/backend/skeleton/httpclient/restclient/BlockingStreamingLifecycleTest.java` -- Test: `modules/httpclient/httpclient-webclient/src/test/java/io/backend/skeleton/httpclient/webclient/ReactiveStreamingLifecycleTest.java` -- Test: `modules/httpclient/httpclient-webclient/src/test/java/io/backend/skeleton/httpclient/webclient/FirstByteRetryBoundaryTest.java` - -**Interfaces:** -- Blocking response implements `AutoCloseable` and owns the response body lifecycle. -- Reactive response emits bounded `DataBuffer` values and disables Retry after first `onNext`. - -- [ ] **Step 1: Write failing close, cancel, and first-byte tests** - -```java -class BlockingStreamingLifecycleTest { - @Test - void closeReturnsConnectionAfterPartialRead() throws Exception { - StreamingFixture fixture = StreamingFixture.apacheLargeBody(); - try (BlockingStreamingResponse response = fixture.gateway().download(fixture.operation())) { - assertThat(response.body().readNBytes(16)).hasSize(16); - } - await().atMost(Duration.ofSeconds(2)) - .untilAsserted(() -> assertThat(fixture.leasedConnections()).isZero()); - } -} - -class FirstByteRetryBoundaryTest { - @Test - void doesNotRetryAfterFirstBufferWasDelivered() { - ReactiveStreamingFixture fixture = ReactiveStreamingFixture.emitThenReset(); - StepVerifier.create(fixture.gateway().download(fixture.operation())) - .expectNextCount(1) - .expectError(HttpResponseTruncatedException.class) - .verify(); - assertThat(fixture.physicalRequestCount()).isEqualTo(1); - } -} -``` - -- [ ] **Step 2: Run streaming lifecycle tests and confirm failure** - -```bash -./gradlew :modules:httpclient:httpclient-restclient:test \ - --tests '*BlockingStreamingLifecycleTest' \ - :modules:httpclient:httpclient-webclient:test \ - --tests '*ReactiveStreamingLifecycleTest' \ - --tests '*FirstByteRetryBoundaryTest' -``` - -Expected: FAIL because streaming gateways and guards are missing. - -- [ ] **Step 3: Implement bounded lifecycle wrappers** - -```java -public final class DefaultBlockingStreamingResponse - implements BlockingStreamingResponse { - private final InputStream body; - private final Runnable closeAction; - private final AtomicBoolean closed = new AtomicBoolean(); - - @Override - public void close() { - if (closed.compareAndSet(false, true)) { - try { body.close(); } catch (IOException ignored) { } - closeAction.run(); - } - } -} -``` - -`CountingBoundedInputStream` throws `HttpResponseTooLargeException` when actual bytes exceed the profile limit and closes the underlying response. `FirstByteDeliveryGuard` atomically marks `firstByteDelivered` before forwarding the first buffer. `BoundedDataBufferFlux` releases the current and discarded buffers on error or cancellation. - -- [ ] **Step 4: Run streaming tests with leak detection** - -```bash -./gradlew :modules:httpclient:httpclient-restclient:test \ - :modules:httpclient:httpclient-webclient:test \ - -Dio.netty.leakDetection.level=paranoid -``` - -Expected: PASS with zero leaked connection and zero Netty leak report. - -- [ ] **Step 5: Commit** - -```bash -git add modules/httpclient/httpclient-restclient \ - modules/httpclient/httpclient-webclient -git commit -m "feat: enforce http streaming lifecycle" -``` - ---- - -### Task 30: SSE 연결·Idle Timeout·재연결 구현 - -**Files:** -- Create: `modules/httpclient/httpclient-webclient/src/main/java/io/backend/skeleton/httpclient/webclient/ReactiveSseGateway.java` -- Create: `modules/httpclient/httpclient-webclient/src/main/java/io/backend/skeleton/httpclient/webclient/DefaultReactiveSseGateway.java` -- Create: `modules/httpclient/httpclient-webclient/src/main/java/io/backend/skeleton/httpclient/webclient/SseOperation.java` -- Create: `modules/httpclient/httpclient-webclient/src/main/java/io/backend/skeleton/httpclient/webclient/SseReconnectPolicy.java` -- Create: `modules/httpclient/httpclient-webclient/src/main/java/io/backend/skeleton/httpclient/webclient/SseIdleTimeoutException.java` -- Test: `modules/httpclient/httpclient-webclient/src/test/java/io/backend/skeleton/httpclient/webclient/ReactiveSseGatewayTest.java` - -**Interfaces:** -- Produces `Flux> connect(...)`. -- Setup deadline, streaming idle timeout, max stream duration, and `Last-Event-ID` policy are separate. - -- [ ] **Step 1: Write failing event decode, idle, and reconnect tests** - -```java -class ReactiveSseGatewayTest { - @Test - void reconnectsWithLastEventIdWhenPolicyAllowsIt() { - SseFixture fixture = SseFixture.disconnectAfterEvent("event-1"); - StepVerifier.create(fixture.gateway().connect( - fixture.profile(), fixture.operationWithReconnect(), - ResponseType.of(EventPayload.class)).take(2)) - .expectNextMatches(event -> event.id().equals("event-1")) - .expectNextMatches(event -> event.id().equals("event-2")) - .verifyComplete(); - assertThat(fixture.secondRequestHeader("Last-Event-ID")) - .contains("event-1"); - } - - @Test - void closesSilentStreamAtStreamingIdleTimeout() { - SseFixture fixture = SseFixture.neverEmits(); - StepVerifier.withVirtualTime(() -> fixture.gateway().connect( - fixture.profile(), fixture.shortIdleOperation(), - ResponseType.of(EventPayload.class))) - .thenAwait(Duration.ofSeconds(5)) - .expectError(SseIdleTimeoutException.class) - .verify(); - } -} -``` - -- [ ] **Step 2: Run SSE tests and confirm failure** - -```bash -./gradlew :modules:httpclient:httpclient-webclient:test \ - --tests '*ReactiveSseGatewayTest' -``` - -Expected: FAIL because SSE contracts are missing. - -- [ ] **Step 3: Implement setup and stream-phase policies** - -```java -public final class DefaultReactiveSseGateway implements ReactiveSseGateway { - @Override - public Flux> connect(ClientProfileName profile, - SseOperation operation, - ResponseType eventType) { - return open(profile, operation, eventType, Optional.empty()) - .timeout(operation.streamingIdleTimeout(), - Flux.error(new SseIdleTimeoutException(operation.operationName()))) - .retryWhen(reconnectSpec(operation)); - } -} -``` - -`reconnectSpec` uses Retry Budget and only sets `Last-Event-ID` when the operation explicitly opts in. Application cancellation stops reconnect and closes the active connection. - -- [ ] **Step 4: Run SSE and cancellation tests** - -```bash -./gradlew :modules:httpclient:httpclient-webclient:test \ - --tests '*ReactiveSseGatewayTest' \ - -Dio.netty.leakDetection.level=paranoid -``` - -Expected: PASS; a cancelled subscription produces no later reconnect request. - -- [ ] **Step 5: Commit** - -```bash -git add modules/httpclient/httpclient-webclient -git commit -m "feat: add bounded reactive sse clients" -``` - ---- - -### Task 31: Proxy 지원과 HTTP/2 Protocol Evidence 구현 - -**Files:** -- Create: `modules/httpclient/httpclient-profile/src/main/java/io/backend/skeleton/httpclient/profile/ProxySettings.java` -- Create: `modules/httpclient/httpclient-auth/src/main/java/io/backend/skeleton/httpclient/auth/ProxyCredentialProvider.java` -- Create: `modules/httpclient/httpclient-resilience/src/main/java/io/backend/skeleton/httpclient/resilience/Http2ProtocolEvidence.java` -- Create: `modules/httpclient/httpclient-resilience/src/main/java/io/backend/skeleton/httpclient/resilience/Http2EvidenceMapper.java` -- Modify: `modules/httpclient/httpclient-transport-apache/src/main/java/io/backend/skeleton/httpclient/apache/ApacheClientFactory.java` -- Modify: `modules/httpclient/httpclient-transport-reactor-netty/src/main/java/io/backend/skeleton/httpclient/reactor/ReactorHttpClientFactory.java` -- Create: `modules/httpclient/httpclient-testkit/src/main/java/io/backend/skeleton/httpclient/testkit/Http2FailureFixture.java` -- Test: `modules/httpclient/httpclient-testkit/src/test/java/io/backend/skeleton/httpclient/testkit/ForwardProxyContractTest.java` -- Test: `modules/httpclient/httpclient-resilience/src/test/java/io/backend/skeleton/httpclient/resilience/Http2EvidenceMapperTest.java` - -**Interfaces:** -- Proxy connect failure remains distinct from target connect and TLS failure. -- `REFUSED_STREAM` and GOAWAY stream IDs can prove peer non-processing. - -- [ ] **Step 1: Write failing proxy isolation and H2 evidence tests** - -```java -class Http2EvidenceMapperTest { - @Test - void refusedStreamIsPeerNotProcessedEvidence() { - Http2ProtocolEvidence evidence = Http2ProtocolEvidence.refusedStream(7); - assertThat(new Http2EvidenceMapper().map(evidence)) - .isEqualTo(ProtocolEvidence.peerDidNotProcess("REFUSED_STREAM")); - } - - @Test - void streamAfterGoAwayLastIdIsPeerNotProcessed() { - Http2ProtocolEvidence evidence = Http2ProtocolEvidence.goAway(11, 15); - assertThat(new Http2EvidenceMapper().map(evidence).peerDidNotProcess()).isTrue(); - } -} -``` - -- [ ] **Step 2: Run proxy and HTTP/2 tests and verify failure** - -```bash -./gradlew :modules:httpclient:httpclient-testkit:test \ - --tests '*ForwardProxyContractTest' \ - :modules:httpclient:httpclient-resilience:test \ - --tests '*Http2EvidenceMapperTest' -``` - -Expected: FAIL because proxy settings and H2 evidence mapping are missing. - -- [ ] **Step 3: Implement explicit proxy and H2 mappings** - -```java -public record ProxySettings( - boolean enabled, - String host, - int port, - ProxyType type, - Optional credentialProvider, - Duration connectTimeout) { -} -``` - -Configure target and proxy credentials separately. Ignore ambient `NO_PROXY` in production unless explicitly imported into the validated profile. Map GOAWAY and REFUSED_STREAM only when the transport exposes reliable stream IDs; otherwise retain conservative evidence. - -- [ ] **Step 4: Run proxy, HTTP/2, Apache, and Reactor tests** - -```bash -./gradlew :modules:httpclient:httpclient-testkit:test \ - :modules:httpclient:httpclient-resilience:test \ - :modules:httpclient:httpclient-transport-apache:test \ - :modules:httpclient:httpclient-transport-reactor-netty:test -``` - -Expected: PASS; proxy authentication never appears in target requests or logs. - -- [ ] **Step 5: Commit** - -```bash -git add modules/httpclient/httpclient-profile \ - modules/httpclient/httpclient-auth \ - modules/httpclient/httpclient-resilience \ - modules/httpclient/httpclient-transport-apache \ - modules/httpclient/httpclient-transport-reactor-netty \ - modules/httpclient/httpclient-testkit -git commit -m "feat: add proxy and http2 failure semantics" -``` - ---- - -### Task 32: Spring Boot Starter·Properties·Actuator 구현 - -**Files:** -- Create: `modules/httpclient/httpclient-spring-boot-starter/src/main/java/io/backend/skeleton/httpclient/autoconfigure/HttpClientsProperties.java` -- Create: `modules/httpclient/httpclient-spring-boot-starter/src/main/java/io/backend/skeleton/httpclient/autoconfigure/HttpClientProfileAutoConfiguration.java` -- Create: `modules/httpclient/httpclient-spring-boot-starter/src/main/java/io/backend/skeleton/httpclient/autoconfigure/HttpClientTransportAutoConfiguration.java` -- Create: `modules/httpclient/httpclient-spring-boot-starter/src/main/java/io/backend/skeleton/httpclient/autoconfigure/HttpClientResilienceAutoConfiguration.java` -- Create: `modules/httpclient/httpclient-spring-boot-starter/src/main/java/io/backend/skeleton/httpclient/autoconfigure/HttpClientAuthenticationAutoConfiguration.java` -- Create: `modules/httpclient/httpclient-spring-boot-starter/src/main/java/io/backend/skeleton/httpclient/autoconfigure/HttpClientSecurityAutoConfiguration.java` -- Create: `modules/httpclient/httpclient-spring-boot-starter/src/main/java/io/backend/skeleton/httpclient/autoconfigure/HttpClientObservationAutoConfiguration.java` -- Create: `modules/httpclient/httpclient-spring-boot-starter/src/main/java/io/backend/skeleton/httpclient/autoconfigure/HttpServiceClientAutoConfiguration.java` -- Create: `modules/httpclient/httpclient-spring-boot-starter/src/main/java/io/backend/skeleton/httpclient/autoconfigure/DynamicTargetAutoConfiguration.java` -- Create: `modules/httpclient/httpclient-spring-boot-starter/src/main/java/io/backend/skeleton/httpclient/autoconfigure/HttpClientStartupValidator.java` -- Create: `modules/httpclient/httpclient-spring-boot-starter/src/main/java/io/backend/skeleton/httpclient/autoconfigure/HttpClientActuatorEndpoint.java` -- Create: `modules/httpclient/httpclient-spring-boot-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports` -- Test: `modules/httpclient/httpclient-spring-boot-starter/src/test/java/io/backend/skeleton/httpclient/autoconfigure/HttpClientAutoConfigurationTest.java` -- Test: `modules/httpclient/httpclient-spring-boot-starter/src/test/java/io/backend/skeleton/httpclient/autoconfigure/UnsafeStartupConfigurationTest.java` - -**Interfaces:** -- Binds `http-clients.*` properties into immutable profiles. -- Startup fails on all unsafe conditions listed in the design. - -- [ ] **Step 1: Write failing safe binding and unsafe startup tests** - -```java -class UnsafeStartupConfigurationTest { - private final ApplicationContextRunner runner = new ApplicationContextRunner() - .withConfiguration(AutoConfigurations.of(HttpClientProfileAutoConfiguration.class)); - - @Test - void productionTrustAllConfigurationFailsStartup() { - runner.withPropertyValues( - "spring.profiles.active=prod", - "http-clients.payment.base-url=https://payment.test", - "http-clients.payment.transport=APACHE", - "http-clients.payment.tls.trust-all=true") - .run(context -> assertThat(context).hasFailed()); - } - - @Test - void bindsNamedProfileAndCreatesTypedRegistry() { - runner.withPropertyValues(ProfileProperties.validPayment()) - .run(context -> { - assertThat(context).hasSingleBean(ClientRuntimeRegistry.class); - assertThat(context).hasSingleBean(HttpServiceRegistry.class); - }); - } -} -``` - -- [ ] **Step 2: Run starter tests and confirm failure** - -```bash -./gradlew :modules:httpclient:httpclient-spring-boot-starter:test -``` - -Expected: FAIL because property binding and auto-configuration are missing. - -- [ ] **Step 3: Implement typed properties and fail-fast startup** - -```java -@ConfigurationProperties("http-clients") -public record HttpClientsProperties(Map clients) { - public HttpClientsProperties { - clients = Map.copyOf(clients); - } -} -``` - -`HttpClientStartupValidator` aggregates profile, TLS, transport capability, duplicate operation, Dynamic credential, production Simple factory, Retry owner, and HTTP/3 Stable violations and throws one `HttpConfigurationException` with stable violation codes. Actuator exposes only name, generation, transport, protocol, pool state, circuit state, credential type, TLS profile ID, and reload outcome. - -- [ ] **Step 4: Run starter and complete module tests** - -```bash -./gradlew :modules:httpclient:httpclient-spring-boot-starter:test \ - :modules:httpclient:httpclient-service-client:test -``` - -Expected: PASS; `/actuator/httpclients` output contains no base URL, credential, trust path, resolved IP, or secret. - -- [ ] **Step 5: Commit** - -```bash -git add modules/httpclient/httpclient-spring-boot-starter -git commit -m "feat: add http client spring boot starter" -``` - ---- - -### Task 33: RestTemplate Migration 호환 계층 구현 - -**Files:** -- Create: `modules/httpclient/httpclient-resttemplate-migration/src/main/java/io/backend/skeleton/httpclient/migration/RestTemplateInventory.java` -- Create: `modules/httpclient/httpclient-resttemplate-migration/src/main/java/io/backend/skeleton/httpclient/migration/RestTemplateInventoryScanner.java` -- Create: `modules/httpclient/httpclient-resttemplate-migration/src/main/java/io/backend/skeleton/httpclient/migration/RestTemplateToRestClientAdapter.java` -- Create: `modules/httpclient/httpclient-resttemplate-migration/src/main/java/io/backend/skeleton/httpclient/migration/MigrationFinding.java` -- Create: `modules/httpclient/httpclient-resttemplate-migration/src/main/java/io/backend/skeleton/httpclient/migration/DeprecatedRestTemplateUsageArchRule.java` -- Test: `modules/httpclient/httpclient-resttemplate-migration/src/test/java/io/backend/skeleton/httpclient/migration/RestTemplateToRestClientAdapterTest.java` -- Test: `modules/httpclient/httpclient-resttemplate-migration/src/test/java/io/backend/skeleton/httpclient/migration/RestTemplateBoundaryTest.java` - -**Interfaces:** -- Converts existing converter, interceptor, request factory settings into a migration report and RestClient builder. -- Does not expose Dynamic Target, HTTP/3, or new resilience features through RestTemplate. - -- [ ] **Step 1: Write failing behavior parity and boundary tests** - -```java -class RestTemplateToRestClientAdapterTest { - @Test - void preservesExistingMessageConvertersAndInterceptors() { - RestTemplate template = RestTemplateFixtures.withJsonAndCorrelationInterceptor(); - RestClient client = new RestTemplateToRestClientAdapter().adapt(template); - assertThat(RestTemplateFixtures.exchangeWith(client)).isEqualTo("ok"); - assertThat(RestTemplateFixtures.recordedCorrelationHeader()).isPresent(); - } -} - -class RestTemplateBoundaryTest { - @Test - void productionModulesCannotDependOnMigrationModule() { - JavaClasses classes = new ClassFileImporter().importPackages("io.backend.skeleton"); - DeprecatedRestTemplateUsageArchRule.rule().check(classes); - } -} -``` - -- [ ] **Step 2: Run migration tests and confirm failure** - -```bash -./gradlew :modules:httpclient:httpclient-resttemplate-migration:test -``` - -Expected: FAIL because migration adapter and ArchUnit rule are missing. - -- [ ] **Step 3: Implement audit-first migration** - -```java -public final class RestTemplateToRestClientAdapter { - public RestClient adapt(RestTemplate template) { - return RestClient.builder(template) - .build(); - } -} -``` - -`RestTemplateInventoryScanner` reports request factory type, converters, interceptors, error handler, URI handler, and timeout gaps. The ArchUnit rule permits RestTemplate only inside the migration module and named legacy packages. - -- [ ] **Step 4: Run migration and architecture tests** - -```bash -./gradlew :modules:httpclient:httpclient-resttemplate-migration:test -``` - -Expected: PASS; no new production module references `RestTemplate`. - -- [ ] **Step 5: Commit** - -```bash -git add modules/httpclient/httpclient-resttemplate-migration -git commit -m "feat: add resttemplate migration path" -``` - ---- - -### Task 34: Spring 7 HTTP Service Group 선택 통합 구현 - -**Files:** -- Create: `modules/httpclient/httpclient-spring7-service-groups/src/main/java/io/backend/skeleton/httpclient/spring7/NamedHttpServiceGroupRegistrar.java` -- Create: `modules/httpclient/httpclient-spring7-service-groups/src/main/java/io/backend/skeleton/httpclient/spring7/HttpServiceGroupProfileResolver.java` -- Create: `modules/httpclient/httpclient-spring7-service-groups/src/main/java/io/backend/skeleton/httpclient/spring7/Spring7GroupCompatibility.java` -- Test: `modules/httpclient/httpclient-spring7-service-groups/src/test/java/io/backend/skeleton/httpclient/spring7/NamedHttpServiceGroupRegistrarTest.java` -- Create: `modules/httpclient/httpclient-spring7-service-groups/src/test/resources/application-groups.yml` - -**Interfaces:** -- Compiles only in the Spring 7 compatibility test suite. -- Reuses Named Client Profile and operation validation rather than creating a parallel configuration model. - -- [ ] **Step 1: Write a failing group-to-profile registration test** - -```java -class NamedHttpServiceGroupRegistrarTest { - @Test - void registersMultipleInterfacesAgainstOneNamedProfile() { - ApplicationContext context = Spring7GroupFixtures.start( - "catalog", CatalogClient.class, PriceClient.class); - assertThat(context.getBean(CatalogClient.class)).isNotNull(); - assertThat(context.getBean(PriceClient.class)).isNotNull(); - assertThat(Spring7GroupFixtures.profileFor(CatalogClient.class)).isEqualTo("catalog"); - assertThat(Spring7GroupFixtures.profileFor(PriceClient.class)).isEqualTo("catalog"); - } -} -``` - -- [ ] **Step 2: Run the Spring 7-only test and confirm failure** - -```bash -./gradlew :modules:httpclient:httpclient-spring7-service-groups:test \ - -PspringFrameworkLine=7.0 -``` - -Expected: FAIL because the group registrar is missing. - -- [ ] **Step 3: Implement the optional group adapter** - -```java -public final class HttpServiceGroupProfileResolver { - public ClientProfileName resolve(String groupName) { - return new ClientProfileName(groupName); - } -} -``` - -The registrar delegates interface validation to `ServiceOperationDescriptorScanner`, obtains the existing profile runtime, and configures the Spring 7 service group with the same RestClient/WebClient instance. It does not compile into the Spring 6.2 distribution. - -- [ ] **Step 4: Run Spring 6.2 common and Spring 7 group matrices** - -```bash -./gradlew spring62CompatibilityTest spring70CompatibilityTest \ - :modules:httpclient:httpclient-spring7-service-groups:test \ - -PspringFrameworkLine=7.0 -``` - -Expected: PASS; common artifacts remain free of Spring 7-only class references. - -- [ ] **Step 5: Commit** - -```bash -git add modules/httpclient/httpclient-spring7-service-groups -git commit -m "feat: integrate spring7 http service groups" -``` - ---- - -### Task 35: Jetty HTTP/3 Experimental Transport 구현 - -**Files:** -- Create: `modules/httpclient/httpclient-jetty-http3-experimental/src/main/java/io/backend/skeleton/httpclient/http3/JettyHttp3TransportProvider.java` -- Create: `modules/httpclient/httpclient-jetty-http3-experimental/src/main/java/io/backend/skeleton/httpclient/http3/Http3ExperimentalAcknowledgement.java` -- Create: `modules/httpclient/httpclient-jetty-http3-experimental/src/main/java/io/backend/skeleton/httpclient/http3/JettyHttp3FailureClassifier.java` -- Create: `modules/httpclient/httpclient-jetty-http3-experimental/src/main/java/io/backend/skeleton/httpclient/http3/Http3CapabilityReport.java` -- Test: `modules/httpclient/httpclient-jetty-http3-experimental/src/test/java/io/backend/skeleton/httpclient/http3/JettyHttp3TransportProviderTest.java` -- Test: `modules/httpclient/httpclient-jetty-http3-experimental/src/test/java/io/backend/skeleton/httpclient/http3/Http3OptInTest.java` - -**Interfaces:** -- Requires `experimental=true` and explicit acknowledgement string. -- Never auto-configured by the Stable starter. - -- [ ] **Step 1: Write failing opt-in and QUIC capability tests** - -```java -class Http3OptInTest { - @Test - void rejectsHttp3WithoutExplicitAcknowledgement() { - ClientProfile profile = ClientProfiles.http3WithoutAcknowledgement(); - assertThatThrownBy(() -> new JettyHttp3TransportProvider().create( - profile, NoopLifecycleListener.INSTANCE)) - .isInstanceOf(HttpConfigurationException.class) - .hasMessageContaining("experimental acknowledgement"); - } -} -``` - -- [ ] **Step 2: Run HTTP/3 tests and confirm failure** - -```bash -./gradlew :modules:httpclient:httpclient-jetty-http3-experimental:test -``` - -Expected: FAIL because the Experimental provider is missing. - -- [ ] **Step 3: Implement isolated Jetty HTTP/3 transport** - -```java -public record Http3ExperimentalAcknowledgement(String value) { - public static final String REQUIRED = "I_ACCEPT_HTTP3_EXPERIMENTAL_SEMANTICS"; - public Http3ExperimentalAcknowledgement { - if (!REQUIRED.equals(value)) { - throw new IllegalArgumentException("invalid HTTP/3 experimental acknowledgement"); - } - } -} -``` - -Create a Jetty QUIC transport with TLS 1.3, separate capability report, and failure classifier. Reuse stable result, error, deadline, retry, observation, and body lifecycle contracts. Keep Dynamic Target disabled in this module. - -- [ ] **Step 4: Run HTTP/3 tests in the dedicated environment** - -```bash -./gradlew :modules:httpclient:httpclient-jetty-http3-experimental:test \ - -Phttp3.tests.enabled=true -``` - -Expected: PASS when QUIC native support is present; otherwise the task fails with a clear missing-capability message rather than silently skipping release verification. - -- [ ] **Step 5: Commit** - -```bash -git add modules/httpclient/httpclient-jetty-http3-experimental -git commit -m "feat: add experimental jetty http3 transport" -``` - ---- - -### Task 36: 통합 장애·보안·관측 Contract Suite 구현 - -**Files:** -- Create: `modules/httpclient/httpclient-testkit/src/testFixtures/java/io/backend/skeleton/httpclient/testkit/BlockingTransportContract.java` -- Create: `modules/httpclient/httpclient-testkit/src/testFixtures/java/io/backend/skeleton/httpclient/testkit/ReactiveTransportContract.java` -- Create: `modules/httpclient/httpclient-testkit/src/testFixtures/java/io/backend/skeleton/httpclient/testkit/RetrySafetyContract.java` -- Create: `modules/httpclient/httpclient-testkit/src/testFixtures/java/io/backend/skeleton/httpclient/testkit/DynamicTargetSecurityContract.java` -- Create: `modules/httpclient/httpclient-testkit/src/testFixtures/java/io/backend/skeleton/httpclient/testkit/ObservabilityContract.java` -- Create: `modules/httpclient/httpclient-testkit/src/testFixtures/java/io/backend/skeleton/httpclient/testkit/ResourceLifecycleContract.java` -- Create: `modules/httpclient/httpclient-testkit/src/test/java/io/backend/skeleton/httpclient/testkit/AllStableTransportsContractTest.java` -- Create: `modules/httpclient/httpclient-testkit/src/test/java/io/backend/skeleton/httpclient/testkit/FailureInjectionContractTest.java` -- Create: `modules/httpclient/httpclient-testkit/src/test/java/io/backend/skeleton/httpclient/testkit/SecurityContractTest.java` - -**Interfaces:** -- Executes the same semantic contract against Apache, JDK, and Reactor. -- Jetty HTTP/3 uses the subset declared by `Http3CapabilityReport`. - -- [ ] **Step 1: Write a failing cross-transport contract runner** - -```java -class AllStableTransportsContractTest { - @ParameterizedTest - @MethodSource("stableTransports") - void notSentConnectFailureHasSameStableMetadata(HttpClientHarness harness) { - HttpClientException failure = catchThrowableOfType( - () -> harness.callBlackholedTarget(), HttpClientException.class); - assertThat(failure.metadata().stage()).isEqualTo(AttemptStage.CONNECT); - assertThat(failure.metadata().evidence()).isEqualTo(ExecutionEvidence.NOT_SENT); - assertThat(failure.getClass()).isEqualTo(HttpConnectException.class); - } -} -``` - -- [ ] **Step 2: Run the contract runner and inspect current differences** - -```bash -./gradlew :modules:httpclient:httpclient-testkit:test \ - --tests '*AllStableTransportsContractTest' \ - --tests '*FailureInjectionContractTest' \ - --tests '*SecurityContractTest' -``` - -Expected: FAIL until every transport produces the same stable metadata and security behavior. - -- [ ] **Step 3: Implement the complete matrix and fix each adapter to satisfy it** - -The contract suite must contain executable cases for: - -```text -all supported methods and URI encoding -pool, DNS, connect, TLS, proxy, header, body idle, total deadline -GET, PUT, POST with and without idempotency key -408, 425, 429, 500, 502, 503, 504, Retry-After -partial request write and partial response -body not consumed, close, decode error, cancellation -OAuth token cache, concurrent refresh, 401 replay, secret rotation -loopback, private, link-local, ULA, metadata, IDNA, DNS rebinding -public-to-private redirect and credential leakage -full URL metric cardinality and secret redaction -shutdown drain and retry suppression -``` - -Use Toxiproxy for TCP faults, WireMock for protocol status, TLS fixtures for certificate failures, and the HTTP/2 fixture for GOAWAY and REFUSED_STREAM. - -- [ ] **Step 4: Run the complete stable contract suite** - -```bash -./gradlew httpClientStableContractTest \ - -Dio.netty.leakDetection.level=paranoid \ - -Pblockhound.enabled=true -``` - -Expected: PASS for Apache, JDK, and Reactor with no leaked connection, buffer, thread, secret, or forbidden metric label. - -- [ ] **Step 5: Commit** - -```bash -git add modules/httpclient/httpclient-testkit \ - modules/httpclient/httpclient-transport-apache \ - modules/httpclient/httpclient-transport-jdk \ - modules/httpclient/httpclient-transport-reactor-netty \ - modules/httpclient/httpclient-restclient \ - modules/httpclient/httpclient-webclient -git commit -m "test: certify http client failure semantics" -``` - ---- - -### Task 37: 부하·Resource·Rotation 성능 인증 구현 - -**Files:** -- Create: `modules/httpclient/httpclient-testkit/src/jmh/java/io/backend/skeleton/httpclient/testkit/BlockingClientBenchmark.java` -- Create: `modules/httpclient/httpclient-testkit/src/jmh/java/io/backend/skeleton/httpclient/testkit/ReactiveClientBenchmark.java` -- Create: `modules/httpclient/httpclient-testkit/src/performanceTest/java/io/backend/skeleton/httpclient/testkit/PoolSaturationPerformanceTest.java` -- Create: `modules/httpclient/httpclient-testkit/src/performanceTest/java/io/backend/skeleton/httpclient/testkit/Http2StreamSaturationTest.java` -- Create: `modules/httpclient/httpclient-testkit/src/performanceTest/java/io/backend/skeleton/httpclient/testkit/LargeBodyResourceTest.java` -- Create: `modules/httpclient/httpclient-testkit/src/performanceTest/java/io/backend/skeleton/httpclient/testkit/RetryStormBudgetTest.java` -- Create: `modules/httpclient/httpclient-testkit/src/performanceTest/java/io/backend/skeleton/httpclient/testkit/OAuthRefreshContentionTest.java` -- Create: `modules/httpclient/httpclient-testkit/src/performanceTest/java/io/backend/skeleton/httpclient/testkit/RuntimeRotationDrainTest.java` -- Create: `docs/httpclient/performance-baseline.md` - -**Interfaces:** -- Produces reproducible performance evidence, not runtime adaptive defaults. -- Baseline records configuration, hardware, JVM, transport, protocol, payload, and concurrency. - -- [ ] **Step 1: Write failing hard resource-bound assertions** - -```java -class RetryStormBudgetTest { - @Test - void failedUpstreamCannotMultiplyPhysicalTrafficBeyondBudget() { - LoadResult result = LoadHarness.failedUpstream() - .logicalCalls(10_000) - .retryBudgetRatio(0.10) - .run(); - assertThat(result.physicalAttempts()).isLessThanOrEqualTo(11_000); - } -} - -class LargeBodyResourceTest { - @Test - void streamingDownloadDoesNotBufferWholePayloadOnHeap() { - ResourceSample sample = LoadHarness.download(512 * MEBIBYTE).streaming().run(); - assertThat(sample.peakHeapIncrease()).isLessThan(64 * MEBIBYTE); - } -} -``` - -- [ ] **Step 2: Run performance tests and capture the failing baseline** - -```bash -./gradlew httpClientPerformanceTest \ - -Pperformance.assertions.enabled=true -``` - -Expected: FAIL until pool, streaming, retry, and rotation resource bounds are enforced. - -- [ ] **Step 3: Tune only explicit profile settings and record the baseline** - -Set and record: - -```text -max connections -max pending acquires -attempt bulkhead -HTTP/2 stream concurrency -request and response size limits -total and stage timeouts -retry budget and max attempts -runtime drain timeout -``` - -Do not introduce hidden adaptive defaults. Update `performance-baseline.md` with command, commit, hardware, JVM flags, profile YAML, p50/p95/p99/max, heap, direct memory, threads, connections, attempts, and error count. - -- [ ] **Step 4: Run the performance certification suite** - -```bash -./gradlew httpClientPerformanceTest jmh \ - -Pperformance.assertions.enabled=true -``` - -Expected: PASS within the documented heap, direct memory, thread, connection, retry, and latency bounds. - -- [ ] **Step 5: Commit** - -```bash -git add modules/httpclient/httpclient-testkit docs/httpclient/performance-baseline.md -git commit -m "perf: certify http client resource bounds" -``` - ---- - -### Task 38: CI Matrix, Support Matrix, Runbook, Release Gate 완성 - -**Files:** -- Create: `.github/workflows/httpclient-contract.yml` -- Create: `.github/workflows/httpclient-nightly.yml` -- Create: `.github/workflows/httpclient-release.yml` -- Create: `docs/httpclient/support-matrix.md` -- Create: `docs/httpclient/configuration-reference.md` -- Create: `docs/httpclient/retry-and-ambiguity.md` -- Create: `docs/httpclient/security.md` -- Create: `docs/httpclient/streaming.md` -- Create: `docs/httpclient/operations.md` -- Create: `docs/httpclient/migration-guide.md` -- Create: `docs/httpclient/release-checklist.md` -- Create: `scripts/verify-httpclient-docs.py` -- Test: `modules/httpclient/httpclient-testkit/src/test/java/io/backend/skeleton/httpclient/testkit/PublicApiArchitectureTest.java` - -**Interfaces:** -- CI gates Spring 6.2·7.0, Apache, JDK, Reactor, HTTP/1.1·2, OAuth2, TLS, Dynamic Target, and fault injection. -- HTTP/3 is a separate Experimental nightly job. - -- [ ] **Step 1: Write failing public API and documentation verification tests** - -```java -class PublicApiArchitectureTest { - @Test - void publicApiDoesNotExposeNativeEnginesOrUnsafeBuilders() { - JavaClasses classes = new ClassFileImporter() - .importPackages("io.backend.skeleton.httpclient"); - noClasses().that().resideInAPackage("..api..") - .should().dependOnClassesThat() - .resideInAnyPackage( - "org.apache.hc..", "reactor.netty..", "org.eclipse.jetty..", - "java.net.http..", "io.github.resilience4j..") - .check(classes); - } -} -``` - -`verify-httpclient-docs.py` must fail when a Stable profile, exception, configuration property, metric, or support matrix row exists in code but not in documentation. - -- [ ] **Step 2: Run final verification before CI files are complete** - -```bash -./gradlew :modules:httpclient:httpclient-testkit:test \ - --tests '*PublicApiArchitectureTest' -python scripts/verify-httpclient-docs.py -``` - -Expected: FAIL because CI workflows and complete documentation are missing. - -- [ ] **Step 3: Add CI jobs and exact release commands** - -`httpclient-contract.yml` runs on every PR: - -```yaml -jobs: - stable-contract: - strategy: - matrix: - spring-line: ["6.2", "7.0"] - transport: [apache, jdk, reactor] - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-java@v4 - with: - distribution: temurin - java-version: "21" - - run: ./gradlew httpClientStableContractTest -PspringFrameworkLine=${{ matrix.spring-line }} -Phttpclient.contract.transport=${{ matrix.transport }} -``` - -Nightly runs Toxiproxy, mTLS rotation, HTTP/2 failure, performance smoke, and HTTP/3 Experimental. Release runs all tests, documentation verifier, support matrix verifier, and dependency report. - -- [ ] **Step 4: Run the complete release gate** - -```bash -./gradlew clean \ - test \ - spring62CompatibilityTest \ - spring70CompatibilityTest \ - httpClientStableContractTest \ - httpClientSecurityTest \ - httpClientFailureInjectionTest \ - httpClientPerformanceTest -python scripts/verify-httpclient-docs.py -``` - -Expected: PASS with zero failed test, zero documentation drift, zero forbidden dependency, and zero secret/cardinality violation. - -- [ ] **Step 5: Commit** - -```bash -git add .github/workflows docs/httpclient scripts/verify-httpclient-docs.py \ - modules/httpclient/httpclient-testkit -git commit -m "docs: finalize http client release gates" -``` - ---- - -## 3. Plan Self-Review Checklist - -Before execution begins, verify the plan against the design using the following checklist. - -- [ ] Every design decision D-01 through D-18 maps to at least one Task. -- [ ] H1, H2, H3, and H4 exposure rules are enforced by code or ArchUnit. -- [ ] Apache, JDK, Reactor, and Experimental Jetty modules have explicit capability matrices. -- [ ] `ExecutionEvidence`, `BodyReplayability`, and `OperationIdempotency` signatures are consistent across Tasks. -- [ ] Retry Eligibility is a pure decision and Retry Coordinator performs timing and attempts. -- [ ] Circuit → Rate Limiter → Bulkhead order is tested. -- [ ] total deadline includes Retry backoff and shutdown suppresses new retries. -- [ ] response body lifecycle is tested for success, partial read, error, size rejection, and cancel. -- [ ] OAuth2 single-flight and 401 maximum-one-replay rules are tested. -- [ ] TLS trust-all and hostname verification bypass are impossible to configure. -- [ ] Dynamic Target validates every resolved address and pins the actual connection. -- [ ] cross-origin redirect strips credentials. -- [ ] first-byte delivery disables transparent Retry. -- [ ] full URL and secret values cannot become low-cardinality tags. -- [ ] Spring 6.2 common and Spring 7 optional paths are separate. -- [ ] RestTemplate is limited to the migration module. -- [ ] HTTP/3 requires explicit Experimental acknowledgement. -- [ ] final CI executes contract, security, failure, compatibility, performance, and documentation gates. - -## 4. Execution Handoff - -Implementation must begin with Task 1 and proceed in order. The recommended execution mode is `superpowers:subagent-driven-development`: one fresh implementation agent per Task, followed by a requirements review and a code-quality review before the next Task begins. An inline execution session may instead use `superpowers:executing-plans`, but it must retain the same red-green-commit boundaries and release gates. diff --git a/httpclient-superpowers-package/docs/superpowers/specs/2026-08-08-httpclient-platform-design.md b/httpclient-superpowers-package/docs/superpowers/specs/2026-08-08-httpclient-platform-design.md deleted file mode 100644 index bd14ef4..0000000 --- a/httpclient-superpowers-package/docs/superpowers/specs/2026-08-08-httpclient-platform-design.md +++ /dev/null @@ -1,1956 +0,0 @@ -# HTTP Client Platform 설계서 - -**문서 상태:** 구현 기준선 확정 -**작성 기준일:** 2026-08-08 -**입력 근거:** `Java/Spring 외부 HTTP Client 플랫폼 설계 심층 리서치` -**대상 저장소:** Spring 기반 Backend Skeleton -**문서 목적:** 구현 중 추가 설계 판단이나 반복 질문 없이 공개 API, 전송 엔진, Named Client Profile, 시간 예산, 재시도 안전성, 복원력, 인증, 보안, 관측성, 테스트 및 릴리스 조건을 확정한다. - ---- - -## 1. 요약 - -이 설계는 `httpclient`를 `RestClient`나 `WebClient`를 한 번 감싼 편의 Wrapper가 아니라, 외부 HTTP 호출의 **대상, 연결 자원, 시간 예산, 실행 증거, 재시도 안전성, 인증, 보안, 관측성**을 하나의 계약으로 통제하는 공통 플랫폼으로 정의한다. - -일반 애플리케이션은 다음 네 단계 중 필요한 최소 단계만 사용한다. - -1. **H1 Typed Service Client** — `@HttpExchange` 기반 interface를 기본 진입점으로 사용한다. -2. **H2 Generic Exchange Gateway** — 등록된 Named Client Profile 안에서만 동적 method, path, header, body를 허용한다. -3. **H3 Dynamic Target Gateway** — 사용자 입력 URL이 필요한 기능을 별도 보안 경계와 SSRF 정책 아래에서 제공한다. -4. **H4 Native Engine SPI** — Apache, Reactor Netty, JDK, Jetty 고유 API는 플랫폼 내부 또는 Lab에만 공개한다. - -플랫폼의 핵심 판정은 단순한 `성공/예외`가 아니다. - -```text -요청이 실제 서버에 전달됐는가? -서버가 업무 처리를 완료했을 가능성이 있는가? -요청 Body를 동일한 의미로 다시 생성할 수 있는가? -호출이 표준 또는 계약상 멱등한가? -남은 deadline과 retry budget으로 다음 시도를 완료할 수 있는가? -``` - -이를 위해 모든 물리 시도는 다음 세 축을 보존한다. - -```text -ExecutionEvidence -├─ NOT_SENT -├─ SENT_NO_RESPONSE -├─ RESPONSE_RECEIVED -└─ PARTIAL_RESPONSE - -BodyReplayability -├─ REPLAYABLE -├─ REOPENABLE -├─ ONE_SHOT -└─ UNKNOWN - -OperationIdempotency -├─ STANDARD_IDEMPOTENT -├─ CONTRACT_IDEMPOTENT -├─ IDEMPOTENCY_KEY_REQUIRED -└─ NON_IDEMPOTENT -``` - -최종 구조는 다음과 같다. - -```text -Application - → Typed Service Client 또는 제한형 Gateway - → Named Client Profile - → Operation Policy Validation - → Effective Deadline - → Authentication Materialization - → Retry Coordinator - → Circuit Breaker - → Attempt Rate Limiter - → Attempt Bulkhead - → RestClient 또는 WebClient - → Apache / JDK / Reactor Netty / Jetty - → Execution Evidence Classification - → Stable Result 또는 Stable Exception -``` - ---- - -## 2. 목표와 성공 기준 - -### 2.1 목표 - -- 다양한 내부 서비스와 외부 SaaS API를 동일한 운영 기준으로 호출할 수 있게 한다. -- 일반 서비스 코드는 H1 Typed Client만으로 대부분의 호출을 구현하게 한다. -- upstream별 connection pool, timeout, 인증, retry, circuit, bulkhead를 서로 격리한다. -- 비멱등 요청의 중복 실행과 장애 시 retry 폭풍을 구조적으로 차단한다. -- Blocking과 Reactive 호출을 모두 지원하되 resource lifecycle과 cancellation 의미론을 구분한다. -- Dynamic URL 호출은 Trusted Client와 완전히 다른 보안 경계로 제공한다. -- Apache, Reactor Netty, JDK 전송 엔진이 동일한 오류·관측 semantic을 제공하게 한다. -- 구현자가 timeout, retry, redirect, 인증, TLS, SSRF, streaming 정책을 다시 판단하지 않게 한다. - -### 2.2 성공 기준 - -| 영역 | 완료 기준 | -|---|---| -| 공개 API | 일반 업무 모듈이 Native client를 직접 참조하지 않고 H1 Typed Client를 사용한다. | -| 설정 | 모든 호출 대상이 `clientName`으로 등록된 Named Client Profile을 가진다. | -| 시간 예산 | pool acquire부터 retry backoff까지 전체 호출이 effective deadline을 초과하지 않는다. | -| 실행 증거 | 실패 시 `NOT_SENT`, `SENT_NO_RESPONSE`, `RESPONSE_RECEIVED`, `PARTIAL_RESPONSE` 중 하나를 설명할 수 있다. | -| Retry | idempotency, body replayability, evidence, status, deadline, retry budget을 모두 통과한 시도만 재실행된다. | -| Resource | 성공, timeout, decode 실패, size 초과, cancellation에서 connection과 buffer가 회수된다. | -| 보안 | trust-all, hostname verification 해제, unrestricted Dynamic URL, credential redirect leakage가 차단된다. | -| 관측성 | 논리 호출과 물리 시도 수가 분리되고 전체 URL·사용자 ID·token이 metric label에 들어가지 않는다. | -| 호환성 | Spring Framework 6.2와 7.0 지원 범위가 CI 매트릭스로 검증된다. | -| 전송 엔진 | Apache·JDK blocking과 Reactor Netty reactive가 공통 계약 테스트를 통과한다. | -| Streaming | 첫 byte가 호출자에게 전달된 이후 투명 retry가 발생하지 않는다. | -| Dynamic Target | canonicalization, DNS/IP 검증, redirect 재검증, egress 정책이 함께 적용된다. | - ---- - -## 3. 입력 자료의 제약과 구현 가정 - -첨부 리서치는 설계 방향, 지원 범위, API 초안, 장애 의미론, 테스트 및 구현 순서를 충분히 제공하지만 실제 Backend Skeleton 저장소의 다음 정보는 포함하지 않는다. - -- root package -- Java toolchain -- Gradle 구조 -- Spring Boot BOM -- 기존 observability·security·resilience 공통 모듈 -- 배포 환경의 proxy, service mesh, egress 정책 - -따라서 이 문서는 실행 가능한 계획을 만들기 위해 다음 구현 기준을 사용한다. - -| 항목 | 구현 기준 | -|---|---| -| Java | Java 21 | -| 빌드 | Gradle Kotlin DSL 멀티모듈 | -| root package | `io.backend.skeleton.httpclient` | -| Spring 기준 | 공통 코드는 Spring Framework 6.2 API 기준으로 컴파일하고 7.0 호환 테스트를 수행한다. | -| Spring 7 전용 기능 | HTTP Service Group은 독립 선택 모듈로 분리한다. | -| Spring Boot | host 저장소의 dependency management를 사용하고 라이브러리가 Boot patch version을 직접 고정하지 않는다. | -| Reactive type | Reactor `Mono`, `Flux`는 reactive integration module에서만 공개한다. | -| Resilience | Resilience4j를 실행 primitive로 사용하되 HTTP retry 가능성 판정은 플랫폼이 소유한다. | -| 테스트 | JUnit 5, AssertJ, ArchUnit, MockWebServer, WireMock, Testcontainers, Toxiproxy, BlockHound | - -실제 저장소가 다른 package 또는 더 높은 Java 기준을 사용하면 경로와 toolchain만 조정한다. 본 문서의 공개 계약, 정책 순서, 오류 의미론은 변경하지 않는다. - ---- - -## 4. 범위 - -### 4.1 포함 범위 - -- Spring `RestClient` -- Spring `WebClient` -- `@HttpExchange` 기반 HTTP Service Client -- `RestTemplate` 마이그레이션 호환 계층 -- Apache HttpClient 5 blocking transport -- JDK HttpClient blocking transport -- Reactor Netty reactive transport -- Jetty HTTP/3 Experimental transport -- HTTP/1.1과 HTTP/2 Stable -- 동기 DTO·header·empty response -- Reactive `Mono`·`Flux` -- JSON, XML, text, bytes, form, multipart, octet-stream -- streaming upload·download -- SSE -- redirect, compression, conditional request, Range client semantics -- proxy와 HTTPS CONNECT -- connection pool과 lifecycle -- 단계별 timeout과 전체 deadline -- retry, retry budget, backoff, jitter, `Retry-After` -- Circuit Breaker, Bulkhead, Rate Limiter -- API key, Basic, Bearer, OAuth2 Client, mTLS, request signing SPI -- TLS 1.2·1.3, custom CA, certificate rotation -- Dynamic URL SSRF 방어 -- RFC 9457 problem response 변환 -- metric, trace, logging, audit -- 계약·장애·보안·성능 테스트 - -### 4.2 제외 또는 별도 모듈 - -- WebSocket -- gRPC -- GraphQL query·error·subscription 의미론 -- Fileserver의 저장·publish·Range 응답 생성 -- 브라우저 JavaScript HTTP Client -- API Gateway와 inbound routing -- 서비스 디스커버리와 client-side load balancing 구현 -- unrestricted Dynamic URL -- application-facing Native engine access -- 자동 공유 Cookie Jar -- TRACE -- 무제한 redirect -- one-shot request body의 자동 retry -- partial response가 호출자에게 전달된 뒤의 투명 retry -- HTTP/3 공통 Stable 보장 -- request hedging Stable 지원 -- transparent shared response cache -- trust-all, hostname verification 해제, 평문 fallback -- Simple request factory의 운영 사용 -- RestTemplate 신규 기능 - ---- - -## 5. 설계 결정 - -| ID | 결정 | 결과 | -|---|---|---| -| D-01 | 기본 진입점은 H1 Typed Service Client다. | 일반 업무 코드가 URL, timeout, auth, retry를 매번 조립하지 않는다. | -| D-02 | H2 Generic Gateway는 등록 profile의 base URL과 정책을 변경할 수 없다. | 범용 호출 기능은 제공하되 정책 우회를 막는다. | -| D-03 | H3 Dynamic Target Gateway는 별도 모듈·권한·설정으로 제공한다. | Trusted credential, Cookie, default header를 상속하지 않는다. | -| D-04 | H4 Native API는 플랫폼 내부 SPI다. | 애플리케이션이 engine 설정과 관측성을 우회하지 못한다. | -| D-05 | 설정 단위는 upstream별 Named Client Profile이다. | pool, timeout, auth, resilience, observability가 upstream마다 격리된다. | -| D-06 | Blocking 기본은 `RestClient + Apache HC5`, 경량 대안은 JDK HttpClient다. | 세밀한 운영 profile과 의존성 최소화 profile을 모두 제공한다. | -| D-07 | Reactive·Streaming 기본은 `WebClient + Reactor Netty`다. | backpressure, cancellation, SSE를 안정적으로 제공한다. | -| D-08 | Jetty와 HTTP/3는 Experimental로 격리한다. | Stable portability와 장애 의미론을 훼손하지 않는다. | -| D-09 | Retry 가능성은 HTTP method 하나로 결정하지 않는다. | idempotency, idempotency key, body replayability, evidence, deadline, budget을 함께 판정한다. | -| D-10 | 전체 deadline이 모든 timeout과 retry의 상위 예산이다. | 개별 attempt가 성공해도 전체 사용자 요청 시간을 초과하지 않는다. | -| D-11 | Retry Coordinator 바깥에서 logical admission을 적용하고, 각 물리 시도는 Circuit → Rate Limiter → Bulkhead를 통과한다. | backoff 중 permit을 점유하지 않고 실제 upstream 요청 수를 제한한다. | -| D-12 | 첫 response byte를 application에 전달한 뒤에는 transparent retry를 금지한다. | streaming 중복·순서 오류를 차단한다. | -| D-13 | OAuth2 token 획득은 Spring Security에 위임하되 cache key, refresh single-flight, 401 재호출 규칙은 플랫폼이 고정한다. | 인증 구현을 재작성하지 않으면서 동시 갱신과 중복 호출을 통제한다. | -| D-14 | TLS 오류 중 trust·hostname·expiry 오류는 영구 오류로 분류한다. | 인증서 오류를 retry하거나 평문으로 fallback하지 않는다. | -| D-15 | Dynamic Target Stable은 검증한 DNS 결과로 실제 연결을 pin할 수 있는 transport에서만 제공한다. | DNS rebinding과 검사-연결 간 TOCTOU를 줄인다. | -| D-16 | Spring 표준 `http.client.requests`는 물리 시도 metric으로 유지하고 logical call metric을 추가한다. | retry가 사용자 호출 성공률과 upstream 부하를 왜곡하지 않는다. | -| D-17 | Spring 6.2 공통 API를 기준으로 하고 Spring 7 전용 Service Group은 선택 모듈로 둔다. | 두 안정 계열을 지원하면서 공통 모듈의 분기를 줄인다. | -| D-18 | RestTemplate은 migration module에서만 허용한다. | 신규 코드가 deprecated API에 고착되지 않는다. | - ---- - -## 6. 지원 매트릭스 - -### 6.1 Spring API - -| API | 등급 | 역할 | 제약 | -|---|---:|---|---| -| `RestClient` | Stable | Blocking 요청 실행 | bounded concurrency와 deadline 필수 | -| `WebClient` | Stable | Reactive·Streaming·SSE | event-loop blocking 금지 | -| HTTP Service Client | 기본 | 선언형 Typed Client | operation metadata 등록 필수 | -| `RestTemplate` | Migration only | 기존 호출 이전 | 신규 profile·기능 금지 | -| Generic Exchange | 제한 | 동적 method·path·body | base URL과 정책 변경 금지 | -| Dynamic Target | 제한 | 사용자 URL | 별도 SSRF 정책과 credential 미상속 | -| Native Engine | Internal/Lab | 엔진 고유 기능 | application public API 금지 | - -### 6.2 전송 엔진 - -| 엔진 | Blocking | Reactive | HTTP/2 | HTTP/3 | Stable 역할 | -|---|---:|---:|---:|---:|---| -| Apache HttpClient 5 | 예 | 내부 async 가능 | 예 | 아니오 | Blocking 기본 | -| JDK HttpClient | 예 | `sendAsync` 가능 | 예 | 아니오 | 경량 Blocking 대안 | -| Reactor Netty | 제한 | 예 | 예 | Experimental | Reactive 기본 | -| Jetty HttpClient | sync facade | 예 | 예 | 예 | HTTP/3 Experimental | -| Simple factory | 예 | 아니오 | 제한 | 아니오 | local test only | - -### 6.3 HTTP 기능 - -| 기능 | Stable | 제약 | -|---|---:|---| -| GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS | 예 | operation idempotency 등록 | -| TRACE | 아니오 | startup과 runtime에서 차단 | -| custom method | 제한 | 사전 등록 descriptor 필요 | -| path·query template | 예 | 문자열 연결 금지, component encoding | -| absolute URI | H3만 | SSRF 정책 필수 | -| JSON, XML, text, bytes | 예 | codec와 크기 상한 | -| form, multipart | 예 | part 수·크기·replayability 계산 | -| InputStream request | 제한 | one-shot, 자동 retry 금지 | -| reopenable file request | 예 | 매 시도 새 stream 생성 | -| DTO response | 예 | decoded size 상한 | -| InputStream response | 제한 | `AutoCloseable` lifecycle | -| Reactive body | 예 | cancellation·buffer release | -| SSE | 예 | setup deadline과 idle timeout 분리 | -| redirect | 제한 | 기본 off, hop·origin 정책 | -| compression | 예 | wire·decoded size 모두 제한 | -| conditional request | 예 | validator 전달 | -| Range request | 예 | client 의미론만 제공 | -| trailer | Stable 제외 | engine-specific advanced API | -| `100-continue` | 선택 | 대용량 replayable body만 | -| HTTP/1.1 | 예 | fallback | -| HTTP/2 | 예 | stream concurrency 별도 제한 | -| HTTP/3 | Experimental | Jetty/Reactor 전용 | - ---- - -## 7. 전체 아키텍처 - -```mermaid -flowchart TB - APP[Application] - - subgraph PublicAPI[Public API] - H1[H1 Typed Service Client] - H2[H2 Generic Exchange] - H3[H3 Dynamic Target Gateway] - end - - subgraph Runtime[Runtime and Policy] - REG[Client Profile Registry] - META[Operation Descriptor Registry] - TARGET[Target Policy] - DEADLINE[Deadline Calculator] - AUTH[Authentication Provider] - RETRY[Retry Coordinator] - RES[Attempt Resilience] - ERROR[Error Mapper] - OBS[Observation] - end - - subgraph SpringClients[Spring Client Layer] - REST[RestClient] - WEB[WebClient] - end - - subgraph Transport[Transport Providers] - APACHE[Apache HC5] - JDK[JDK HttpClient] - REACTOR[Reactor Netty] - JETTY[Jetty Experimental] - end - - APP --> H1 - APP --> H2 - APP --> H3 - H1 --> REG - H2 --> REG - H3 --> REG - REG --> META - META --> TARGET - TARGET --> DEADLINE - DEADLINE --> AUTH - AUTH --> RETRY - RETRY --> RES - RES --> REST - RES --> WEB - REST --> APACHE - REST --> JDK - WEB --> REACTOR - WEB --> JETTY - REST --> ERROR - WEB --> ERROR - ERROR --> OBS -``` - -### 7.1 논리 호출 흐름 - -```text -1. profileName과 operationName을 해석한다. -2. profile과 operation descriptor를 immutable snapshot으로 가져온다. -3. method, URI template, body type, idempotency metadata를 검증한다. -4. Trusted 또는 Dynamic target policy를 적용한다. -5. parent deadline과 profile timeout에서 effective deadline을 계산한다. -6. credential을 materialize한다. -7. logical admission limit를 통과한다. -8. Retry Coordinator가 attempt 1을 생성한다. -9. attempt가 Circuit Breaker → Rate Limiter → Bulkhead를 통과한다. -10. RestClient 또는 WebClient가 물리 요청을 실행한다. -11. transport classifier가 stage와 execution evidence를 판정한다. -12. Retry Eligibility Engine이 다음 시도 여부를 결정한다. -13. 최종 결과를 `HttpCallResult` 또는 안정 예외로 반환한다. -14. 성공·실패·cancel 모두에서 response body와 connection을 정리한다. -``` - -### 7.2 Runtime 세대 교체 - -Named Client Profile은 mutable client를 직접 수정하지 않는다. - -```text -ClientRuntimeRegistry - payment → generation 17 - search → generation 4 -``` - -인증서, secret, base URL 또는 pool 설정이 변경되면 다음 순서로 교체한다. - -1. 새 immutable `ClientRuntime`을 생성한다. -2. startup validation과 선택적 connectivity probe를 수행한다. -3. registry pointer를 새 generation으로 atomic swap한다. -4. 신규 호출은 새 runtime을 사용한다. -5. 기존 runtime은 drain timeout 동안 진행 호출을 완료한다. -6. timeout 후 pool과 connection을 강제 종료한다. - -이 구조는 mTLS certificate와 OAuth client secret rotation을 connection pool lifecycle과 일치시킨다. - ---- - -## 8. 모듈 구조 - -```text -backend-skeleton/ -├── modules/httpclient/ -│ ├── httpclient-core-api/ -│ ├── httpclient-profile/ -│ ├── httpclient-transport-spi/ -│ ├── httpclient-transport-apache/ -│ ├── httpclient-transport-jdk/ -│ ├── httpclient-restclient/ -│ ├── httpclient-resilience/ -│ ├── httpclient-auth/ -│ ├── httpclient-security/ -│ ├── httpclient-observability/ -│ ├── httpclient-transport-reactor-netty/ -│ ├── httpclient-webclient/ -│ ├── httpclient-service-client/ -│ ├── httpclient-dynamic-target/ -│ ├── httpclient-resttemplate-migration/ -│ ├── httpclient-spring7-service-groups/ -│ ├── httpclient-jetty-http3-experimental/ -│ ├── httpclient-spring-boot-starter/ -│ └── httpclient-testkit/ -├── infra/httpclient/ -│ ├── proxy/ -│ ├── tls/ -│ ├── oauth2/ -│ └── toxiproxy/ -└── docs/httpclient/ -``` - -| 모듈 | 책임 | 의존 규칙 | -|---|---|---| -| `httpclient-core-api` | 안정 타입, result, evidence, body, 오류 | Spring·Apache·Netty·Resilience4j에 의존하지 않는다. | -| `httpclient-profile` | Named Client Profile, validation, runtime registry | core-api에만 공개적으로 의존한다. | -| `httpclient-transport-spi` | blocking·reactive transport provider와 classifier | Spring Web integration type은 이 SPI부터 허용한다. | -| `httpclient-transport-apache` | Apache HC5 request factory, pool, proxy, TLS hooks | native client를 외부에 반환하지 않는다. | -| `httpclient-transport-jdk` | JDK request factory와 제한 capability | fine-grained pool이 필요한 profile을 거부한다. | -| `httpclient-restclient` | Blocking Generic Gateway와 RestClient 실행 pipeline | Apache/JDK provider를 선택한다. | -| `httpclient-resilience` | deadline, retry, budget, circuit, rate, bulkhead | HTTP-specific retry 판정을 소유한다. | -| `httpclient-auth` | API key, Basic, Bearer, OAuth2, mTLS identity, signing SPI | token과 secret을 result·log에 노출하지 않는다. | -| `httpclient-security` | target, URI, redirect, header, body size, TLS 정책 | H1·H2·H3 모두 우회하지 못한다. | -| `httpclient-observability` | logical·attempt metric, trace, redaction | low-cardinality vocabulary를 소유한다. | -| `httpclient-transport-reactor-netty` | Reactor connector, pool, timeout, cancel | event-loop blocking을 허용하지 않는다. | -| `httpclient-webclient` | Reactive Generic Gateway, streaming, SSE | Reactor Context로 operation metadata를 전달한다. | -| `httpclient-service-client` | `@HttpExchange` proxy, profile·operation annotation | blocking·reactive proxy를 생성한다. | -| `httpclient-dynamic-target` | canonicalization, DNS/IP pinning, redirect revalidation | trusted credential을 의존하거나 상속하지 않는다. | -| `httpclient-resttemplate-migration` | 기존 RestTemplate 설정을 RestClient로 이전 | 신규 feature annotation을 제공하지 않는다. | -| `httpclient-spring7-service-groups` | Spring 7 HTTP Service Group 통합 | Spring 6.2 core에서 완전히 분리한다. | -| `httpclient-jetty-http3-experimental` | Jetty HTTP/3 connector와 capability matrix | Stable starter가 자동 활성화하지 않는다. | -| `httpclient-spring-boot-starter` | properties, auto-configuration, validation | production unsafe 설정에서 startup을 실패시킨다. | -| `httpclient-testkit` | mock·fault·TLS·H2·proxy·OAuth contract fixture | production module에서 의존하지 않는다. | - ---- - -## 9. 공개 API - -### 9.1 핵심 식별자 - -```java -public record ClientProfileName(String value) { - public ClientProfileName { - if (value == null || !value.matches("[a-z][a-z0-9-]{1,62}")) { - throw new IllegalArgumentException("invalid client profile name"); - } - } -} - -public record OperationName(String value) { - public OperationName { - if (value == null || !value.matches("[a-z][a-z0-9.-]{1,127}")) { - throw new IllegalArgumentException("invalid operation name"); - } - } -} -``` - -### 9.2 H1 Typed Service Client - -```java -public interface HttpServiceRegistry { - T client(ClientProfileName profileName, Class serviceType); -} - -@Target(ElementType.TYPE) -@Retention(RetentionPolicy.RUNTIME) -public @interface HttpClientProfile { - String value(); -} - -@Target(ElementType.METHOD) -@Retention(RetentionPolicy.RUNTIME) -public @interface HttpOperationPolicy { - String name(); - OperationIdempotency idempotency(); - String retryPolicy() default "none"; - String timeoutPolicy() default "default"; - boolean streaming() default false; -} -``` - -```java -@HttpClientProfile("payment") -@HttpExchange("/payments") -public interface PaymentClient { - - @PostExchange - @HttpOperationPolicy( - name = "create-payment", - idempotency = OperationIdempotency.IDEMPOTENCY_KEY_REQUIRED, - retryPolicy = "payment-write") - PaymentResponse create( - @RequestHeader("Idempotency-Key") String idempotencyKey, - @RequestBody PaymentRequest request); -} -``` - -Typed interface는 다음 조건을 만족해야 startup에 성공한다. - -- interface에 `@HttpClientProfile`이 존재한다. -- 모든 method에 안정적인 `operationName`이 존재한다. -- POST·PATCH는 idempotency를 명시한다. -- `IDEMPOTENCY_KEY_REQUIRED` method에는 등록된 key parameter가 존재한다. -- streaming method는 one-shot 여부가 드러나는 wrapper type을 사용한다. -- 반환형이 blocking인지 reactive인지 하나의 interface에서 모호하지 않다. - -### 9.3 H2 Generic Exchange - -```java -public interface GenericHttpGateway { - HttpCallResult exchange( - ClientProfileName profileName, - HttpOperation operation, - ResponseType responseType); -} - -public interface ReactiveHttpGateway { - Mono> exchange( - ClientProfileName profileName, - HttpOperation operation, - ResponseType responseType); -} -``` - -H2가 변경할 수 있는 것은 method, profile 내부 상대 path, query, 승인된 header와 body다. 다음은 변경할 수 없다. - -- scheme -- host -- port -- proxy -- TLS trust -- credential provider -- hard body limit -- redirect cross-origin 허용 -- metric naming - -### 9.4 H3 Dynamic Target - -```java -public interface DynamicTargetGateway { - HttpCallResult exchange( - DynamicTargetPolicyName policyName, - URI target, - HttpOperation operation, - ResponseType responseType); -} - -public interface ReactiveDynamicTargetGateway { - Mono> exchange( - DynamicTargetPolicyName policyName, - URI target, - HttpOperation operation, - ResponseType responseType); -} -``` - -H3는 profile의 API key, OAuth token, Cookie, custom default header를 상속하지 않는다. host별 credential이 필요하면 보안 관리자가 `DynamicCredentialBinding`을 별도로 등록한다. - -### 9.5 H4 Native SPI - -다음 형태의 application-facing API는 제공하지 않는다. - -```java -ApacheHttpClient nativeApacheClient(); -HttpClient nativeJdkClient(); -reactor.netty.http.client.HttpClient nativeReactorClient(); -WebClient.Builder mutableBuilder(); -RestClient.Builder mutableBuilder(); -``` - -Native 구성은 `TransportProvider` 구현 내부와 Experimental 모듈에서만 접근한다. - ---- - -## 10. Core 계약 - -### 10.1 Operation - -```java -public record HttpOperation( - OperationName operationName, - HttpMethod method, - String uriTemplate, - Map uriVariables, - Map> headers, - BodySource body, - OperationIdempotency idempotency, - Optional idempotencyKey, - Optional deadline) { -} -``` - -`HttpMethod`는 플랫폼 enum을 사용한다. TRACE는 enum에 포함하지 않고 custom method descriptor도 사전 등록해야 한다. - -### 10.2 BodySource - -```java -public sealed interface BodySource permits - EmptyBody, - ObjectBody, - ByteArrayBody, - ReopenableStreamBody, - OneShotStreamBody { - - BodyReplayability replayability(); - OptionalLong knownLength(); -} - -public record ReopenableStreamBody( - IOSupplier opener, - OptionalLong knownLength, - String mediaType) implements BodySource { - @Override public BodyReplayability replayability() { - return BodyReplayability.REOPENABLE; - } -} - -public record OneShotStreamBody( - InputStream stream, - OptionalLong knownLength, - String mediaType) implements BodySource { - @Override public BodyReplayability replayability() { - return BodyReplayability.ONE_SHOT; - } -} -``` - -Reactive body는 `httpclient-webclient`의 별도 타입을 사용한다. - -```java -public record ReactiveBodySource( - Supplier> publisherFactory, - BodyReplayability replayability, - OptionalLong knownLength, - MediaType mediaType) { -} -``` - -`Publisher` instance 자체를 받는 API는 one-shot으로 간주한다. retry 가능한 body는 매 시도 새 publisher를 생성하는 factory를 요구한다. - -### 10.3 ResponseType과 lifecycle - -```java -public sealed interface ResponseType permits - ClassResponseType, - GenericResponseType, - ByteArrayResponseType, - EmptyResponseType { -} - -public interface BlockingStreamingResponse extends AutoCloseable { - HttpStatus status(); - Map> headers(); - InputStream body(); - @Override void close(); -} -``` - -Streaming response는 반드시 `AutoCloseable`로 반환한다. `InputStream`만 단독 반환하지 않는다. - -### 10.4 Result - -```java -public record HttpCallResult( - HttpStatus status, - Map> headers, - T body, - int attempts, - Duration elapsed, - ExecutionEvidence evidence, - Optional remoteProblem) { -} -``` - -2xx 이외 status를 result로 반환할지 예외로 변환할지는 operation policy가 결정한다. 기본 Typed Client는 4xx·5xx를 안정 예외로 변환하고 Generic Gateway는 `StatusHandlingPolicy`를 명시할 수 있다. - ---- - -## 11. Named Client Profile - -### 11.1 구성 모델 - -```yaml -http-clients: - payment: - mode: TRUSTED - base-url: https://payment.example.com - allowed-hosts: [payment.example.com] - allowed-ports: [443] - api: REST_CLIENT - transport: APACHE - protocols: [HTTP_2, HTTP_1_1] - - pool: - max-total-connections: 100 - max-connections-per-route: 50 - max-pending-acquires: 200 - pending-acquire-timeout: 200ms - max-idle-time: 30s - max-life-time: 5m - validate-after-inactivity: 5s - eviction-interval: 15s - - timeout: - dns: 300ms - connect: 500ms - tls-handshake: 1s - proxy-connect: 500ms - request-write-idle: 1s - response-header: 2s - read-idle: 3s - total-call: 4s - streaming-idle: 30s - - redirect: - enabled: false - max-hops: 0 - allow-cross-origin: false - - request: - max-body-bytes: 1048576 - compression: false - - response: - max-wire-bytes: 5242880 - max-decoded-bytes: 10485760 - allowed-content-types: - - application/json - - application/problem+json - - authentication: - type: OAUTH2_CLIENT_CREDENTIALS - registration-id: payment - scopes: [payments.write] - audience: payment-api - - retry: - policy: payment-write - max-attempts: 2 - base-backoff: 50ms - max-backoff: 200ms - jitter: FULL - retry-after: HONOR - budget: payment - - circuit-breaker: payment - bulkhead: payment - rate-limiter: payment-attempts - - observability: - operation-name-required: true - full-url-recording: false - body-logging: false -``` - -위 숫자는 플랫폼 default가 아니라 `payment` profile의 명시적 예시다. production profile은 upstream SLO와 부하 계산 없이 숨은 기본값으로 생성되지 않는다. - -### 11.2 startup validation - -다음 조건은 startup 실패다. - -- Trusted profile에 base URL이 없다. -- `http` scheme이 production profile에서 사용된다. -- base URL에 userinfo 또는 query가 포함된다. -- allowed host와 base URL host가 다르다. -- redirect가 활성화됐는데 max hops가 0이거나 cross-origin credential 정책이 없다. -- total call timeout이 connect 또는 response header timeout보다 짧다. -- max decoded bytes가 global hard maximum을 초과한다. -- JDK transport에 세밀한 pending queue 또는 route pool 보장을 요구한다. -- HTTP/3를 Stable profile에서 요청한다. -- Dynamic mode에 default OAuth, API key, Cookie가 설정된다. -- trust-all, hostname verification off, plaintext fallback이 설정된다. -- production에서 Simple request factory가 선택된다. -- POST retry policy가 idempotency 조건 없이 활성화된다. - -### 11.3 Operation override - -operation은 profile 값을 더 위험한 방향으로 넓힐 수 없다. - -```text -허용: -- 더 짧은 total timeout -- 더 작은 response size -- retry 비활성화 -- stricter content type -- streaming idle timeout 지정 - -금지: -- 더 긴 timeout -- 더 큰 body limit -- 다른 host -- 다른 credential -- cross-origin redirect 활성화 -- non-idempotent retry 강제 -``` - ---- - -## 12. Target·URI·Header 정책 - -### 12.1 Trusted target - -Trusted profile은 startup에 다음을 검증한다. - -- URI strict parsing -- scheme, host, port -- IDNA canonical host -- userinfo 없음 -- path base normalization -- allowed host·port 일치 -- production TLS policy - -H2 호출자는 상대 URI template만 전달한다. `URI` absolute 값이 들어오면 거부한다. - -### 12.2 URI encoding - -- path와 query를 문자열로 연결하지 않는다. -- template variable은 component별로 encode한다. -- 이미 인코딩된 값과 raw 값을 동일 API에서 혼용하지 않는다. -- query value의 민감정보는 log와 trace에서 제거한다. -- 국제화 host는 Punycode canonical form으로 allowlist와 비교한다. -- IPv4-mapped IPv6를 원래 IPv4로 정규화한다. - -### 12.3 Header - -다음 header는 플랫폼이 소유한다. - -```text -Authorization -Proxy-Authorization -Host -Content-Length -Transfer-Encoding -Traceparent -Tracestate -Baggage -Cookie (profile opt-in일 때) -``` - -호출자가 임의로 덮어쓰지 못한다. `Idempotency-Key`는 operation descriptor가 요구할 때만 허용한다. header name·value에 CR 또는 LF가 있으면 요청 전 거부한다. - -### 12.4 Redirect - -기본값은 비활성이다. - -| 상태 | 기본 정책 | -|---|---| -| 301, 302, 303 | 자동 method 변환을 신뢰하지 않고 operation별로 명시한다. | -| 307, 308 | method와 body를 보존하므로 body replayable일 때만 허용한다. | -| same-origin | max hop과 method 정책 안에서 선택 허용한다. | -| cross-origin | 기본 거부한다. 허용 시 Authorization, Cookie, API key를 제거한다. | -| Dynamic Target | 각 hop을 새로운 target으로 canonicalize·resolve·IP 검증한다. | - ---- - -## 13. Transport SPI - -### 13.1 Blocking provider - -```java -public interface BlockingTransportProvider { - TransportId id(); - BlockingTransportCapabilities capabilities(); - ClientHttpRequestFactory create( - ClientProfile profile, - TransportLifecycleListener listener); - TransportFailureClassifier failureClassifier(); -} -``` - -### 13.2 Reactive provider - -```java -public interface ReactiveTransportProvider { - TransportId id(); - ReactiveTransportCapabilities capabilities(); - ClientHttpConnector create( - ClientProfile profile, - TransportLifecycleListener listener); - TransportFailureClassifier failureClassifier(); -} -``` - -### 13.3 공통 원칙 - -- provider는 native client를 반환하지 않는다. -- capability가 profile 요구사항보다 약하면 startup에 실패한다. -- transport exception은 public API에 직접 노출하지 않는다. -- transport가 `NOT_SENT`를 증명할 수 없으면 `SENT_NO_RESPONSE` 또는 보수적 unknown reason으로 분류한다. -- response body를 소비·close하지 않은 경우 connection 재사용 여부를 명시한다. -- client runtime 종료 시 신규 retry를 금지하고 진행 호출을 drain한다. - -### 13.4 Apache profile - -- 전체·route별 connection 제한 -- pending acquire timeout -- max idle, max lifetime -- validate after inactivity -- background eviction -- proxy와 CONNECT -- custom TLS strategy -- HTTP/1.1·2 -- blocking response lifecycle - -### 13.5 JDK profile - -- 의존성 최소화 profile -- HTTP/1.1·2 -- sync send 기반 -- 세밀한 pool queue·route limit을 요구하지 않는 경우만 사용 -- Dynamic Target Stable에서 제외 -- streaming body close·cancel contract 검증 - -### 13.6 Reactor Netty profile - -- provider를 upstream별로 분리한다. -- max connections, pending acquire, idle, lifetime, eviction을 설정한다. -- DNS, connect, TLS, proxy, response timeout을 stage별로 계측한다. -- event-loop에서 blocking codec·file I/O를 금지한다. -- cancellation에서 inbound buffer를 release하고 connection을 반환 또는 폐기한다. - -### 13.7 Jetty HTTP/3 - -- feature flag와 별도 module이 필요하다. -- Stable starter가 자동 구성하지 않는다. -- QUIC native dependency와 TLS 1.3을 요구한다. -- HTTP/3 failure를 공통 evidence로 변환하는 contract suite를 통과해야 Beta로 승격한다. - ---- - -## 14. Connection Pool과 동시성 - -### 14.1 pool과 bulkhead 분리 - -HTTP/1.1은 connection과 in-flight 요청 수가 가까울 수 있지만 HTTP/2는 하나의 connection에 여러 stream을 multiplex한다. 따라서 다음을 독립 설정으로 둔다. - -```text -connection pool limit -pending acquire queue limit -HTTP/2 stream capacity -logical admission limit -attempt bulkhead concurrency -``` - -### 14.2 pool 설정 - -| 설정 | 의미 | -|---|---| -| `maxTotalConnections` | runtime 전체 socket 상한 | -| `maxConnectionsPerRoute` | 한 upstream route 상한 | -| `maxPendingAcquires` | 대기 요청 메모리 상한 | -| `pendingAcquireTimeout` | pool·stream 대기 상한 | -| `maxIdleTime` | 유휴 연결 제거 | -| `maxLifeTime` | DNS·LB 변경과 인증서 rotation 반영 | -| `validateAfterInactivity` | stale·half-open 연결 검사 | -| `evictionInterval` | background cleanup | -| `shutdownTimeout` | drain 후 강제 종료 시각 | - -### 14.3 DNS와 기존 연결 - -DNS TTL만으로 pooled connection이 새 IP로 전환된다고 가정하지 않는다. `maxLifeTime`과 eviction을 함께 사용하고, DNS 변경 contract test에서 일정 시간 내 새 endpoint로 전환되는지 확인한다. - ---- - -## 15. Timeout과 Deadline - -### 15.1 단계별 timeout - -| 타입 | 시작과 종료 | -|---|---| -| DNS | hostname resolve 시작부터 결과 | -| Pool Acquire | queue 진입부터 connection 또는 stream 확보 | -| Connect | socket connect 시작부터 성공 | -| TLS Handshake | TCP 이후 TLS·ALPN 완료 | -| Proxy Connect | proxy socket 또는 CONNECT 완료 | -| Request Write Idle | request chunk 진행이 없는 시간 | -| Response Header | request 전송 후 final header 수신까지 | -| Read Idle | response chunk 사이 무진행 시간 | -| Total Call | 최초 논리 호출부터 모든 retry·backoff 종료까지 | -| Streaming Idle | 장기 stream event 사이 무진행 시간 | -| Shutdown | runtime drain 시작부터 강제 종료까지 | - -### 15.2 effective deadline - -```text -effectiveDeadline = min(parentDeadline, now + profile.totalCallTimeout) -remaining = effectiveDeadline - now - safetyMargin -attemptBudget = remaining - plannedBackoff - cleanupReserve -``` - -다음이면 새 attempt를 시작하지 않는다. - -- `remaining <= minimumAttemptBudget` -- 다음 backoff 이후 attempt budget이 없다. -- body가 replayable하지 않다. -- ambiguous execution이고 operation이 안전하지 않다. -- retry budget이 고갈됐다. -- circuit이 open이다. -- runtime이 draining 상태다. - -### 15.3 Streaming - -Streaming은 연결 설정 단계와 연결 유지 단계를 분리한다. - -```text -setupDeadline -→ response headers 수신 -→ streamingIdleTimeout -→ optional maxStreamDuration -``` - -일반 total timeout을 SSE 전체 수명에 적용하지 않는다. - ---- - -## 16. 실행 증거 - -### 16.1 public evidence - -| Evidence | 의미 | 예 | -|---|---|---| -| `NOT_SENT` | 서버에 요청이 전달되지 않았음을 증명 | profile 거부, pool timeout, DNS 실패, connect 실패, request 전 TLS 실패 | -| `SENT_NO_RESPONSE` | 일부 또는 전체 요청을 보냈으나 final header를 받지 못함 | partial write, response header timeout, reset | -| `RESPONSE_RECEIVED` | final HTTP header를 받음 | 2xx, 4xx, 5xx, redirect | -| `PARTIAL_RESPONSE` | header와 body 일부를 받음 | decode 중 reset, streaming 중단 | - -### 16.2 stage - -```java -public enum AttemptStage { - VALIDATION, - AUTHENTICATION, - POOL_ACQUIRE, - DNS, - CONNECT, - TLS_HANDSHAKE, - PROXY_CONNECT, - REQUEST_HEADERS, - REQUEST_BODY, - RESPONSE_HEADERS, - RESPONSE_BODY, - COMPLETE -} -``` - -### 16.3 보수적 분류 - -- `NOT_SENT`는 증명 가능한 stage 실패에서만 사용한다. -- engine generic I/O exception은 false `NOT_SENT`로 만들지 않는다. -- request body write가 시작됐으면 기본 `SENT_NO_RESPONSE`다. -- response header를 받았으면 status와 무관하게 `RESPONSE_RECEIVED`다. -- body 일부가 application에 전달됐으면 `PARTIAL_RESPONSE`다. -- HTTP/2 `REFUSED_STREAM`과 GOAWAY last-stream-id는 내부 protocol evidence로 보존하고 안전한 경우 `NOT_SENT`에 준해 retry한다. - ---- - -## 17. Retry - -### 17.1 판정 입력 - -```java -public record RetryContext( - OperationIdempotency idempotency, - Optional idempotencyKey, - BodyReplayability replayability, - ExecutionEvidence evidence, - FailureCategory failureCategory, - Optional responseStatus, - Optional retryAfter, - int attempt, - Duration remainingDeadline, - RetryBudgetSnapshot budget) { -} -``` - -### 17.2 판정 결과 - -```java -public sealed interface RetryDecision permits - RetryAllowed, - RetryDenied, - AmbiguousFailure { -} -``` - -### 17.3 기본 규칙 - -| 상황 | 기본 판정 | -|---|---| -| validation·auth configuration failure | retry 금지 | -| pool·DNS·connect failure | body 재생 가능하고 deadline·budget이 있으면 허용 | -| certificate·hostname failure | retry 금지 | -| request body 일부 송신 | standard 또는 contract idempotent가 아니면 ambiguous | -| response header timeout | read-only 또는 idempotency contract가 있을 때만 허용 | -| 408 | replayability·deadline 조건으로 제한 | -| 425 | early data 없이 한 번만 제한 retry | -| 429 | `Retry-After`, deadline, budget 내에서 허용 | -| 500 | 기본 금지, upstream policy가 transient로 등록한 경우만 | -| 502·503·504 | 안전한 operation에 제한 허용 | -| 401 | credential invalidation 후 최대 1회, 안전한 body와 operation만 | -| partial response | application 전달 전 read-only buffering에서만 제한 | -| one-shot body | retry 금지 | -| first byte delivered | retry 금지 | - -### 17.4 Retry budget - -upstream별 token bucket을 사용한다. - -```text -원 요청 성공·실패 수에 비례한 retry token 공급 -물리 retry마다 token 소비 -budget 고갈 시 즉시 최종 실패 -``` - -metric은 logical call 수와 physical attempt 수를 분리한다. - -### 17.5 Backoff - -- exponential backoff -- full 또는 decorrelated jitter -- max backoff -- `Retry-After` 상한 -- deadline보다 긴 대기 금지 -- backoff 중 bulkhead permit과 connection을 보유하지 않음 - ---- - -## 18. Resilience 실행 순서 - -```mermaid -flowchart LR - A[Operation Validation] --> B[Effective Deadline] - B --> C[Authentication] - C --> D[Logical Admission] - D --> E[Retry Coordinator] - E --> F{Circuit Open?} - F -- Yes --> X[Fail Fast] - F -- No --> G[Attempt Rate Limiter] - G --> H[Attempt Bulkhead] - H --> I[HTTP Attempt] - I --> J[Evidence Classification] - J --> K{Retry Safe?} - K -- Yes --> L[Backoff + Jitter] - L --> E - K -- No --> M[Result or Stable Error] -``` - -### 18.1 역할 - -| 기능 | 보호 대상 | -|---|---| -| Logical admission | retry coordinator와 대기 객체의 과도한 생성 | -| Circuit Breaker | 실패하거나 느린 upstream 호출 | -| Attempt Rate Limiter | 외부 API의 물리 요청 quota | -| Attempt Bulkhead | in-flight 물리 요청과 thread·stream capacity | -| Retry Budget | 장애 중 추가 요청 총량 | -| Total Deadline | 사용자 요청의 전체 시간 예산 | - -### 18.2 Blocking과 Reactive - -- Blocking Apache/JDK는 semaphore 또는 bounded executor bulkhead를 사용한다. -- Reactive는 event-loop를 thread-pool bulkhead로 감싸지 않고 semaphore concurrency를 사용한다. -- blocking token acquisition이나 secret load는 event-loop에서 실행하지 않는다. - ---- - -## 19. 오류 모델 - -```text -HttpClientException - ├─ HttpConfigurationException - ├─ HttpTargetRejectedException - ├─ HttpDnsException - ├─ HttpPoolAcquireTimeoutException - ├─ HttpConnectException - ├─ HttpProxyException - ├─ HttpTlsException - ├─ HttpRequestWriteException - ├─ HttpResponseTimeoutException - ├─ HttpResponseTruncatedException - ├─ HttpRemoteErrorException - ├─ HttpProblemDetailException - ├─ HttpRedirectRejectedException - ├─ HttpAuthenticationException - ├─ HttpSerializationException - ├─ HttpResponseTooLargeException - ├─ HttpDeadlineExceededException - ├─ HttpCircuitOpenException - ├─ HttpBulkheadRejectedException - ├─ HttpRateLimitRejectedException - └─ HttpAmbiguousExecutionException -``` - -### 19.1 공통 metadata - -```java -public record HttpFailureMetadata( - ClientProfileName clientName, - OperationName operationName, - HttpMethod method, - String uriTemplate, - ExecutionEvidence evidence, - BodyReplayability replayability, - AttemptStage stage, - boolean retryable, - int attempt, - Duration elapsed, - Duration remainingDeadline, - Optional status, - Optional traceId) { -} -``` - -다음은 예외 message나 public metadata에 포함하지 않는다. - -- 전체 URL -- query value -- 실제 path variable -- request·response body -- Authorization, Cookie, API key -- idempotency key 원문 -- client secret -- resolved IP의 metric label - -### 19.2 RFC 9457 - -`application/problem+json`은 다음 필드를 제한 크기로 보존한다. - -```text -type -title -status -detail -instance -등록된 extension allowlist -``` - -HTTP response status가 authoritative다. body의 `status`로 실제 status를 덮어쓰지 않는다. `detail`, `instance`, extension은 log에 기본 기록하지 않는다. - ---- - -## 20. 인증 - -### 20.1 지원 방식 - -| 방식 | 등급 | 정책 | -|---|---:|---| -| None | Stable | 명시 profile | -| Basic | 제한 | TLS 필수, secret provider | -| API Key Header | Stable | header name allowlist | -| API Key Query | 승인 필요 | provider 요구 시만 | -| Static Bearer | 제한 | 짧은 TTL과 rotation | -| OAuth2 Client Credentials | Stable | M2M 기본 | -| Authorization Code authorized client | 지원 | principal을 명시 전달 | -| token relay | 제한 | audience·scope 확인 | -| Token Exchange | 선택 | audience 축소·delegation | -| mTLS | Stable | TLS identity profile | -| Request Signing | SPI | provider별 module | -| Proxy Authentication | Stable | target auth와 분리 | - -### 20.2 credential provider - -```java -public interface RequestCredentialProvider { - CredentialType type(); - RequestCredentials resolve(CredentialRequest request); -} - -public interface ReactiveRequestCredentialProvider { - CredentialType type(); - Mono resolve(CredentialRequest request); -} -``` - -### 20.3 OAuth2 token cache - -cache key는 다음을 포함한다. - -```text -registrationId -principalClass -scopeSet -audience -tenantBoundary -mTLSCertificateIdentity -``` - -동일 key의 refresh는 single-flight로 수행한다. token endpoint는 target upstream과 별도 Named Client Profile을 사용한다. - -### 20.4 401 재호출 - -- token을 한 번 invalidate한다. -- refresh 후 최대 한 번만 재호출한다. -- body가 replayable해야 한다. -- operation이 read-only이거나 인증 실패가 side effect 전 반환된다는 계약이 있어야 한다. -- one-shot upload와 ambiguous write에는 적용하지 않는다. - ---- - -## 21. TLS와 인증서 rotation - -### 21.1 허용 - -- TLS 1.2·1.3 -- hostname verification -- JVM trust store -- profile별 custom CA -- profile별 client certificate -- mTLS -- SNI와 ALPN -- 새 runtime generation으로 certificate rotation - -### 21.2 금지 - -- trust-all TrustManager -- hostname verification 비활성화 -- 인증서 오류 무시 -- production self-signed 자동 신뢰 -- HTTPS 실패 후 HTTP fallback -- key material의 config file·log 기록 - -### 21.3 오류 분류 - -| 오류 | retry | -|---|---:| -| unknown CA | 금지 | -| hostname mismatch | 금지 | -| expired certificate | 금지 | -| revoked certificate | 금지 | -| protocol mismatch | profile 오류로 금지 | -| transient handshake timeout | deadline과 policy 안에서 제한 | -| client certificate 없음 | 금지 | - ---- - -## 22. Dynamic Target와 SSRF - -### 22.1 처리 순서 - -```text -1. URI strict parse -2. scheme allowlist -3. userinfo·invalid port 거부 -4. host IDNA canonicalization -5. host allowlist 또는 suffix policy -6. 모든 A·AAAA resolve -7. 각 주소를 canonical IP로 정규화 -8. loopback, link-local, private, ULA, metadata 대역 검사 -9. 검증한 주소로 실제 connection pinning -10. response size·content policy 적용 -11. redirect마다 1~10을 반복 -``` - -### 22.2 기본 금지 주소 - -- IPv4·IPv6 loopback -- link-local -- RFC1918 private address -- IPv6 ULA -- unspecified·multicast -- IPv4-mapped IPv6의 차단 대상 -- cloud metadata endpoint -- 조직이 정의한 internal CIDR - -### 22.3 transport 제한 - -Dynamic Target Stable은 validated resolver 또는 validated address pinning을 제공하는 Apache와 Reactor Netty에서 먼저 지원한다. JDK와 Jetty는 동일 보장을 contract test로 증명하기 전까지 H3에서 사용할 수 없다. - -### 22.4 redirect credential - -origin이 변경되면 다음을 제거한다. - -```text -Authorization -Proxy-Authorization -Cookie -API key header -custom sensitive header -``` - -Dynamic profile에는 Cookie Jar를 기본 생성하지 않는다. - -### 22.5 네트워크 계층 - -애플리케이션 검증만으로 충분하다고 간주하지 않는다. Kubernetes NetworkPolicy, service mesh egress, firewall, proxy ACL 중 하나 이상의 네트워크 제어를 운영 완료 조건으로 요구한다. - ---- - -## 23. Streaming과 대용량 Body - -### 23.1 request replayability - -| Body | Replayability | -|---|---| -| immutable `byte[]` | REPLAYABLE | -| DTO + deterministic codec | REPLAYABLE | -| reopenable file/resource supplier | REOPENABLE | -| one `InputStream` instance | ONE_SHOT | -| publisher factory | 선언값에 따름 | -| publisher instance | ONE_SHOT | -| multipart | 가장 약한 part와 동일 | - -### 23.2 response lifecycle - -- blocking stream은 `AutoCloseable` response wrapper로 반환한다. -- reactive body는 consume, cancel, error에서 buffer를 release한다. -- content length를 신뢰하지 않고 실제 wire bytes와 decoded bytes를 측정한다. -- gzip·deflate 응답은 압축 전후 상한을 각각 적용한다. -- decode error와 size 초과에서도 connection을 회수하거나 명시적으로 폐기한다. - -### 23.3 first-byte boundary - -```text -response header 수신 -→ 내부 buffer에 아직 byte 미전달 - → read-only operation은 제한 retry 가능 -→ application InputStream read 또는 Flux onNext 발생 - → transparent retry 영구 금지 -``` - -### 23.4 SSE - -```java -public interface ReactiveSseGateway { - Flux> connect( - ClientProfileName profileName, - SseOperation operation, - ResponseType eventType); -} -``` - -- setup deadline -- streaming idle timeout -- `Last-Event-ID` 재연결은 operation opt-in -- reconnect에도 retry budget 적용 -- application cancel 시 connection close - ---- - -## 24. HTTP protocol 세부 정책 - -### 24.1 HTTP/2 - -- connection 수와 stream concurrency를 분리한다. -- max concurrent streams를 metric으로 노출한다. -- `REFUSED_STREAM`은 peer 미처리 증거로 제한 retry할 수 있다. -- GOAWAY의 last stream ID 이후 요청만 peer 미처리로 분류한다. -- stream reset 원인을 stable failure category로 변환한다. -- connection coalescing은 host·certificate·security policy를 검증한 profile에서만 허용한다. - -### 24.2 HTTP/3 - -- TLS 1.3 필수 -- UDP·QUIC 네트워크 경로 테스트 -- proxy·egress 지원 별도 매트릭스 -- Stable H1/H2 API의 result·error semantic을 재사용 -- 별도 `experimental=true`와 startup acknowledgment 요구 - -### 24.3 Proxy - -- target auth와 proxy auth를 분리한다. -- proxy connect timeout을 별도 metric으로 기록한다. -- HTTPS CONNECT 실패를 target TLS 실패로 오분류하지 않는다. -- `NO_PROXY` 환경변수가 production allowlist를 우회하지 못하게 한다. -- service mesh retry가 활성화되면 application retry owner 검사를 수행한다. - ---- - -## 25. 관측성 - -### 25.1 metric - -| 이름 | 의미 | -|---|---| -| `http.client.requests` | 물리 attempt timer | -| `http.client.logical.calls` | 사용자 논리 호출 timer | -| `http.client.attempts` | attempt counter | -| `http.client.retry.count` | retry 이유별 수 | -| `http.client.retry.exhausted` | retry 소진 | -| `http.client.ambiguous` | 결과 모호성 | -| `http.client.timeout` | timeout stage | -| `http.client.request.bytes` | request wire bytes | -| `http.client.response.bytes` | response wire·decoded bytes | -| `http.client.active` | 진행 중 attempt | -| `http.client.pool.connections` | active·idle connection | -| `http.client.pool.pending` | pool 대기 | -| `http.client.pool.acquire.duration` | pool 대기 시간 | -| `http.client.dns.duration` | DNS 시간 | -| `http.client.connect.duration` | connect 시간 | -| `http.client.tls.duration` | TLS 시간 | -| `http.client.circuit.state` | circuit 상태 | -| `http.client.bulkhead.rejected` | bulkhead 거절 | -| `http.client.rate_limit.rejected` | local rate 거절 | -| `http.client.oauth.refresh` | token refresh 결과 | -| `http.client.ssrf.rejected` | dynamic target 거절 | - -### 25.2 low-cardinality tag - -허용: - -```text -clientName -operationName -method -uriTemplate -status -outcome -transport -protocol -timeoutType -retryReason -evidence -circuitState -``` - -금지: - -```text -full URL -query parameter -path variable value -user ID -tenant ID 원문 -resolved IP -API key -token -Cookie -idempotency key -request·response body -exception message -``` - -### 25.3 trace - -```text -http.client.operation logical internal span -└─ http.client.request attempt 1 CLIENT span -└─ http.client.request attempt 2 CLIENT span -``` - -- W3C Trace Context -- Baggage allowlist -- Dynamic target는 기본 trace propagation off -- retry reason과 evidence를 span event로 기록 -- credential과 remote error body는 attribute에 기록하지 않음 - -### 25.4 logging - -- 시도마다 WARN을 남기지 않는다. -- 최종 실패 한 번을 구조화 로그로 남긴다. -- retry attempt는 DEBUG 또는 trace event다. -- URL은 template과 profile name만 남긴다. -- body logging은 production에서 off다. -- header는 이름 allowlist, 값은 redaction policy를 적용한다. - ---- - -## 26. Spring 통합 - -### 26.1 RestClient - -- profile마다 immutable RestClient를 생성한다. -- Apache 또는 JDK request factory를 선택한다. -- default header는 credential과 trace보다 먼저 고정하지 않는다. -- request interceptor는 operation context와 attempt context를 읽는다. -- response extractor는 body lifecycle과 size를 통제한다. - -### 26.2 WebClient - -- profile마다 immutable WebClient를 생성한다. -- Reactor Netty provider를 upstream별로 분리한다. -- filter chain은 context에서 operation metadata를 가져온다. -- body cancel·discard hook을 등록한다. -- `.block()`을 public API 내부에서 호출하지 않는다. - -### 26.3 HTTP Service Client - -`HttpServiceRegistry`는 다음 작업을 수행한다. - -1. interface annotation scan -2. method descriptor 생성 -3. signature validation -4. RestClient 또는 WebClient proxy 생성 -5. operation context wrapper proxy 생성 -6. blocking은 try/finally로 context 제거 -7. reactive는 Reactor Context에 descriptor 주입 - -### 26.4 Spring 7 Service Group - -Spring 7 전용 모듈은 여러 service interface가 같은 profile을 공유하도록 group integration을 제공한다. 공통 계약과 profile validation은 그대로 재사용한다. - -### 26.5 RestTemplate migration - -Migration module은 다음만 제공한다. - -- 기존 request factory와 message converter를 조사하는 audit 도구 -- RestTemplate에서 RestClient builder로 이전하는 adapter -- deprecated usage report -- 동일 동작 contract test - -신규 retry, Dynamic URL, HTTP/3 기능은 RestTemplate 경로에 추가하지 않는다. - ---- - -## 27. Spring Boot Starter - -### 27.1 auto-configuration - -```text -HttpClientProfileAutoConfiguration -HttpClientTransportAutoConfiguration -HttpClientResilienceAutoConfiguration -HttpClientAuthenticationAutoConfiguration -HttpClientSecurityAutoConfiguration -HttpClientObservationAutoConfiguration -HttpServiceClientAutoConfiguration -DynamicTargetAutoConfiguration -``` - -### 27.2 startup guard - -- production unsafe TLS 설정 탐지 -- Simple factory 차단 -- profile capability mismatch -- duplicate client name -- operation name duplicate -- H1 interface annotation 누락 -- POST/PATCH idempotency 누락 -- Dynamic credential 상속 -- unsupported HTTP/3 Stable 설정 -- response size hard max 위반 -- retry owner 중복 선언 - -### 27.3 actuator - -관리 endpoint는 값 원문을 숨기고 다음만 제공한다. - -```text -profile name -runtime generation -transport -protocol -pool state -circuit state -credential type -TLS profile ID -last reload outcome -capability warnings -``` - -base URL 전체, credential, trust store path, resolved IP는 공개하지 않는다. - ---- - -## 28. 테스트 전략 - -### 28.1 test topology - -| 도구 | 용도 | -|---|---| -| MockWebServer | deterministic request·response contract | -| WireMock | stateful status, redirect, OAuth fixture | -| Toxiproxy | latency, reset, bandwidth, half-open | -| TLS test server | CA, hostname, expiry, mTLS | -| HTTP/2 server | GOAWAY, REFUSED_STREAM, reset | -| Forward proxy | CONNECT, auth, target failure | -| OAuth2 server | token expiry, refresh race, rotation | -| Testcontainers | isolated proxy·server runtime | -| BlockHound | event-loop blocking 검출 | -| ArchUnit | module·native type 경계 | - -### 28.2 계약 테스트 - -- method와 URI encoding -- header ownership과 CRLF 차단 -- JSON·XML·form·multipart -- empty, generic, streaming response -- redirect 301·302·303·307·308 -- compression과 decoded size -- conditional request와 Range -- HTTP/1.1·2 -- Apache·JDK·Reactor 공통 result·error semantic - -### 28.3 timeout·failure - -- DNS timeout -- pool saturation -- connect refused·blackhole -- TLS timeout·trust·hostname -- slow request receiver -- response header delay -- body idle -- total deadline -- streaming idle -- shutdown 중 신규 retry 금지 - -### 28.4 retry·resilience - -- GET connect failure -- PUT body replay -- POST idempotency key 있음·없음 -- partial write -- 408, 425, 429, 500, 502, 503, 504 -- Retry-After -- budget exhaustion -- circuit half-open -- rate limiter와 retry attempt 수 -- bulkhead permit 반환 -- service mesh 중복 retry configuration guard - -### 28.5 security - -- loopback, private, link-local, ULA, metadata -- IPv4-mapped IPv6 -- IDNA host -- DNS rebinding -- public→private redirect -- Authorization·Cookie leakage -- trust-all bean startup failure -- hostname mismatch -- mTLS certificate 없음·rotation -- CRLF header -- compressed bomb -- JSON nesting·XML entity - -### 28.6 streaming lifecycle - -- response 미소비 -- partial read 후 close -- decode failure -- size limit -- reactive cancel -- DataBuffer release -- slow subscriber backpressure -- first byte 이후 retry 없음 -- SSE idle와 Last-Event-ID reconnect -- event-loop blocking 없음 - -### 28.7 observability - -- logical call 1, attempt N -- retry reason -- evidence -- URI template cardinality -- 전체 URL label 없음 -- token·API key 마스킹 -- dynamic target trace propagation off - -### 28.8 성능 - -- pool·bulkhead saturation -- HTTP/2 stream saturation -- 대용량 upload·download -- gzip decoded size -- concurrent OAuth refresh -- runtime generation rotation -- shutdown drain -- heap·direct memory·thread 상한 - ---- - -## 29. 호환성 인증 매트릭스 - -| 프로파일 | CI 빈도 | 릴리스 Gate | -|---|---|---| -| Spring Framework 6.2 latest patch | 모든 PR·release | 필수 | -| Spring Framework 7.0 latest patch | release | 필수 | -| Apache HC5 + RestClient | 모든 PR | 필수 | -| JDK HttpClient + RestClient | 모든 PR | 필수 | -| Reactor Netty + WebClient | 모든 PR | 필수 | -| Jetty HTTP/3 | nightly | Experimental 비차단 | -| HTTP/1.1 | 모든 PR | 필수 | -| HTTP/2 | release | 필수 | -| Forward proxy | release | 지원 선언 시 필수 | -| OAuth2 Client Credentials | 모든 PR | 필수 | -| mTLS | release | 지원 선언 시 필수 | -| Dynamic Target Apache | security suite | 필수 | -| Dynamic Target Reactor | security suite | 필수 | -| Toxiproxy failure suite | nightly·release | 필수 | - ---- - -## 30. 운영 설정과 기본 정책 - -### 30.1 숨은 운영 기본값 금지 - -production Named Client Profile은 다음을 명시해야 한다. - -```text -base URL -transport -total timeout -response header timeout -pool 또는 concurrency limit -request·response body hard limit -authentication type -retry policy 또는 none -redirect policy -TLS profile -``` - -미설정 시 매우 큰 framework default로 조용히 동작하지 않고 startup에 실패한다. - -### 30.2 retry owner - -application HTTP Client, 외부 SDK, service mesh 중 하나만 retry owner가 된다. starter는 known mesh annotation 또는 설정을 읽어 중복 retry를 경고하거나 strict mode에서 실패시킨다. - -### 30.3 shutdown - -```text -runtime state RUNNING → DRAINING -신규 logical call 거부 또는 새 generation으로 routing -진행 attempt 완료 -신규 retry 금지 -shutdown timeout -남은 call cancel -pool close -``` - ---- - -## 31. 비지원 범위의 runtime 강제 - -문서만으로 금지하지 않고 다음 guard를 코드로 둔다. - -| 비지원 | 강제 방식 | -|---|---| -| TRACE | method registry에서 부재·runtime reject | -| unrestricted absolute URL | H2 parser에서 reject | -| trust-all | bean·SSLContext validator startup fail | -| hostname verification off | transport capability validator fail | -| production Simple factory | environment guard fail | -| one-shot retry | Retry Eligibility Engine deny | -| partial stream retry | first-byte marker deny | -| full URL metric | observation convention test | -| Dynamic credential inheritance | configuration validator fail | -| RestTemplate 신규 기능 | module dependency·ArchUnit rule | -| native client exposure | public API signature ArchUnit rule | -| HTTP/3 Stable | profile validator fail | - ---- - -## 32. 릴리스 단계 - -### 32.1 Core Alpha - -- core types -- Named Client Profile -- stable exceptions -- transport SPI -- testkit -- deadline model -- target·header·body guard - -완료 조건: core module의 public API와 configuration validation contract가 통과한다. - -### 32.2 Blocking Beta - -- Apache HC5 -- JDK HttpClient -- RestClient Generic Gateway -- H1 blocking Typed Client -- connection pool·timeout -- error mapping - -완료 조건: Apache와 JDK가 공통 Blocking contract suite를 통과한다. - -### 32.3 Resilience RC - -- execution evidence -- retry eligibility -- retry budget -- circuit·rate·bulkhead -- RFC 9457 -- OAuth2·TLS - -완료 조건: duplicate POST, partial write, 429, pool saturation, token refresh race가 통과한다. - -### 32.4 Security Release - -- Trusted target validation -- Dynamic Target Apache -- DNS/IP pinning -- redirect revalidation -- SSRF security suite - -완료 조건: loopback·private·metadata·rebind·redirect 공격이 모두 차단된다. - -### 32.5 Reactive Release - -- Reactor Netty -- WebClient Gateway -- Reactive Typed Client -- streaming upload·download -- SSE -- cancellation·backpressure - -완료 조건: buffer leak, event-loop blocking, first-byte retry, stream idle suite가 통과한다. - -### 32.6 Extended Release - -- Spring Boot starter -- Spring 7 Service Groups -- RestTemplate migration -- proxy·HTTP/2 advanced evidence -- support matrix와 runbook - -### 32.7 Experimental - -- Jetty HTTP/3 -- Reactor HTTP/3 profile -- Native engine Lab -- request hedging Lab - ---- - -## 33. 완료 정의 - -플랫폼은 다음이 코드와 CI로 증명될 때 완료된다. - -| 영역 | 증명 조건 | -|---|---| -| API | 주요 호출이 Typed Client로 구현되고 H2·H3 사용이 별도 권한으로 제한된다. | -| 경계 | H1~H4가 timeout, host, TLS, auth, size, observation을 우회하지 못한다. | -| Engine | Apache·JDK·Reactor가 동일 result·exception metadata를 제공한다. | -| Deadline | pool·DNS·connect·TLS·retry backoff를 포함한 전체 시간이 effective deadline 이내다. | -| Retry | 모든 추가 attempt가 idempotency·replayability·evidence·deadline·budget으로 설명된다. | -| Ambiguity | 비멱등 `SENT_NO_RESPONSE`가 `HttpAmbiguousExecutionException`으로 구분된다. | -| Resource | body 미소비, decode 오류, cancel, size 초과 후에도 pool과 buffer가 회수된다. | -| Auth | token refresh single-flight, 401 최대 1회, secret rotation이 검증된다. | -| TLS | trust-all과 hostname 검증 해제가 startup에서 차단된다. | -| SSRF | canonicalization, DNS/IP, redirect, egress 테스트가 통과한다. | -| Streaming | first byte 이후 transparent retry가 0회다. | -| Observability | logical call과 attempt가 분리되고 forbidden label이 없다. | -| Failure | DNS, pool, TLS, reset, partial response, HTTP/2 GOAWAY를 재현한다. | -| Performance | 설정된 thread, heap, direct memory, pool, retry budget 상한을 넘지 않는다. | -| Compatibility | Spring 6.2·7.0과 지원 transport matrix가 release CI에 연결된다. | -| Documentation | support matrix, configuration reference, security guide, runbook, migration guide가 코드와 일치한다. | - ---- - -## 34. 최종 구현 기준 - -이 설계의 최종 원칙은 다음과 같다. - -> HTTP 기능을 최대한 많이 열어두되, 호출자가 URL·timeout·retry·credential·TLS·resource lifecycle을 임의로 조립하게 하지 않는다. 일반 호출은 Typed Client와 Named Client Profile을 사용하고, 플랫폼은 요청이 실제로 실행됐을 가능성과 다시 실행해도 되는지를 증거 기반으로 판정한다. - -구현 우선순위는 다음으로 고정한다. - -```text -Core 계약 -→ Named Client Profile -→ Transport SPI와 Testkit -→ Deadline·Target·Observability -→ Apache·JDK Blocking -→ RestClient와 Typed Client -→ Execution Evidence와 Retry -→ Resilience -→ Error·Auth·TLS -→ Dynamic Target SSRF -→ Reactor Netty·WebClient -→ Streaming·SSE -→ Starter·Migration·Compatibility -→ HTTP/3 Experimental -``` diff --git a/httpclient-superpowers-package/validate_httpclient_docs.py b/httpclient-superpowers-package/validate_httpclient_docs.py deleted file mode 100644 index 3a6dfce..0000000 --- a/httpclient-superpowers-package/validate_httpclient_docs.py +++ /dev/null @@ -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) diff --git a/redis-superpowers-package/README.md b/redis-superpowers-package/README.md deleted file mode 100644 index 9eb0482..0000000 --- a/redis-superpowers-package/README.md +++ /dev/null @@ -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`를 읽어 재생성하고 정책 오버레이를 적용하도록 설계했다. diff --git a/redis-superpowers-package/VALIDATION.md b/redis-superpowers-package/VALIDATION.md deleted file mode 100644 index a11631b..0000000 --- a/redis-superpowers-package/VALIDATION.md +++ /dev/null @@ -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 | diff --git a/redis-superpowers-package/docs/superpowers/plans/2026-08-07-redis-wrapper-typed-api-implementation-plan.md b/redis-superpowers-package/docs/superpowers/plans/2026-08-07-redis-wrapper-typed-api-implementation-plan.md deleted file mode 100644 index dfb103b..0000000 --- a/redis-superpowers-package/docs/superpowers/plans/2026-08-07-redis-wrapper-typed-api-implementation-plan.md +++ /dev/null @@ -1,2233 +0,0 @@ -# Redis Wrapper and Typed API Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Spring 기반 Backend Skeleton에 Redis classic 자료구조 전체, 동기·Reactive Typed API, 위험 통제형 Raw Gateway, Standalone·Sentinel·Cluster 지원, Redis 8 확장 모듈을 운영 가능한 공통 SDK로 구현한다. - -**Architecture:** `redis-core-api`에 Redis 또는 Spring 타입이 새지 않는 공개 계약을 두고, `redis-core-lettuce`가 Spring Data Redis 4.1과 Lettuce 7.6으로 이를 구현한다. 모든 명령은 command catalog와 policy guard를 통과하며, R1은 기본 Typed API, R2는 permit와 budget, R3는 별도 admin plane, R4는 전체 차단한다. - -**Tech Stack:** Java 21, Gradle Kotlin DSL, Spring Data Redis 4.1, Lettuce 7.6, Reactor, Micrometer, OpenTelemetry, JUnit 5, AssertJ, ArchUnit, Testcontainers, Toxiproxy, Awaitility, Jackson. - -## Global Constraints - -- 기능 최소 버전은 Redis 7.2다. -- 주 인증 버전은 Redis 7.4 최신 패치와 Redis 8.2 최신 패치다. -- Redis 8.10은 최신 호환성 job에서 검증한다. -- Standalone과 Sentinel은 완전 지원한다. -- Cluster는 DB 0, same-slot 다중 키, node-aware pipeline을 전제로 지원한다. -- 공개 프로그래밍 모델은 동기와 Reactive다. Lettuce native async는 공개 기본 API로 만들지 않는다. -- 일반 명령은 R1, 고비용·Blocking·다중 키는 R2, 운영 명령은 R3, 파괴적 명령은 R4로 분류한다. -- R1은 기본 Typed API, R2는 `AdvancedOperationPermit`와 `OperationBudget`, R3는 별도 admin plane, R4는 차단한다. -- 임의 문자열 기반 `execute(String, byte[]...)` API를 만들지 않는다. -- Java native serialization을 사용하지 않는다. -- 실제 key와 value를 metric label, trace attribute, 일반 log에 기록하지 않는다. -- Pipeline은 원자적이지 않으며 partial result를 반환한다. -- timeout 후 write는 자동 retry하지 않고 ambiguous execution을 표현한다. -- Blocking, transaction, Pub/Sub, admin 명령은 일반 shared connection에서 실행하지 않는다. -- Raw Gateway는 core guardrail 구현 뒤에 추가한다. -- 각 작업은 테스트를 먼저 추가하고, 해당 테스트의 실패를 확인한 뒤 구현한다. -- 각 작업은 독립적으로 검토 가능한 커밋 하나로 종료한다. - ---- - -## 1. 확정 파일 구조 - -```text -backend-skeleton/ -├── settings.gradle.kts -├── build.gradle.kts -├── gradle/libs.versions.toml -├── build-logic/ -│ └── src/main/kotlin/redis-library-conventions.gradle.kts -├── modules/redis/ -│ ├── redis-core-api/ -│ ├── redis-core-lettuce/ -│ ├── redis-cluster/ -│ ├── redis-programmability/ -│ ├── redis-raw-gateway/ -│ ├── redis-admin-plane/ -│ ├── redis-spring-boot-starter/ -│ ├── redis-testkit/ -│ └── extensions/ -│ ├── redis-json/ -│ ├── redis-search/ -│ ├── redis-timeseries/ -│ └── redis-probabilistic/ -├── infra/redis/ -│ ├── standalone/compose.yml -│ ├── sentinel/compose.yml -│ ├── cluster/compose.yml -│ └── acl/ -├── docs/redis/ -│ ├── support-matrix.md -│ ├── command-policy.md -│ ├── operations.md -│ └── upgrade-guide.md -└── docs/superpowers/specs/2026-08-07-redis-wrapper-typed-api-design.md -``` - -## 2. 핵심 패키지 - -```text -io.backend.skeleton.redis.api -io.backend.skeleton.redis.api.key -io.backend.skeleton.redis.api.codec -io.backend.skeleton.redis.api.command -io.backend.skeleton.redis.api.error -io.backend.skeleton.redis.api.operations -io.backend.skeleton.redis.api.reactive -io.backend.skeleton.redis.lettuce -io.backend.skeleton.redis.lettuce.command -io.backend.skeleton.redis.lettuce.connection -io.backend.skeleton.redis.lettuce.observability -io.backend.skeleton.redis.cluster -io.backend.skeleton.redis.programmability -io.backend.skeleton.redis.raw -io.backend.skeleton.redis.admin -io.backend.skeleton.redis.autoconfigure -io.backend.skeleton.redis.testkit -``` - ---- - -### Task 1: Gradle 멀티모듈과 공통 품질 규칙 구성 - -**Files:** -- Modify: `settings.gradle.kts` -- Modify: `gradle/libs.versions.toml` -- Create: `build-logic/src/main/kotlin/redis-library-conventions.gradle.kts` -- Create: `modules/redis/redis-core-api/build.gradle.kts` -- Create: `modules/redis/redis-core-lettuce/build.gradle.kts` -- Create: `modules/redis/redis-cluster/build.gradle.kts` -- Create: `modules/redis/redis-programmability/build.gradle.kts` -- Create: `modules/redis/redis-raw-gateway/build.gradle.kts` -- Create: `modules/redis/redis-admin-plane/build.gradle.kts` -- Create: `modules/redis/redis-spring-boot-starter/build.gradle.kts` -- Create: `modules/redis/redis-testkit/build.gradle.kts` -- Create: `modules/redis/extensions/redis-json/build.gradle.kts` -- Create: `modules/redis/extensions/redis-search/build.gradle.kts` -- Create: `modules/redis/extensions/redis-timeseries/build.gradle.kts` -- Create: `modules/redis/extensions/redis-probabilistic/build.gradle.kts` -- Test: `modules/redis/redis-core-api/src/test/java/io/backend/skeleton/redis/api/ModuleSmokeTest.java` - -**Interfaces:** -- Produces Gradle project paths used by every later task. -- Java toolchain is fixed to 21. -- `redis-core-api` has no Spring Data Redis or Lettuce dependency. - -- [ ] **Step 1: Write the failing module smoke test** - -```java -package io.backend.skeleton.redis.api; - -import org.junit.jupiter.api.Test; - -import static org.assertj.core.api.Assertions.assertThat; - -class ModuleSmokeTest { - @Test - void coreApiModuleLoads() { - assertThat(ModuleSmokeTest.class.getModule()).isNotNull(); - } -} -``` - -- [ ] **Step 2: Register module paths and verify the build fails before build files exist** - -Add to `settings.gradle.kts`: - -```kotlin -include( - ":modules:redis:redis-core-api", - ":modules:redis:redis-core-lettuce", - ":modules:redis:redis-cluster", - ":modules:redis:redis-programmability", - ":modules:redis:redis-raw-gateway", - ":modules:redis:redis-admin-plane", - ":modules:redis:redis-spring-boot-starter", - ":modules:redis:redis-testkit", - ":modules:redis:extensions:redis-json", - ":modules:redis:extensions:redis-search", - ":modules:redis:extensions:redis-timeseries", - ":modules:redis:extensions:redis-probabilistic" -) -``` - -Run: - -```bash -./gradlew :modules:redis:redis-core-api:test -``` - -Expected: FAIL because Redis module build files or source sets do not exist. - -- [ ] **Step 3: Add the version catalog and convention plugin** - -Add to `gradle/libs.versions.toml`: - -```toml -[versions] -java = "21" -spring-data-redis = "4.1.0" -lettuce = "7.6.0.RELEASE" -reactor = "3.8.0" -junit = "5.12.2" -assertj = "3.27.3" -archunit = "1.4.1" -testcontainers = "1.21.3" -awaitility = "4.3.0" -jackson = "2.20.0" - -[libraries] -spring-data-redis = { module = "org.springframework.data:spring-data-redis", version.ref = "spring-data-redis" } -lettuce-core = { module = "io.lettuce:lettuce-core", version.ref = "lettuce" } -reactor-core = { module = "io.projectreactor:reactor-core", version.ref = "reactor" } -junit-bom = { module = "org.junit:junit-bom", version.ref = "junit" } -junit-jupiter = { module = "org.junit.jupiter:junit-jupiter" } -assertj = { module = "org.assertj:assertj-core", version.ref = "assertj" } -archunit = { module = "com.tngtech.archunit:archunit-junit5", version.ref = "archunit" } -testcontainers-bom = { module = "org.testcontainers:testcontainers-bom", version.ref = "testcontainers" } -testcontainers-junit = { module = "org.testcontainers:junit-jupiter" } -toxiproxy = { module = "org.testcontainers:toxiproxy" } -awaitility = { module = "org.awaitility:awaitility", version.ref = "awaitility" } -jackson-databind = { module = "com.fasterxml.jackson.core:jackson-databind", version.ref = "jackson" } -``` - -Create `redis-library-conventions.gradle.kts`: - -```kotlin -plugins { - `java-library` - jacoco -} - -java { - toolchain.languageVersion.set(JavaLanguageVersion.of(21)) - withSourcesJar() - withJavadocJar() -} - -tasks.withType().configureEach { - useJUnitPlatform() -} - -dependencies { - "testImplementation"(platform(libs.junit.bom)) - "testImplementation"(libs.junit.jupiter) - "testImplementation"(libs.assertj) -} -``` - -Apply the convention plugin to every Redis module and set dependency directions exactly as defined in the design document. - -- [ ] **Step 4: Run the module test and dependency report** - -```bash -./gradlew :modules:redis:redis-core-api:test \ - :modules:redis:redis-core-api:dependencies --configuration runtimeClasspath -``` - -Expected: PASS. The runtime classpath must not contain `spring-data-redis` or `lettuce-core`. - -- [ ] **Step 5: Commit** - -```bash -git add settings.gradle.kts gradle/libs.versions.toml build-logic modules/redis -git commit -m "build: add redis sdk module graph" -``` - ---- - -### Task 2: Command policy catalog와 metadata diff 도구 구현 - -**Files:** -- Create: `modules/redis/redis-core-lettuce/src/main/resources/redis-command-policy.yml` -- Create: `modules/redis/redis-core-lettuce/src/main/java/io/backend/skeleton/redis/lettuce/command/RedisCommandPolicy.java` -- Create: `modules/redis/redis-core-lettuce/src/main/java/io/backend/skeleton/redis/lettuce/command/RedisCommandPolicyLoader.java` -- Create: `modules/redis/redis-core-lettuce/src/main/java/io/backend/skeleton/redis/lettuce/command/RedisCommandMetadataDiff.java` -- Create: `modules/redis/redis-core-lettuce/src/test/java/io/backend/skeleton/redis/lettuce/command/RedisCommandPolicyLoaderTest.java` -- Create: `modules/redis/redis-core-lettuce/src/test/java/io/backend/skeleton/redis/lettuce/command/RedisCommandMetadataDiffTest.java` - -**Interfaces:** - -```java -public record RedisCommandPolicy( - String command, - Optional subcommand, - RedisVersion minimumVersion, - RedisRiskLevel riskLevel, - CommandSupport support, - CommandAccess access, - boolean blocking, - boolean readOnly, - boolean retrySafe, - boolean mayBeAmbiguous, - TimeoutProfile timeoutProfile -) {} -``` - -- [ ] **Step 1: Write failing YAML loader tests** - -```java -@Test -void loadsGetAndBlocksKeys() { - RedisCommandPolicyLoader loader = new RedisCommandPolicyLoader(); - Map policies = loader.load( - new ClassPathResource("redis-command-policy.yml") - ); - - assertThat(policies.get(CommandId.of("GET")).riskLevel()).isEqualTo(RedisRiskLevel.R1); - assertThat(policies.get(CommandId.of("KEYS")).support()).isEqualTo(CommandSupport.BLOCKED); -} -``` - -- [ ] **Step 2: Run the loader test** - -```bash -./gradlew :modules:redis:redis-core-lettuce:test \ - --tests "*RedisCommandPolicyLoaderTest" -``` - -Expected: FAIL because the loader and policy resource do not exist. - -- [ ] **Step 3: Implement policy schema, loader, and initial mandatory policies** - -The initial YAML must include at least `GET`, `SET`, `HGETALL`, `SMEMBERS`, `BLPOP`, `XREAD`, `INFO`, `CONFIG`, `KEYS`, `FLUSHALL`, `SHUTDOWN`, and `DEBUG`. Implement duplicate command detection and reject unknown enum values. - -```java -public final class RedisCommandPolicyLoader { - private final ObjectMapper mapper = new ObjectMapper(new YAMLFactory()); - - public Map load(Resource resource) { - try (InputStream input = resource.getInputStream()) { - PolicyDocument document = mapper.readValue(input, PolicyDocument.class); - return document.commands().entrySet().stream() - .map(entry -> Map.entry(CommandId.parse(entry.getKey()), entry.getValue().toPolicy(entry.getKey()))) - .collect(Collectors.toUnmodifiableMap(Map.Entry::getKey, Map.Entry::getValue)); - } catch (IOException exception) { - throw new IllegalStateException("Cannot load Redis command policy", exception); - } - } -} -``` - -- [ ] **Step 4: Add metadata diff behavior and run tests** - -`RedisCommandMetadataDiff.compare()` must report: - -```java -public record RedisCommandMetadataDiff( - Set added, - Set removed, - Set changedKeySpecs, - Set changedAclCategories, - Set deprecatedChanges -) { - public boolean requiresReview() { - return !(added.isEmpty() - && removed.isEmpty() - && changedKeySpecs.isEmpty() - && changedAclCategories.isEmpty() - && deprecatedChanges.isEmpty()); - } -} -``` - -Run: - -```bash -./gradlew :modules:redis:redis-core-lettuce:test \ - --tests "*RedisCommandPolicyLoaderTest" \ - --tests "*RedisCommandMetadataDiffTest" -``` - -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add modules/redis/redis-core-lettuce -git commit -m "feat(redis): add command policy catalog" -``` - ---- - -### Task 3: Redis version, topology, risk, permit, budget 모델 구현 - -**Files:** -- Create: `modules/redis/redis-core-api/src/main/java/io/backend/skeleton/redis/api/RedisVersion.java` -- Create: `modules/redis/redis-core-api/src/main/java/io/backend/skeleton/redis/api/RedisCapability.java` -- Create: `modules/redis/redis-core-api/src/main/java/io/backend/skeleton/redis/api/RedisCapabilities.java` -- Create: `modules/redis/redis-core-api/src/main/java/io/backend/skeleton/redis/api/RedisDeploymentMode.java` -- Create: `modules/redis/redis-core-api/src/main/java/io/backend/skeleton/redis/api/command/RedisRiskLevel.java` -- Create: `modules/redis/redis-core-api/src/main/java/io/backend/skeleton/redis/api/command/CommandSupport.java` -- Create: `modules/redis/redis-core-api/src/main/java/io/backend/skeleton/redis/api/command/OperationBudget.java` -- Create: `modules/redis/redis-core-api/src/main/java/io/backend/skeleton/redis/api/command/AdvancedOperationPermit.java` -- Create: `modules/redis/redis-core-api/src/main/java/io/backend/skeleton/redis/api/command/MultiKeyPermit.java` -- Create: `modules/redis/redis-core-api/src/main/java/io/backend/skeleton/redis/api/command/PersistentKeyPermit.java` -- Create: `modules/redis/redis-core-api/src/main/java/io/backend/skeleton/redis/api/command/RedisPolicyAuthority.java` -- Create: `modules/redis/redis-core-api/src/main/java/io/backend/skeleton/redis/api/command/RedisPermitVerifier.java` -- Test: `modules/redis/redis-core-api/src/test/java/io/backend/skeleton/redis/api/RedisVersionTest.java` -- Test: `modules/redis/redis-core-api/src/test/java/io/backend/skeleton/redis/api/command/OperationBudgetTest.java` - -**Interfaces:** - -```java -public record RedisVersion(int major, int minor, int patch) implements Comparable {} -public record OperationBudget(int maxElements, long maxRequestBytes, long maxReplyBytes, Duration timeout) {} -``` - -- [ ] **Step 1: Write failing value-object tests** - -```java -@Test -void parsesAndOrdersVersions() { - assertThat(RedisVersion.parse("8.2.1")).isGreaterThan(RedisVersion.parse("7.4.9")); -} - -@Test -void rejectsNonPositiveBudget() { - assertThatThrownBy(() -> new OperationBudget(0, 1, 1, Duration.ofMillis(1))) - .isInstanceOf(IllegalArgumentException.class); -} -``` - -- [ ] **Step 2: Run tests** - -```bash -./gradlew :modules:redis:redis-core-api:test \ - --tests "*RedisVersionTest" \ - --tests "*OperationBudgetTest" -``` - -Expected: FAIL because the types do not exist. - -- [ ] **Step 3: Implement immutable models** - -Implement strict semantic version parsing, natural ordering, and strictly positive budget validation. Define permits as public marker contracts in `redis-core-api`; only `redis-spring-boot-starter` may provide package-private granted implementations through `RedisPolicyAuthority`. This preserves module boundaries while preventing application code from constructing approved grants directly. - -```java -public interface AdvancedOperationPermit { - String policyName(); -} - -public interface MultiKeyPermit { - String policyName(); -} - -public interface PersistentKeyPermit { - String policyName(); -} -``` - -The starter later provides package-private signed implementations and a configured authority/verifier pair. `RedisPermitVerifier` is invoked by every guarded executor path; a caller-created implementation of a permit interface must fail provenance verification. - -- [ ] **Step 4: Run API tests** - -```bash -./gradlew :modules:redis:redis-core-api:test -``` - -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add modules/redis/redis-core-api -git commit -m "feat(redis): add capability and policy value objects" -``` - ---- - -### Task 4: Key namespace와 slot-safe typed key 구현 - -**Files:** -- Create: `modules/redis/redis-core-api/src/main/java/io/backend/skeleton/redis/api/key/RedisKeyRules.java` -- Create: `modules/redis/redis-core-api/src/main/java/io/backend/skeleton/redis/api/key/RedisNamespace.java` -- Create: `modules/redis/redis-core-api/src/main/java/io/backend/skeleton/redis/api/key/RedisKeyName.java` -- Create: `modules/redis/redis-core-api/src/main/java/io/backend/skeleton/redis/api/key/RedisSlotTag.java` -- Create: `modules/redis/redis-core-api/src/main/java/io/backend/skeleton/redis/api/key/QualifiedRedisKey.java` -- Create: `modules/redis/redis-core-api/src/main/java/io/backend/skeleton/redis/api/key/RedisKeyRenderer.java` -- Create: `modules/redis/redis-core-api/src/main/java/io/backend/skeleton/redis/api/key/TypedRedisKeys.java` -- Test: `modules/redis/redis-core-api/src/test/java/io/backend/skeleton/redis/api/key/RedisKeyRendererTest.java` -- Test: `modules/redis/redis-core-api/src/test/java/io/backend/skeleton/redis/api/key/RedisKeyRulesTest.java` - -**Interfaces:** - -```java -public record QualifiedRedisKey( - RedisNamespace namespace, - RedisKeyName name, - Optional slotTag -) {} -``` - -- [ ] **Step 1: Write failing rendering and privacy tests** - -```java -@Test -void rendersClusterSlotTagOnlyInsideBraces() { - QualifiedRedisKey key = new QualifiedRedisKey( - new RedisNamespace("prod", "order", "shared"), - new RedisKeyName("summary", "42"), - Optional.of(new RedisSlotTag("customer-7")) - ); - - assertThat(new RedisKeyRenderer(512).render(key)) - .isEqualTo("prod:order:shared:{customer-7}:summary:42"); -} - -@Test -void rejectsEmailInIdentifier() { - assertThatThrownBy(() -> new RedisKeyName("user", "person@example.com")) - .isInstanceOf(IllegalArgumentException.class); -} -``` - -- [ ] **Step 2: Run tests** - -```bash -./gradlew :modules:redis:redis-core-api:test --tests "*RedisKey*Test" -``` - -Expected: FAIL. - -- [ ] **Step 3: Implement validation and typed key records** - -Create `ValueKey`, `HashKey`, `ListKey`, `SetKey`, `SortedSetKey`, `BitmapKey`, `HyperLogLogKey`, `GeoKey`, and `StreamKey`. Each record stores `QualifiedRedisKey` plus the required codec references. - -- [ ] **Step 4: Run tests and ArchUnit package rule** - -```bash -./gradlew :modules:redis:redis-core-api:test -``` - -Expected: PASS. `key` package must not depend on Spring or Lettuce packages. - -- [ ] **Step 5: Commit** - -```bash -git add modules/redis/redis-core-api/src/main/java/io/backend/skeleton/redis/api/key \ - modules/redis/redis-core-api/src/test/java/io/backend/skeleton/redis/api/key -git commit -m "feat(redis): add namespaced typed keys" -``` - ---- - -### Task 5: Codec registry와 versioned envelope 구현 - -**Files:** -- Create: `modules/redis/redis-core-api/src/main/java/io/backend/skeleton/redis/api/codec/RedisCodec.java` -- Create: `modules/redis/redis-core-api/src/main/java/io/backend/skeleton/redis/api/codec/RedisEnvelope.java` -- Create: `modules/redis/redis-core-lettuce/src/main/java/io/backend/skeleton/redis/lettuce/codec/RedisCodecRegistry.java` -- Create: `modules/redis/redis-core-lettuce/src/main/java/io/backend/skeleton/redis/lettuce/codec/Utf8StringCodec.java` -- Create: `modules/redis/redis-core-lettuce/src/main/java/io/backend/skeleton/redis/lettuce/codec/LongCodec.java` -- Create: `modules/redis/redis-core-lettuce/src/main/java/io/backend/skeleton/redis/lettuce/codec/VersionedJsonCodec.java` -- Test: `modules/redis/redis-core-lettuce/src/test/java/io/backend/skeleton/redis/lettuce/codec/VersionedJsonCodecTest.java` -- Test: `modules/redis/redis-core-lettuce/src/test/resources/golden/order-summary-v1.json` - -**Interfaces:** - -```java -public interface RedisCodec { - String id(); - byte[] encode(T value); - T decode(byte[] bytes); -} -``` - -- [ ] **Step 1: Write failing golden-byte compatibility test** - -```java -private record OrderSummary(String orderId, long amount) {} - -@Test -void readsVersionOneGoldenPayload() throws Exception { - VersionedJsonCodec codec = orderSummaryCodec(); - byte[] bytes = Files.readAllBytes(Path.of( - "src/test/resources/golden/order-summary-v1.json" - )); - - assertThat(codec.decode(bytes)).isEqualTo(new OrderSummary("order-1", 12000L)); -} -``` - -- [ ] **Step 2: Run codec test** - -```bash -./gradlew :modules:redis:redis-core-lettuce:test --tests "*VersionedJsonCodecTest" -``` - -Expected: FAIL. - -- [ ] **Step 3: Implement codec registry and envelope validation** - -`VersionedJsonCodec` must reject unknown schema IDs, support configured reader versions, measure encoded bytes before Redis execution, and throw `RedisSerializationException` on corruption. Do not use Java native serialization. - -- [ ] **Step 4: Run codec tests** - -```bash -./gradlew :modules:redis:redis-core-lettuce:test --tests "*codec*" -``` - -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add modules/redis/redis-core-api/src/main/java/io/backend/skeleton/redis/api/codec \ - modules/redis/redis-core-lettuce/src/main/java/io/backend/skeleton/redis/lettuce/codec \ - modules/redis/redis-core-lettuce/src/test -git commit -m "feat(redis): add versioned codec registry" -``` - ---- - -### Task 6: 안정된 오류 모델과 ambiguous execution 구현 - -**Files:** -- Create: `modules/redis/redis-core-api/src/main/java/io/backend/skeleton/redis/api/error/RedisFailureMetadata.java` -- Create: `modules/redis/redis-core-api/src/main/java/io/backend/skeleton/redis/api/error/RedisOperationException.java` -- Create: `modules/redis/redis-core-api/src/main/java/io/backend/skeleton/redis/api/error/RedisTimeoutException.java` -- Create: `modules/redis/redis-core-api/src/main/java/io/backend/skeleton/redis/api/error/RedisConnectionException.java` -- Create: `modules/redis/redis-core-api/src/main/java/io/backend/skeleton/redis/api/error/RedisCrossSlotException.java` -- Create: `modules/redis/redis-core-api/src/main/java/io/backend/skeleton/redis/api/error/RedisAmbiguousExecutionException.java` -- Create: `modules/redis/redis-core-lettuce/src/main/java/io/backend/skeleton/redis/lettuce/command/LettuceExceptionTranslator.java` -- Test: `modules/redis/redis-core-lettuce/src/test/java/io/backend/skeleton/redis/lettuce/command/LettuceExceptionTranslatorTest.java` - -**Interfaces:** - -```java -public record RedisFailureMetadata( - String commandCategory, - CommandAccess access, - boolean readOperation, - boolean retryable, - boolean ambiguousExecution, - RedisVersion serverVersion, - RedisDeploymentMode deploymentMode, - OptionalInt slot, - Duration elapsed -) {} -``` - -- [ ] **Step 1: Write failing translation tests** - -```java -@Test -void marksWriteTimeoutAsAmbiguousAndNotRetryable() { - RedisOperationException translated = translator.translate( - new RedisCommandTimeoutException("timeout"), - CommandExecutionContext.write("INCR") - ); - - assertThat(translated).isInstanceOf(RedisAmbiguousExecutionException.class); - assertThat(translated.metadata().retryable()).isFalse(); - assertThat(translated.metadata().ambiguousExecution()).isTrue(); -} -``` - -- [ ] **Step 2: Run translator tests** - -```bash -./gradlew :modules:redis:redis-core-lettuce:test --tests "*LettuceExceptionTranslatorTest" -``` - -Expected: FAIL. - -- [ ] **Step 3: Implement exception hierarchy and translation matrix** - -Translate timeout, connection, ACL, CROSSSLOT, MOVED/ASK, BUSY, NOSCRIPT, WRONGTYPE, serialization, policy rejection, capability absence, and ambiguous execution. Sanitize messages so command arguments, key, value, password are absent. - -- [ ] **Step 4: Run tests** - -```bash -./gradlew :modules:redis:redis-core-api:test :modules:redis:redis-core-lettuce:test -``` - -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add modules/redis/redis-core-api/src/main/java/io/backend/skeleton/redis/api/error \ - modules/redis/redis-core-lettuce/src/main/java/io/backend/skeleton/redis/lettuce/command \ - modules/redis/redis-core-lettuce/src/test/java/io/backend/skeleton/redis/lettuce/command -git commit -m "feat(redis): add stable failure semantics" -``` - ---- - -### Task 7: 동기·Reactive 공개 API와 parity test 구현 - -**Files:** -- Create: `modules/redis/redis-core-api/src/main/java/io/backend/skeleton/redis/api/RedisOperations.java` -- Create: `modules/redis/redis-core-api/src/main/java/io/backend/skeleton/redis/api/ReactiveRedisOperations.java` -- Create: `modules/redis/redis-core-api/src/main/java/io/backend/skeleton/redis/api/operations/*.java` -- Create: `modules/redis/redis-core-api/src/main/java/io/backend/skeleton/redis/api/reactive/*.java` -- Create: `modules/redis/redis-core-api/src/test/java/io/backend/skeleton/redis/api/ApiParityInspector.java` -- Create: `modules/redis/redis-core-api/src/test/java/io/backend/skeleton/redis/api/ApiParityReport.java` -- Create: `modules/redis/redis-core-api/src/test/java/io/backend/skeleton/redis/api/ApiParityTest.java` -- Create: `modules/redis/redis-core-api/src/test/java/io/backend/skeleton/redis/api/NoDriverLeakArchitectureTest.java` - -**Interfaces:** -- Use the exact method sets from design sections 8 and 10. -- Sync and Reactive names and parameter types are identical. -- Reactive return types are `Mono` for single result and `Flux` only for streaming subscription or cursor consumption. - -- [ ] **Step 1: Write failing parity and architecture tests** - -```java -@Test -void everySyncOperationHasReactiveCounterpart() { - ApiParityReport report = ApiParityInspector.compare( - RedisValueOperations.class, - ReactiveRedisValueOperations.class - ); - assertThat(report.differences()).isEmpty(); -} -``` - -```java -@ArchTest -static final ArchRule apiMustNotDependOnDrivers = noClasses() - .that().resideInAPackage("io.backend.skeleton.redis.api..") - .should().dependOnClassesThat() - .resideInAnyPackage("org.springframework.data.redis..", "io.lettuce.core.."); -``` - -- [ ] **Step 2: Run API tests** - -```bash -./gradlew :modules:redis:redis-core-api:test \ - --tests "*ApiParityTest" \ - --tests "*NoDriverLeakArchitectureTest" -``` - -Expected: FAIL because interfaces are incomplete. - -- [ ] **Step 3: Add all public interface signatures and supporting models** - -Create operation models such as `Expiration`, `ScanRequest`, `ScanPage`, `PageRequest`, `ScoreRange`, `StreamTrimPolicy`, `StreamRecord`, `GeoSearchRequest`, `BatchOptions`, and `BatchItemResult`. Keep them immutable and driver-independent. - -- [ ] **Step 4: Run all core API tests** - -```bash -./gradlew :modules:redis:redis-core-api:test -``` - -Expected: PASS with zero parity differences and zero driver dependency violations. - -- [ ] **Step 5: Commit** - -```bash -git add modules/redis/redis-core-api -git commit -m "feat(redis): define sync and reactive typed api" -``` - ---- - -### Task 8: Spring Boot properties, topology probe, connection isolation 구현 - -**Files:** -- Create: `modules/redis/redis-spring-boot-starter/src/main/java/io/backend/skeleton/redis/autoconfigure/BackendRedisProperties.java` -- Create: `modules/redis/redis-spring-boot-starter/src/main/java/io/backend/skeleton/redis/autoconfigure/RedisCapabilityProbe.java` -- Create: `modules/redis/redis-spring-boot-starter/src/main/java/io/backend/skeleton/redis/autoconfigure/RedisConnectionAutoConfiguration.java` -- Create: `modules/redis/redis-core-lettuce/src/main/java/io/backend/skeleton/redis/lettuce/connection/RedisConnectionKind.java` -- Create: `modules/redis/redis-core-lettuce/src/main/java/io/backend/skeleton/redis/lettuce/connection/RedisConnectionRegistry.java` -- Create: `modules/redis/redis-spring-boot-starter/src/main/java/io/backend/skeleton/redis/autoconfigure/ConfiguredRedisPolicyAuthority.java` -- Create: `modules/redis/redis-spring-boot-starter/src/main/java/io/backend/skeleton/redis/autoconfigure/GrantedAdvancedOperationPermit.java` -- Create: `modules/redis/redis-spring-boot-starter/src/main/java/io/backend/skeleton/redis/autoconfigure/GrantedMultiKeyPermit.java` -- Create: `modules/redis/redis-spring-boot-starter/src/main/java/io/backend/skeleton/redis/autoconfigure/GrantedPersistentKeyPermit.java` -- Create: `modules/redis/redis-spring-boot-starter/src/main/java/io/backend/skeleton/redis/autoconfigure/ConfiguredRedisPermitVerifier.java` -- Create: `modules/redis/redis-testkit/src/main/java/io/backend/skeleton/redis/testkit/StandaloneRedisEnvironment.java` -- Create: `modules/redis/redis-testkit/src/main/java/io/backend/skeleton/redis/testkit/SentinelRedisEnvironment.java` -- Create: `modules/redis/redis-testkit/src/main/java/io/backend/skeleton/redis/testkit/ClusterRedisEnvironment.java` -- Create: `modules/redis/redis-testkit/src/main/kotlin/io/backend/skeleton/redis/testkit/RedisTopologyTestTasksPlugin.kt` -- Modify: `modules/redis/redis-testkit/build.gradle.kts` -- Test: `modules/redis/redis-spring-boot-starter/src/test/java/io/backend/skeleton/redis/autoconfigure/BackendRedisPropertiesTest.java` -- Test: `modules/redis/redis-spring-boot-starter/src/test/java/io/backend/skeleton/redis/autoconfigure/RedisCapabilityProbeTest.java` - -**Interfaces:** - -```java -public enum RedisConnectionKind { REGULAR, BLOCKING, TRANSACTION, PUBSUB, ADMIN } -``` - -- [ ] **Step 1: Write failing property validation tests** - -```java -@Test -void clusterRejectsDatabaseOtherThanZero() { - BackendRedisProperties properties = validProperties(); - properties.setMode(RedisDeploymentMode.CLUSTER); - properties.setDatabase(1); - - assertThatThrownBy(properties::validate) - .hasMessageContaining("Cluster supports database 0 only"); -} -``` - -- [ ] **Step 2: Run starter tests** - -```bash -./gradlew :modules:redis:redis-spring-boot-starter:test --tests "*BackendRedisPropertiesTest" -``` - -Expected: FAIL. - -- [ ] **Step 3: Implement properties, validation, policy authority, topology test bootstrap, and five connection kinds** - -Use the exact defaults from design section 23. `RedisCapabilityProbe` must read server version, deployment mode, command availability, DB index, and enabled extension capabilities. Startup must fail when an explicitly enabled capability is unavailable. - -`ConfiguredRedisPolicyAuthority` implements the core `RedisPolicyAuthority` contract. It issues package-private signed permit implementations only for configured policy names. `ConfiguredRedisPermitVerifier` validates implementation provenance, issuer ID, signature, and required policy; application-created fake permit implementations are rejected. These beans exist only when advanced operations are enabled. - -Create baseline Testcontainers environments and register these Gradle tasks now, before any data-structure contract uses them: - -```text -redis72Test -redis74Test -redis82Test -redis810Test -sentinel74Test -sentinel82Test -cluster74Test -cluster82Test -redis82ExtensionsTest -``` - -At this stage the environments only need deterministic startup, endpoint/credential export, readiness checks, cleanup, and test filtering. Later Sentinel, Cluster, fault, ACL, and performance tasks extend these same classes rather than recreating them. - -- [ ] **Step 4: Run starter tests and context runner tests** - -```bash -./gradlew :modules:redis:redis-spring-boot-starter:test -``` - -Expected: PASS. A normal application context must not create ADMIN or Raw Gateway beans unless enabled. - -- [ ] **Step 5: Commit** - -```bash -git add modules/redis/redis-spring-boot-starter \ - modules/redis/redis-core-lettuce/src/main/java/io/backend/skeleton/redis/lettuce/connection \ - modules/redis/redis-testkit -git commit -m "feat(redis): add topology aware connection configuration" -``` - ---- - -### Task 9: Policy-aware command executor와 관측성 구현 - -**Files:** -- Create: `modules/redis/redis-core-lettuce/src/main/java/io/backend/skeleton/redis/lettuce/command/CommandRequest.java` -- Create: `modules/redis/redis-core-lettuce/src/main/java/io/backend/skeleton/redis/lettuce/command/CommandPolicyGuard.java` -- Create: `modules/redis/redis-core-lettuce/src/main/java/io/backend/skeleton/redis/lettuce/command/SyncRedisCommandExecutor.java` -- Create: `modules/redis/redis-core-lettuce/src/main/java/io/backend/skeleton/redis/lettuce/command/ReactiveRedisCommandExecutor.java` -- Create: `modules/redis/redis-core-lettuce/src/main/java/io/backend/skeleton/redis/lettuce/observability/RedisObservation.java` -- Test: `modules/redis/redis-core-lettuce/src/test/java/io/backend/skeleton/redis/lettuce/command/CommandPolicyGuardTest.java` -- Test: `modules/redis/redis-core-lettuce/src/test/java/io/backend/skeleton/redis/lettuce/observability/RedisObservationTest.java` - -**Interfaces:** - -```java -public record CommandRequest( - CommandId commandId, - List keys, - long requestBytes, - long expectedReplyBytes, - Optional advancedPermit, - Optional budget, - Supplier> invocation -) {} -``` - -- [ ] **Step 1: Write failing guard tests** - -```java -@Test -void rejectsR2WithoutPermitAndBudget() { - assertThatThrownBy(() -> guard.validate(requestFor("HGETALL"))) - .isInstanceOf(RedisCommandRejectedException.class) - .hasMessageContaining("R2 command requires permit and budget"); -} - -@Test -void rejectsCallerImplementedPermitThatWasNotIssuedByAuthority() { - AdvancedOperationPermit fake = () -> "collection-full-read"; - - assertThatThrownBy(() -> guard.validate(requestFor("HGETALL", fake, boundedBudget()))) - .isInstanceOf(RedisCommandRejectedException.class) - .hasMessageContaining("permit provenance"); -} - -@Test -void neverAddsRawKeyToMetricTags() { - RedisObservation observation = observationFor("prod:order:user:42"); - assertThat(observation.lowCardinalityTags()).doesNotContainKey("redis.key"); -} -``` - -- [ ] **Step 2: Run executor tests** - -```bash -./gradlew :modules:redis:redis-core-lettuce:test \ - --tests "*CommandPolicyGuardTest" \ - --tests "*RedisObservationTest" -``` - -Expected: FAIL. - -- [ ] **Step 3: Implement the fixed execution pipeline** - -`CommandPolicyGuard` receives `RedisPermitVerifier`; permit presence alone is insufficient. It verifies provenance and the command policy's required policy name before continuing. - -Execution order must be: - -```text -capability -> risk/permit provenance -> namespace -> slot -> request budget -> connection kind --> timeout/retry policy -> invocation -> reply budget -> exception translation --> metric/trace/audit close -``` - -Metric names and low-cardinality tags must match design section 21. `SyncRedisCommandExecutor` waits on the shared `CompletionStage` using the selected timeout profile; `ReactiveRedisCommandExecutor` adapts the same stage with `Mono.fromCompletionStage`, so command policy and driver invocation remain single-sourced. - -- [ ] **Step 4: Run executor tests** - -```bash -./gradlew :modules:redis:redis-core-lettuce:test -``` - -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add modules/redis/redis-core-lettuce -git commit -m "feat(redis): enforce command policy execution pipeline" -``` - ---- - -### Task 10: String와 Key·TTL operations 구현 - -**Files:** -- Create: `modules/redis/redis-core-lettuce/src/main/java/io/backend/skeleton/redis/lettuce/operations/LettuceRedisValueOperations.java` -- Create: `modules/redis/redis-core-lettuce/src/main/java/io/backend/skeleton/redis/lettuce/operations/LettuceReactiveRedisValueOperations.java` -- Create: `modules/redis/redis-core-lettuce/src/main/java/io/backend/skeleton/redis/lettuce/operations/LettuceRedisKeyOperations.java` -- Create: `modules/redis/redis-core-lettuce/src/main/java/io/backend/skeleton/redis/lettuce/operations/LettuceReactiveRedisKeyOperations.java` -- Test: `modules/redis/redis-testkit/src/test/java/io/backend/skeleton/redis/contract/RedisValueOperationsContract.java` -- Test: `modules/redis/redis-testkit/src/test/java/io/backend/skeleton/redis/contract/RedisKeyOperationsContract.java` - -**Interfaces:** -- Implement every method declared in design sections 10.1 and 10.11. -- `set` and expiration must be atomic. -- `KEYS` is absent from the public API. - -- [ ] **Step 1: Write failing contract tests** - -```java -@Test -void setWithExpirationNeverCreatesPersistentKey() { - ValueKey key = keys.value("cache", "one", codecs.string()); - operations.values().set(key, "value", new Expiration.After(Duration.ofSeconds(2))); - - assertThat(operations.keys().ttl(key.key())).hasValueSatisfying(ttl -> - assertThat(ttl).isPositive().isLessThanOrEqualTo(Duration.ofSeconds(2)) - ); -} -``` - -```java -@Test -void incrementWithInitialExpirationIsAtomic() { - ValueKey key = keys.value("counter", "one", codecs.longCodec()); - assertThat(operations.values().increment(key, 1, new Expiration.After(Duration.ofMinutes(1)))) - .isEqualTo(1L); - assertThat(operations.keys().ttl(key.key())).isPresent(); -} -``` - -- [ ] **Step 2: Run contracts against Standalone 7.4** - -```bash -./gradlew :modules:redis:redis-testkit:test --tests "*RedisValueOperationsContract" --tests "*RedisKeyOperationsContract" -``` - -Expected: FAIL. - -- [ ] **Step 3: Implement sync and Reactive adapters** - -Use `SET` options for atomic TTL. Use a registered script for increment-plus-initial-TTL on Redis 7.2–8.2 and a version-gated optimized path when `INCREX` is available. `SCAN` requires R2 permit and bounded count. - -- [ ] **Step 4: Run contracts on Redis 7.2, 7.4, and 8.2** - -```bash -./gradlew :modules:redis:redis-testkit:redis72Test \ - :modules:redis:redis-testkit:redis74Test \ - :modules:redis:redis-testkit:redis82Test \ - --tests "*RedisValueOperationsContract" \ - --tests "*RedisKeyOperationsContract" -``` - -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add modules/redis/redis-core-lettuce modules/redis/redis-testkit -git commit -m "feat(redis): implement string and key ttl operations" -``` - ---- - -### Task 11: Hash operations와 field TTL version gate 구현 - -**Files:** -- Create: `modules/redis/redis-core-lettuce/src/main/java/io/backend/skeleton/redis/lettuce/operations/LettuceRedisHashOperations.java` -- Create: `modules/redis/redis-core-lettuce/src/main/java/io/backend/skeleton/redis/lettuce/operations/LettuceReactiveRedisHashOperations.java` -- Create: `modules/redis/redis-core-lettuce/src/main/java/io/backend/skeleton/redis/lettuce/operations/LettuceRedisHashFieldExpirationOperations.java` -- Test: `modules/redis/redis-testkit/src/test/java/io/backend/skeleton/redis/contract/RedisHashOperationsContract.java` -- Test: `modules/redis/redis-testkit/src/test/java/io/backend/skeleton/redis/contract/RedisHashFieldExpirationContract.java` - -**Interfaces:** -- Implement design section 10.2 exactly. -- `entries` is R2 and requires budget. -- field TTL bean requires Redis 7.4 or later. - -- [ ] **Step 1: Write failing hash contracts** - -```java -@Test -void entriesRejectsReplyAboveBudget() { - HashKey key = keys.hash("profile", "1", codecs.string(), codecs.string()); - operations.hashes().putAll(key, Map.of("a", "1", "b", "2")); - - assertThatThrownBy(() -> operations.hashes().entries( - key, - permits.advanced("test"), - new OperationBudget(1, 1024, 1024, Duration.ofSeconds(1)) - )).isInstanceOf(RedisCommandRejectedException.class); -} -``` - -- [ ] **Step 2: Run hash contracts** - -```bash -./gradlew :modules:redis:redis-testkit:test --tests "*RedisHash*Contract" -``` - -Expected: FAIL. - -- [ ] **Step 3: Implement hash CRUD, scan, bounded entries, and field TTL** - -For Redis 7.2, the starter must not register `RedisHashFieldExpirationOperations`. For Redis 7.4+, register it after capability probe. For Redis 8.0+, enable get/set-plus-field-expiration optimized commands without changing the public contract. - -- [ ] **Step 4: Run version-gated tests** - -```bash -./gradlew :modules:redis:redis-testkit:redis72Test \ - :modules:redis:redis-testkit:redis74Test \ - :modules:redis:redis-testkit:redis82Test \ - --tests "*RedisHash*Contract" -``` - -Expected: PASS. Redis 7.2 test asserts the field-expiration bean is absent. - -- [ ] **Step 5: Commit** - -```bash -git add modules/redis/redis-core-lettuce modules/redis/redis-testkit -git commit -m "feat(redis): implement hash operations and field ttl" -``` - ---- - -### Task 12: Set와 Sorted Set operations 구현 - -**Files:** -- Create: `modules/redis/redis-core-lettuce/src/main/java/io/backend/skeleton/redis/lettuce/operations/LettuceRedisSetOperations.java` -- Create: `modules/redis/redis-core-lettuce/src/main/java/io/backend/skeleton/redis/lettuce/operations/LettuceReactiveRedisSetOperations.java` -- Create: `modules/redis/redis-core-lettuce/src/main/java/io/backend/skeleton/redis/lettuce/operations/LettuceRedisSortedSetOperations.java` -- Create: `modules/redis/redis-core-lettuce/src/main/java/io/backend/skeleton/redis/lettuce/operations/LettuceReactiveRedisSortedSetOperations.java` -- Test: `modules/redis/redis-testkit/src/test/java/io/backend/skeleton/redis/contract/RedisSetOperationsContract.java` -- Test: `modules/redis/redis-testkit/src/test/java/io/backend/skeleton/redis/contract/RedisSortedSetOperationsContract.java` - -**Interfaces:** -- Implement design sections 10.4 and 10.5. -- Union, intersection, difference and store variants are R2. -- Every multi-key operation validates same-slot before server execution. - -- [ ] **Step 1: Write failing same-slot and bounded-result tests** - -```java -@Test -void crossSlotIntersectionFailsBeforeRedisCall() { - SetKey one = keys.setWithSlot("set", "one", "slot-a", codecs.string()); - SetKey two = keys.setWithSlot("set", "two", "slot-b", codecs.string()); - - assertThatThrownBy(() -> operations.sets().intersection( - List.of(one, two), - permits.advanced("test"), - budgets.collection() - )).isInstanceOf(RedisCrossSlotException.class); -} -``` - -- [ ] **Step 2: Run contracts** - -```bash -./gradlew :modules:redis:redis-testkit:test \ - --tests "*RedisSetOperationsContract" \ - --tests "*RedisSortedSetOperationsContract" -``` - -Expected: FAIL. - -- [ ] **Step 3: Implement set and sorted-set adapters** - -Do not add `members()` or unbounded `rangeAll()` convenience methods. Use scan and bounded range models. Normalize reverse range commands through `SortDirection` rather than deprecated command-specific method names. - -- [ ] **Step 4: Run Standalone and Cluster contracts** - -```bash -./gradlew :modules:redis:redis-testkit:redis74Test \ - :modules:redis:redis-testkit:cluster74Test \ - --tests "*RedisSetOperationsContract" \ - --tests "*RedisSortedSetOperationsContract" -``` - -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add modules/redis/redis-core-lettuce modules/redis/redis-testkit -git commit -m "feat(redis): implement set and sorted set operations" -``` - ---- - -### Task 13: List operations와 Blocking 전용 pool 구현 - -**Files:** -- Create: `modules/redis/redis-core-lettuce/src/main/java/io/backend/skeleton/redis/lettuce/operations/LettuceRedisListOperations.java` -- Create: `modules/redis/redis-core-lettuce/src/main/java/io/backend/skeleton/redis/lettuce/operations/LettuceReactiveRedisListOperations.java` -- Create: `modules/redis/redis-core-lettuce/src/main/java/io/backend/skeleton/redis/lettuce/operations/LettuceRedisBlockingListOperations.java` -- Create: `modules/redis/redis-core-lettuce/src/main/java/io/backend/skeleton/redis/lettuce/connection/BlockingConnectionPool.java` -- Test: `modules/redis/redis-testkit/src/test/java/io/backend/skeleton/redis/contract/RedisListOperationsContract.java` -- Test: `modules/redis/redis-testkit/src/test/java/io/backend/skeleton/redis/contract/RedisBlockingListOperationsContract.java` - -**Interfaces:** -- Implement design section 10.3. -- Maximum server block is 30 seconds by default. -- Client timeout is server block plus 2 seconds. - -- [ ] **Step 1: Write failing cancellation and pool-isolation tests** - -```java -@Test -void cancellingBlockingPopReturnsConnectionToBlockingPool() { - Disposable subscription = reactiveBlockingLists.pop( - List.of(key), ListSide.LEFT, Duration.ofSeconds(10) - ).subscribe(); - - subscription.dispose(); - - await().atMost(Duration.ofSeconds(2)).untilAsserted(() -> - assertThat(blockingPool.borrowedCount()).isZero() - ); -} -``` - -- [ ] **Step 2: Run list contracts** - -```bash -./gradlew :modules:redis:redis-testkit:test --tests "*Redis*ListOperationsContract" -``` - -Expected: FAIL. - -- [ ] **Step 3: Implement list and blocking adapters** - -Map deprecated `RPOPLPUSH/BRPOPLPUSH` semantics to `LMOVE/BLMOVE`. Reject infinite block durations. Ensure blocking commands never use the regular connection registry entry. - -- [ ] **Step 4: Run tests with connection metrics assertions** - -```bash -./gradlew :modules:redis:redis-testkit:redis74Test --tests "*Redis*ListOperationsContract" -``` - -Expected: PASS. Regular pending command count remains unaffected during a blocking test. - -- [ ] **Step 5: Commit** - -```bash -git add modules/redis/redis-core-lettuce modules/redis/redis-testkit -git commit -m "feat(redis): add list and isolated blocking operations" -``` - ---- - -### Task 14: Bitmap, Bitfield, HyperLogLog, Geo operations 구현 - -**Files:** -- Create: `modules/redis/redis-core-lettuce/src/main/java/io/backend/skeleton/redis/lettuce/operations/LettuceRedisBitmapOperations.java` -- Create: `modules/redis/redis-core-lettuce/src/main/java/io/backend/skeleton/redis/lettuce/operations/LettuceRedisBitFieldOperations.java` -- Create: `modules/redis/redis-core-lettuce/src/main/java/io/backend/skeleton/redis/lettuce/operations/LettuceRedisHyperLogLogOperations.java` -- Create: `modules/redis/redis-core-lettuce/src/main/java/io/backend/skeleton/redis/lettuce/operations/LettuceRedisGeoOperations.java` -- Create: matching Reactive adapters -- Test: `modules/redis/redis-testkit/src/test/java/io/backend/skeleton/redis/contract/RedisSpecializedStructuresContract.java` - -**Interfaces:** -- Implement design sections 10.6–10.8. -- Bitmap offset and Geo count limits are configuration-backed. -- HyperLogLog contract states approximate cardinality. - -- [ ] **Step 1: Write failing boundary tests** - -```java -@Test -void bitmapRejectsOffsetAboveConfiguredMaximum() { - assertThatThrownBy(() -> operations.bitmaps().set(bitmapKey, 10_000_001L, true)) - .isInstanceOf(RedisCommandRejectedException.class); -} - -@Test -void geoSearchRequiresBoundedCount() { - assertThatThrownBy(() -> operations.geo().search( - geoKey, - GeoSearchRequest.withoutCount(origin, radius), - budgets.collection() - )).isInstanceOf(IllegalArgumentException.class); -} -``` - -- [ ] **Step 2: Run specialized structure contracts** - -```bash -./gradlew :modules:redis:redis-testkit:test --tests "*RedisSpecializedStructuresContract" -``` - -Expected: FAIL. - -- [ ] **Step 3: Implement sync and Reactive adapters** - -Normalize deprecated Geo radius commands to `GEOSEARCH`. Require explicit `BitFieldOverflow`. Validate same-slot for `BITOP`, HLL merge, and Geo store. - -- [ ] **Step 4: Run Standalone and Cluster tests** - -```bash -./gradlew :modules:redis:redis-testkit:redis74Test \ - :modules:redis:redis-testkit:cluster74Test \ - --tests "*RedisSpecializedStructuresContract" -``` - -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add modules/redis/redis-core-lettuce modules/redis/redis-testkit -git commit -m "feat(redis): add bitmap hll and geo operations" -``` - ---- - -### Task 15: Batch와 Pipeline 구현 - -**Files:** -- Create: `modules/redis/redis-core-lettuce/src/main/java/io/backend/skeleton/redis/lettuce/batch/RedisBatchBuilder.java` -- Create: `modules/redis/redis-core-lettuce/src/main/java/io/backend/skeleton/redis/lettuce/batch/LettuceRedisBatchOperations.java` -- Create: `modules/redis/redis-core-lettuce/src/main/java/io/backend/skeleton/redis/lettuce/batch/ClusterBatchPartitioner.java` -- Test: `modules/redis/redis-testkit/src/test/java/io/backend/skeleton/redis/contract/RedisBatchOperationsContract.java` -- Test: `modules/redis/redis-testkit/src/test/java/io/backend/skeleton/redis/contract/RedisClusterBatchContract.java` - -**Interfaces:** - -```java -public record RedisBatchResult(List> items) {} -``` - -- [ ] **Step 1: Write failing partial-result and ordering tests** - -```java -@Test -void preservesInputIndexAcrossNodePartitioning() { - RedisBatch batch = batchBuilder - .get(keyOnSlotOne) - .get(keyOnSlotTwo) - .wrongType(keyOnSlotOne) - .build(); - - RedisBatchResult result = operations.batches().execute(batch, batchOptions()); - - assertThat(result.items()).extracting(BatchItemResult::index) - .containsExactly(0, 1, 2); - assertThat(result.items().get(2).failed()).isTrue(); -} -``` - -- [ ] **Step 2: Run batch contracts** - -```bash -./gradlew :modules:redis:redis-testkit:test --tests "*Redis*Batch*Contract" -``` - -Expected: FAIL. - -- [ ] **Step 3: Implement command/byte caps, node partitioning, backpressure, and partial results** - -Do not wrap pipeline in transaction. Do not retry write batches. Reject batches over 500 commands, 4 MiB request, or 16 MiB expected reply using default configuration. - -- [ ] **Step 4: Run Standalone and Cluster batch tests** - -```bash -./gradlew :modules:redis:redis-testkit:redis74Test \ - :modules:redis:redis-testkit:cluster74Test \ - --tests "*Redis*Batch*Contract" -``` - -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add modules/redis/redis-core-lettuce/src/main/java/io/backend/skeleton/redis/lettuce/batch \ - modules/redis/redis-testkit -git commit -m "feat(redis): add bounded node aware pipelines" -``` - ---- - -### Task 16: Stream operations, pending recovery, version gate 구현 - -**Files:** -- Create: `modules/redis/redis-core-lettuce/src/main/java/io/backend/skeleton/redis/lettuce/stream/LettuceRedisStreamOperations.java` -- Create: `modules/redis/redis-core-lettuce/src/main/java/io/backend/skeleton/redis/lettuce/stream/LettuceReactiveRedisStreamOperations.java` -- Create: `modules/redis/redis-core-lettuce/src/main/java/io/backend/skeleton/redis/lettuce/stream/LettuceRedisBlockingStreamOperations.java` -- Create: `modules/redis/redis-core-lettuce/src/main/java/io/backend/skeleton/redis/lettuce/stream/Redis82StreamExtensions.java` -- Create: `modules/redis/redis-core-lettuce/src/main/java/io/backend/skeleton/redis/lettuce/stream/Redis88StreamExtensions.java` -- Test: `modules/redis/redis-testkit/src/test/java/io/backend/skeleton/redis/contract/RedisStreamOperationsContract.java` -- Test: `modules/redis/redis-testkit/src/test/java/io/backend/skeleton/redis/contract/RedisStreamRecoveryContract.java` - -**Interfaces:** -- Implement design section 10.9. -- Append requires `MAXLEN` or `MINID` trim policy. -- 8.2 and 8.8 extensions are separate conditional beans. - -- [ ] **Step 1: Write failing trim and pending recovery tests** - -```java -@Test -void appendRequiresTrimPolicy() { - assertThatThrownBy(() -> operations.streams().append( - streamKey, - event, - StreamAppendOptions.withoutTrim() - )).isInstanceOf(IllegalArgumentException.class); -} - -@Test -void autoClaimRecoversIdlePendingMessage() { - StreamRecord record = appendAndReadWithoutAck(); - ClaimResult claimed = operations.streams().autoClaim( - streamKey, group, consumerTwo, Duration.ofMillis(10), StreamId.ZERO, 10 - ); - assertThat(claimed.records()).extracting(StreamRecord::id).contains(record.id()); -} -``` - -- [ ] **Step 2: Run stream contracts** - -```bash -./gradlew :modules:redis:redis-testkit:test --tests "*RedisStream*Contract" -``` - -Expected: FAIL. - -- [ ] **Step 3: Implement stream CRUD, groups, pending, claim, blocking read, and metrics** - -Register `Redis82StreamExtensions` only when `XACKDEL` and `XDELEX` are present. Register `Redis88StreamExtensions` only when `XNACK` is present. Expose pending count, oldest idle duration, claim count, and consumer lag metrics without stream key labels. - -- [ ] **Step 4: Run version and recovery tests** - -```bash -./gradlew :modules:redis:redis-testkit:redis74Test \ - :modules:redis:redis-testkit:redis82Test \ - :modules:redis:redis-testkit:redis810Test \ - --tests "*RedisStream*Contract" -``` - -Expected: PASS with version-specific beans asserted. - -- [ ] **Step 5: Commit** - -```bash -git add modules/redis/redis-core-lettuce modules/redis/redis-testkit -git commit -m "feat(redis): implement streams and pending recovery" -``` - ---- - -### Task 17: Pub/Sub과 Sharded Pub/Sub 구현 - -**Files:** -- Create: `modules/redis/redis-core-lettuce/src/main/java/io/backend/skeleton/redis/lettuce/pubsub/LettuceRedisPubSubOperations.java` -- Create: `modules/redis/redis-core-lettuce/src/main/java/io/backend/skeleton/redis/lettuce/pubsub/LettuceRedisShardedPubSubOperations.java` -- Create: `modules/redis/redis-core-lettuce/src/main/java/io/backend/skeleton/redis/lettuce/pubsub/SubscriptionRegistry.java` -- Test: `modules/redis/redis-testkit/src/test/java/io/backend/skeleton/redis/contract/RedisPubSubOperationsContract.java` -- Test: `modules/redis/redis-testkit/src/test/java/io/backend/skeleton/redis/contract/RedisPubSubLossSemanticsTest.java` - -**Interfaces:** -- Implement design section 10.10. -- Pub/Sub uses dedicated connection. -- Cluster defaults to Sharded Pub/Sub. - -- [ ] **Step 1: Write failing subscription lifecycle test** - -```java -@Test -void closeUnsubscribesAndReturnsConnection() { - Subscription subscription = operations.pubSub().subscribe( - List.of(channel), messages::add - ); - - subscription.close(); - - await().untilAsserted(() -> assertThat(subscriptionRegistry.activeCount()).isZero()); -} -``` - -- [ ] **Step 2: Run Pub/Sub contracts** - -```bash -./gradlew :modules:redis:redis-testkit:test --tests "*RedisPubSub*" -``` - -Expected: FAIL. - -- [ ] **Step 3: Implement regular and sharded subscription adapters** - -Handle reconnect and resubscribe without claiming recovery of missed messages. Reject use of Pub/Sub API as a `DurableMessagePublisher` through type separation and architecture test. - -- [ ] **Step 4: Run Standalone and Cluster tests** - -```bash -./gradlew :modules:redis:redis-testkit:redis74Test \ - :modules:redis:redis-testkit:cluster74Test \ - --tests "*RedisPubSub*" -``` - -Expected: PASS. Loss-semantics test confirms messages sent during disconnect are not synthesized after reconnect. - -- [ ] **Step 5: Commit** - -```bash -git add modules/redis/redis-core-lettuce modules/redis/redis-testkit -git commit -m "feat(redis): add pubsub and sharded pubsub" -``` - ---- - -### Task 18: Sentinel failover와 결과 상태 분류 구현 - -**Files:** -- Create: `modules/redis/redis-core-lettuce/src/main/java/io/backend/skeleton/redis/lettuce/connection/SentinelFailoverObserver.java` -- Create: `modules/redis/redis-core-lettuce/src/main/java/io/backend/skeleton/redis/lettuce/command/ExecutionCertainty.java` -- Modify: `modules/redis/redis-testkit/src/main/java/io/backend/skeleton/redis/testkit/SentinelRedisEnvironment.java` -- Test: `modules/redis/redis-testkit/src/test/java/io/backend/skeleton/redis/fault/SentinelFailoverContract.java` - -**Interfaces:** - -```java -public enum ExecutionCertainty { - CONFIRMED_SUCCESS, - CONFIRMED_FAILURE, - SAFE_TO_RETRY_FAILURE, - AMBIGUOUS_FAILURE -} -``` - -- [ ] **Step 1: Write failing promotion tests** - -```java -@Test -void nonIdempotentWriteIsNeverBlindlyRetriedDuringPromotion() { - faultController.pausePrimaryAfterCommandRead(); - - assertThatThrownBy(() -> operations.values().increment(counterKey, 1, new Expiration.Persistent(testPermit()))) - .isInstanceOf(RedisAmbiguousExecutionException.class); - - assertThat(metrics.retryCountFor("INCR")).isZero(); -} -``` - -- [ ] **Step 2: Run Sentinel fault test** - -```bash -./gradlew :modules:redis:redis-testkit:sentinel74Test --tests "*SentinelFailoverContract" -``` - -Expected: FAIL. - -- [ ] **Step 3: Implement failover observer, bounded reconnect queue, and certainty classification** - -The observer records primary switch, reconnect duration, queued command count, and ambiguous write count. Reads may retry according to the fixed retry matrix; writes may not retry after possible server execution. - -- [ ] **Step 4: Run Sentinel 7.4 and 8.2 tests** - -```bash -./gradlew :modules:redis:redis-testkit:sentinel74Test \ - :modules:redis:redis-testkit:sentinel82Test \ - --tests "*SentinelFailoverContract" -``` - -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add modules/redis/redis-core-lettuce modules/redis/redis-testkit -git commit -m "feat(redis): model sentinel failover certainty" -``` - ---- - -### Task 19: Cluster slot, redirect, topology, node-local scan 구현 - -**Files:** -- Create: `modules/redis/redis-cluster/src/main/java/io/backend/skeleton/redis/cluster/RedisSlotCalculator.java` -- Create: `modules/redis/redis-cluster/src/main/java/io/backend/skeleton/redis/cluster/SameSlotValidator.java` -- Create: `modules/redis/redis-cluster/src/main/java/io/backend/skeleton/redis/cluster/ClusterTopologyObserver.java` -- Create: `modules/redis/redis-cluster/src/main/java/io/backend/skeleton/redis/cluster/ClusterScanCursor.java` -- Modify: `modules/redis/redis-testkit/src/main/java/io/backend/skeleton/redis/testkit/ClusterRedisEnvironment.java` -- Test: `modules/redis/redis-cluster/src/test/java/io/backend/skeleton/redis/cluster/RedisSlotCalculatorTest.java` -- Test: `modules/redis/redis-testkit/src/test/java/io/backend/skeleton/redis/fault/RedisClusterContract.java` - -**Interfaces:** - -```java -public interface SameSlotValidator { - int requireSameSlot(Collection keys); -} -``` - -- [ ] **Step 1: Write failing hash-tag and CROSSSLOT tests** - -```java -@Test -void bracesControlSlotCalculation() { - assertThat(slotCalculator.slot("prod:svc:{user-1}:a")) - .isEqualTo(slotCalculator.slot("prod:svc:{user-1}:b")); -} -``` - -- [ ] **Step 2: Run cluster tests** - -```bash -./gradlew :modules:redis:redis-cluster:test \ - :modules:redis:redis-testkit:cluster74Test --tests "*RedisClusterContract" -``` - -Expected: FAIL. - -- [ ] **Step 3: Implement slot validation, redirect metrics, topology refresh, node-local scan aggregation** - -Handle `MOVED`, `ASK`, and bounded `TRYAGAIN` retries. `ClusterScanCursor` must retain per-node cursors and mark completion only after every current primary cursor reaches zero. It is not a snapshot. - -- [ ] **Step 4: Run resharding and promotion tests** - -```bash -./gradlew :modules:redis:redis-testkit:cluster74Test \ - :modules:redis:redis-testkit:cluster82Test \ - --tests "*RedisClusterContract" -``` - -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add modules/redis/redis-cluster modules/redis/redis-testkit -git commit -m "feat(redis): add slot aware cluster support" -``` - ---- - -### Task 20: WATCH/MULTI/EXEC transaction 구현 - -**Files:** -- Create: `modules/redis/redis-programmability/src/main/java/io/backend/skeleton/redis/programmability/RedisTransactionOperations.java` -- Create: `modules/redis/redis-programmability/src/main/java/io/backend/skeleton/redis/programmability/LettuceRedisTransactionOperations.java` -- Create: `modules/redis/redis-programmability/src/main/java/io/backend/skeleton/redis/programmability/TransactionConnectionScope.java` -- Test: `modules/redis/redis-testkit/src/test/java/io/backend/skeleton/redis/contract/RedisTransactionContract.java` - -**Interfaces:** - -```java -public interface RedisTransactionOperations { - TransactionResult watchAndExecute( - Collection watchedKeys, - RedisTransactionCallback callback, - TransactionOptions options - ); -} -``` - -- [ ] **Step 1: Write failing conflict and connection cleanup tests** - -```java -@Test -void watchConflictReturnsNotExecutedWithoutRollbackClaim() { - TransactionResult result = concurrentWatchConflict(); - assertThat(result.executed()).isFalse(); - assertThat(result.conflict()).isTrue(); -} - -@Test -void failedCallbackDoesNotLeaveConnectionInMultiState() { - assertThatThrownBy(this::executeFailingTransaction).isInstanceOf(RuntimeException.class); - assertThat(transactionPool.borrowAndPing()).isTrue(); -} -``` - -- [ ] **Step 2: Run transaction contracts** - -```bash -./gradlew :modules:redis:redis-testkit:redis74Test --tests "*RedisTransactionContract" -``` - -Expected: FAIL. - -- [ ] **Step 3: Implement dedicated connection scope and same-slot guard** - -Use `finally` to `DISCARD` or reset the connection. Preserve runtime command errors per result item and never describe them as rollback. Translate lost `EXEC` replies to ambiguous execution. - -- [ ] **Step 4: Run Standalone, Sentinel, and Cluster transaction tests** - -```bash -./gradlew :modules:redis:redis-testkit:redis74Test \ - :modules:redis:redis-testkit:sentinel74Test \ - :modules:redis:redis-testkit:cluster74Test \ - --tests "*RedisTransactionContract" -``` - -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add modules/redis/redis-programmability modules/redis/redis-testkit -git commit -m "feat(redis): add optimistic redis transactions" -``` - ---- - -### Task 21: 등록 Lua Script와 Redis Function 구현 - -**Files:** -- Create: `modules/redis/redis-programmability/src/main/java/io/backend/skeleton/redis/programmability/RegisteredRedisScript.java` -- Create: `modules/redis/redis-programmability/src/main/java/io/backend/skeleton/redis/programmability/RedisScriptRegistry.java` -- Create: `modules/redis/redis-programmability/src/main/java/io/backend/skeleton/redis/programmability/LettuceRedisScriptOperations.java` -- Create: `modules/redis/redis-programmability/src/main/java/io/backend/skeleton/redis/programmability/RedisFunctionLibrary.java` -- Create: `modules/redis/redis-programmability/src/main/resources/redis/scripts/increment-with-expiry.lua` -- Test: `modules/redis/redis-testkit/src/test/java/io/backend/skeleton/redis/contract/RedisProgrammabilityContract.java` - -**Interfaces:** - -```java -public record RegisteredRedisScript( - String id, - String sha256, - int maxKeys, - Duration timeout, - long maxReplyBytes, - RedisResultDecoder decoder -) {} -``` - -- [ ] **Step 1: Write failing allowlist and NOSCRIPT tests** - -```java -@Test -void rejectsUnregisteredScriptSource() { - assertThatThrownBy(() -> scripts.executeRaw("return 1", List.of(), List.of())) - .isInstanceOf(RedisCommandRejectedException.class); -} - -@Test -void reloadsRegisteredScriptOnceAfterNoScript() { - server.flushScriptCacheForTest(); - assertThat(scripts.execute(incrementWithExpiry, List.of(key), List.of(arg("1"), arg("60000")))) - .isEqualTo(1L); -} -``` - -- [ ] **Step 2: Run programmability tests** - -```bash -./gradlew :modules:redis:redis-testkit:redis74Test --tests "*RedisProgrammabilityContract" -``` - -Expected: FAIL. - -- [ ] **Step 3: Implement registry, checksum, key declaration, same-slot, timeout, reply budget** - -Do not expose raw script source execution. Function libraries use ID, semantic version, and checksum. Startup verifies enabled function libraries and server capability. - -- [ ] **Step 4: Run Standalone and Cluster tests** - -```bash -./gradlew :modules:redis:redis-testkit:redis74Test \ - :modules:redis:redis-testkit:cluster74Test \ - --tests "*RedisProgrammabilityContract" -``` - -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add modules/redis/redis-programmability modules/redis/redis-testkit -git commit -m "feat(redis): add registered scripts and functions" -``` - ---- - -### Task 22: 승인형 Raw Command Gateway 구현 - -**Files:** -- Create: `modules/redis/redis-raw-gateway/src/main/java/io/backend/skeleton/redis/raw/RedisRawGateway.java` -- Create: `modules/redis/redis-raw-gateway/src/main/java/io/backend/skeleton/redis/raw/ApprovedRawCommand.java` -- Create: `modules/redis/redis-raw-gateway/src/main/java/io/backend/skeleton/redis/raw/RawCommandPolicyToken.java` -- Create: `modules/redis/redis-raw-gateway/src/main/java/io/backend/skeleton/redis/raw/RawCommandAllowlist.java` -- Create: `modules/redis/redis-raw-gateway/src/main/java/io/backend/skeleton/redis/raw/RawCommandKeyExtractor.java` -- Create: `modules/redis/redis-raw-gateway/src/main/resources/redis/raw-command-allowlist.yml` -- Test: `modules/redis/redis-raw-gateway/src/test/java/io/backend/skeleton/redis/raw/RedisRawGatewaySecurityTest.java` - -**Interfaces:** - -```java -public interface RedisRawGateway { - R execute( - ApprovedRawCommand command, - List arguments, - RawCommandPolicyToken policyToken - ); -} -``` - -- [ ] **Step 1: Write failing security tests** - -```java -@Test -void blocksR3AndR4CommandsEvenWhenNamedInExternalFile() { - assertThatThrownBy(() -> gateway.execute( - approved("FLUSHALL"), List.of(), token - )).isInstanceOf(RedisCommandRejectedException.class); -} - -@Test -void rejectsKeyOutsideNamespace() { - assertThatThrownBy(() -> gateway.execute( - approved("GET"), List.of(arg("prod:other-service:key")), token - )).isInstanceOf(RedisCommandRejectedException.class); -} -``` - -- [ ] **Step 2: Run gateway tests** - -```bash -./gradlew :modules:redis:redis-raw-gateway:test --tests "*RedisRawGatewaySecurityTest" -``` - -Expected: FAIL. - -- [ ] **Step 3: Implement immutable approved descriptors and full guard chain** - -Enforce command/subcommand allowlist, version, official key extraction, namespace, same-slot, risk, request/reply bytes, timeout, registered decoder, and audit. Do not create an overload accepting arbitrary command strings. - -- [ ] **Step 4: Run unit and integration security tests** - -```bash -./gradlew :modules:redis:redis-raw-gateway:test \ - :modules:redis:redis-testkit:redis74Test \ - --tests "*RawGateway*" -``` - -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add modules/redis/redis-raw-gateway modules/redis/redis-testkit -git commit -m "feat(redis): add policy controlled raw gateway" -``` - ---- - -### Task 23: 별도 Admin Plane 구현 - -**Files:** -- Create: `modules/redis/redis-admin-plane/src/main/java/io/backend/skeleton/redis/admin/RedisAdminDiagnostics.java` -- Create: `modules/redis/redis-admin-plane/src/main/java/io/backend/skeleton/redis/admin/LettuceRedisAdminDiagnostics.java` -- Create: `modules/redis/redis-admin-plane/src/main/java/io/backend/skeleton/redis/admin/AdminCommandProjection.java` -- Test: `modules/redis/redis-admin-plane/src/test/java/io/backend/skeleton/redis/admin/RedisAdminDiagnosticsTest.java` -- Test: `modules/redis/redis-admin-plane/src/test/java/io/backend/skeleton/redis/admin/RedisAdminForbiddenCommandsTest.java` - -**Interfaces:** - -```java -public interface RedisAdminDiagnostics { - RedisInfoSnapshot info(Set sections); - OptionalLong memoryUsage(QualifiedRedisKey key); - List slowLog(int count); - List latencyLatest(); - ClusterDiagnostics clusterDiagnostics(); - AclDryRunResult aclDryRun(String username, ApprovedRawCommand command, List arguments); -} -``` - -- [ ] **Step 1: Write failing bean-isolation and forbidden-command tests** - -```java -@Test -void adminBeanIsAbsentInNormalApplicationProfile() { - contextRunner.run(context -> assertThat(context).doesNotHaveBean(RedisAdminDiagnostics.class)); -} - -@Test -void moduleHasNoFlushOrShutdownMethod() { - assertThat(Arrays.stream(RedisAdminDiagnostics.class.getMethods()).map(Method::getName)) - .noneMatch(name -> name.contains("flush") || name.contains("shutdown")); -} -``` - -- [ ] **Step 2: Run admin tests** - -```bash -./gradlew :modules:redis:redis-admin-plane:test -``` - -Expected: FAIL. - -- [ ] **Step 3: Implement read-only projections and separate connection factory requirement** - -Sanitize `CLIENT LIST` and `INFO` fields. Require `backend.redis.admin.enabled=true` and separate admin credentials. Block mutating admin commands in the module and policy catalog. - -- [ ] **Step 4: Run tests** - -```bash -./gradlew :modules:redis:redis-admin-plane:test \ - :modules:redis:redis-spring-boot-starter:test --tests "*Admin*" -``` - -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add modules/redis/redis-admin-plane modules/redis/redis-spring-boot-starter -git commit -m "feat(redis): add isolated readonly admin plane" -``` - ---- - -### Task 24: Redis JSON과 Search 확장 모듈 구현 - -**Files:** -- Create: `modules/redis/extensions/redis-json/src/main/java/io/backend/skeleton/redis/json/RedisJsonOperations.java` -- Create: `modules/redis/extensions/redis-json/src/main/java/io/backend/skeleton/redis/json/LettuceRedisJsonOperations.java` -- Create: `modules/redis/extensions/redis-search/src/main/java/io/backend/skeleton/redis/search/RedisSearchOperations.java` -- Create: `modules/redis/extensions/redis-search/src/main/java/io/backend/skeleton/redis/search/LettuceRedisSearchOperations.java` -- Test: `modules/redis/redis-testkit/src/test/java/io/backend/skeleton/redis/extensions/RedisJsonContract.java` -- Test: `modules/redis/redis-testkit/src/test/java/io/backend/skeleton/redis/extensions/RedisSearchContract.java` - -**Interfaces:** -- JSON provides typed path get/set/delete/array/object operations. -- Search provides declared index schemas, query, aggregation, pagination, and vector query. -- Both modules require capability probe success. - -- [ ] **Step 1: Write failing conditional-bean tests** - -```java -@Test -void jsonBeanIsAbsentOnClassicRedisWithoutJsonCapability() { - classicRedisContext.run(context -> assertThat(context).doesNotHaveBean(RedisJsonOperations.class)); -} - -@Test -void enabledSearchFailsStartupWhenCapabilityIsMissing() { - classicRedisContext.withPropertyValues("backend.redis.search.enabled=true") - .run(context -> assertThat(context.getStartupFailure()) - .isInstanceOf(RedisCapabilityUnavailableException.class)); -} -``` - -- [ ] **Step 2: Run extension tests** - -```bash -./gradlew :modules:redis:extensions:redis-json:test \ - :modules:redis:extensions:redis-search:test -``` - -Expected: FAIL. - -- [ ] **Step 3: Implement independent capability-gated operations** - -Do not add JSON/Search commands to `redis-core-api`. Use the same namespace, codec, policy guard, timeout, exception, metric, trace, and ACL mechanisms as classic operations. - -- [ ] **Step 4: Run Redis 8 integrated extension tests** - -```bash -./gradlew :modules:redis:redis-testkit:redis82ExtensionsTest \ - --tests "*RedisJsonContract" \ - --tests "*RedisSearchContract" -``` - -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add modules/redis/extensions/redis-json modules/redis/extensions/redis-search modules/redis/redis-testkit -git commit -m "feat(redis): add json and search extensions" -``` - ---- - -### Task 25: Time Series와 Probabilistic 확장 모듈 구현 - -**Files:** -- Create: `modules/redis/extensions/redis-timeseries/src/main/java/io/backend/skeleton/redis/timeseries/RedisTimeSeriesOperations.java` -- Create: `modules/redis/extensions/redis-timeseries/src/main/java/io/backend/skeleton/redis/timeseries/LettuceRedisTimeSeriesOperations.java` -- Create: `modules/redis/extensions/redis-probabilistic/src/main/java/io/backend/skeleton/redis/probabilistic/RedisBloomOperations.java` -- Create: `modules/redis/extensions/redis-probabilistic/src/main/java/io/backend/skeleton/redis/probabilistic/RedisCuckooOperations.java` -- Create: `modules/redis/extensions/redis-probabilistic/src/main/java/io/backend/skeleton/redis/probabilistic/RedisCountMinSketchOperations.java` -- Create: `modules/redis/extensions/redis-probabilistic/src/main/java/io/backend/skeleton/redis/probabilistic/RedisTopKOperations.java` -- Create: `modules/redis/extensions/redis-probabilistic/src/main/java/io/backend/skeleton/redis/probabilistic/RedisTDigestOperations.java` -- Test: `modules/redis/redis-testkit/src/test/java/io/backend/skeleton/redis/extensions/RedisTimeSeriesContract.java` -- Test: `modules/redis/redis-testkit/src/test/java/io/backend/skeleton/redis/extensions/RedisProbabilisticContract.java` - -**Interfaces:** -- Each probabilistic structure exposes its approximation/error contract in model types and Javadoc. -- Time Series range queries require bounded time range and result budget. - -- [ ] **Step 1: Write failing capability and approximation-contract tests** - -```java -@Test -void bloomResultIsTypedAsProbabilisticDecision() { - ProbabilisticDecision decision = bloom.mightContain(filterKey, "value"); - assertThat(decision).isIn(ProbabilisticDecision.POSSIBLY_PRESENT, ProbabilisticDecision.DEFINITELY_ABSENT); -} -``` - -- [ ] **Step 2: Run extension contracts** - -```bash -./gradlew :modules:redis:extensions:redis-timeseries:test \ - :modules:redis:extensions:redis-probabilistic:test -``` - -Expected: FAIL. - -- [ ] **Step 3: Implement independent extension adapters and budgets** - -Reuse core guardrails. Do not represent approximate structures as exact membership or exact count APIs. - -- [ ] **Step 4: Run Redis 8 extension tests** - -```bash -./gradlew :modules:redis:redis-testkit:redis82ExtensionsTest \ - --tests "*RedisTimeSeriesContract" \ - --tests "*RedisProbabilisticContract" -``` - -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add modules/redis/extensions/redis-timeseries modules/redis/extensions/redis-probabilistic modules/redis/redis-testkit -git commit -m "feat(redis): add timeseries and probabilistic extensions" -``` - ---- - -### Task 26: Testkit topology, network fault, ACL, performance harness 완성 - -**Files:** -- Modify: `modules/redis/redis-testkit/src/main/java/io/backend/skeleton/redis/testkit/StandaloneRedisEnvironment.java` -- Modify: `modules/redis/redis-testkit/src/main/java/io/backend/skeleton/redis/testkit/SentinelRedisEnvironment.java` -- Modify: `modules/redis/redis-testkit/src/main/java/io/backend/skeleton/redis/testkit/ClusterRedisEnvironment.java` -- Create: `modules/redis/redis-testkit/src/main/java/io/backend/skeleton/redis/testkit/RedisFaultController.java` -- Create: `modules/redis/redis-testkit/src/test/java/io/backend/skeleton/redis/security/RedisAclContract.java` -- Create: `modules/redis/redis-testkit/src/test/java/io/backend/skeleton/redis/performance/RedisGuardrailPerformanceTest.java` -- Create: `infra/redis/standalone/compose.yml` -- Create: `infra/redis/sentinel/compose.yml` -- Create: `infra/redis/cluster/compose.yml` -- Create: `infra/redis/acl/application.acl` -- Create: `infra/redis/acl/application-advanced.acl` -- Create: `infra/redis/acl/admin-readonly.acl` - -**Interfaces:** -- Test environments expose endpoint, credentials, deployment mode, fault controller, and cleanup. -- Fault controller injects latency, packet loss, disconnect, response loss, promotion, and partial node partition. - -- [ ] **Step 1: Write failing ACL and fault tests** - -```java -@Test -void applicationUserCannotExecuteKeysOrFlushAll() { - assertThat(command("ACL", "DRYRUN", applicationUser, "KEYS", "*")).contains("command not allowed"); - assertThat(command("ACL", "DRYRUN", applicationUser, "FLUSHALL")).contains("command not allowed"); -} -``` - -```java -@Test -void responseLossOnIncrementProducesAmbiguousFailureWithoutRetry() { - faults.dropNextResponseAfterServerExecution(); - assertThatThrownBy(() -> operations.values().increment(counterKey, 1, expiration)) - .isInstanceOf(RedisAmbiguousExecutionException.class); -} -``` - -- [ ] **Step 2: Run security and fault tests** - -```bash -./gradlew :modules:redis:redis-testkit:test \ - --tests "*RedisAclContract" \ - --tests "*RedisGuardrailPerformanceTest" -``` - -Expected: FAIL. - -- [ ] **Step 3: Complete the Task 8 topology environments with Toxiproxy faults, ACL files, and guardrail datasets** - -Datasets must include: - -```text -1 MiB String -100,000-field Hash -100,000-member Set -100,000-member Sorted Set -1,000,000-entry Stream with trim policy -500-command pipeline -``` - -Performance assertions record p50, p95, p99, max, JVM allocation, Redis CPU/memory, request/reply bytes, and pending queue. Tests fail on limit bypass, not on absolute production throughput. - -- [ ] **Step 4: Run the full topology suite** - -```bash -./gradlew \ - :modules:redis:redis-testkit:redis72Test \ - :modules:redis:redis-testkit:redis74Test \ - :modules:redis:redis-testkit:redis82Test \ - :modules:redis:redis-testkit:redis810Test \ - :modules:redis:redis-testkit:sentinel74Test \ - :modules:redis:redis-testkit:sentinel82Test \ - :modules:redis:redis-testkit:cluster74Test \ - :modules:redis:redis-testkit:cluster82Test -``` - -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add modules/redis/redis-testkit infra/redis -git commit -m "test(redis): add topology fault and acl harness" -``` - ---- - -### Task 27: CI matrix, support matrix, upgrade gate, 운영 문서 연결 - -**Files:** -- Create: `.github/workflows/redis-pr.yml` -- Create: `.github/workflows/redis-nightly.yml` -- Create: `.github/workflows/redis-release.yml` -- Create: `docs/redis/support-matrix.md` -- Create: `docs/redis/command-policy.md` -- Create: `docs/redis/operations.md` -- Create: `docs/redis/upgrade-guide.md` -- Create: `modules/redis/redis-core-lettuce/src/test/java/io/backend/skeleton/redis/lettuce/command/CommandCatalogDriftTest.java` -- Create: `modules/redis/redis-core-api/src/test/java/io/backend/skeleton/redis/api/PublicApiCompatibilityTest.java` - -**Interfaces:** -- PR matrix: Standalone 7.4 and 8.2. -- Nightly matrix: Standalone 7.2·7.4·8.2·8.10, Sentinel 7.4·8.2, Cluster 7.4·8.2. -- Release adds network faults, ACL, extensions, and performance guardrail jobs. - -- [ ] **Step 1: Write failing catalog drift and documentation sync tests** - -```java -@Test -void commandCatalogHasNoUnreviewedServerCommands() { - RedisCommandMetadataDiff diff = metadataClient.diffAgainstPolicy(); - assertThat(diff.requiresReview()) - .as(diff.toMarkdown()) - .isFalse(); -} -``` - -```java -@Test -void supportMatrixContainsEveryPublishedModule() { - assertThat(SupportMatrixParser.parse(Path.of("docs/redis/support-matrix.md")).modules()) - .containsAll(PublishedRedisModules.names()); -} -``` - -- [ ] **Step 2: Run drift tests** - -```bash -./gradlew :modules:redis:redis-core-lettuce:test --tests "*CommandCatalogDriftTest" \ - :modules:redis:redis-core-api:test --tests "*PublicApiCompatibilityTest" -``` - -Expected: FAIL because generated metadata and docs are not connected. - -- [ ] **Step 3: Implement workflows and generated support artifacts** - -`support-matrix.md` must list module, minimum Redis version, certified versions, topology, risk exposure, sync/reactive support, and known limitations. `upgrade-guide.md` must require command metadata diff, ACL regression, serializer golden bytes, topology suite, and rollback procedure before changing Redis or client versions. - -- [ ] **Step 4: Run the complete release verification locally** - -```bash -./gradlew clean check \ - :modules:redis:redis-testkit:redis72Test \ - :modules:redis:redis-testkit:redis74Test \ - :modules:redis:redis-testkit:redis82Test \ - :modules:redis:redis-testkit:redis810Test \ - :modules:redis:redis-testkit:sentinel74Test \ - :modules:redis:redis-testkit:sentinel82Test \ - :modules:redis:redis-testkit:cluster74Test \ - :modules:redis:redis-testkit:cluster82Test \ - :modules:redis:redis-testkit:redis82ExtensionsTest -``` - -Expected: exit code 0 and zero failed tests. - -- [ ] **Step 5: Commit** - -```bash -git add .github/workflows docs/redis modules/redis -git commit -m "ci(redis): enforce support and upgrade gates" -``` - ---- - -## 3. 작업 간 의존 순서 - -```text -Task 1 - -> Task 2 - -> Tasks 3, 4, 5, 6 - -> Task 7 - -> Task 8 - -> Task 9 - -> Tasks 10, 11, 12, 13, 14 - -> Task 15 - -> Tasks 16, 17 - -> Tasks 18, 19 - -> Tasks 20, 21 - -> Task 22 - -> Task 23 - -> Tasks 24, 25 - -> Task 26 - -> Task 27 -``` - -Task 10–14는 Task 9 이후 병렬 구현할 수 있다. Task 18과 Task 19도 독립 topology 환경에서 병렬 구현할 수 있다. Raw Gateway는 Task 2, 4, 6, 8, 9, 19가 완료된 이후에만 시작한다. - ---- - -## 4. 단계별 release 기준 - -### Milestone A — Core Alpha - -포함 Task: 1–9 - -완료 기준: - -- module graph -- command policy catalog -- key, codec, error, capability, permit, budget -- sync/reactive API -- topology probe -- policy-aware executor - -### Milestone B — Classic Structures Beta - -포함 Task: 10–17 - -완료 기준: - -- classic 자료구조 Typed API -- bounded collection operations -- batch/pipeline -- Stream -- Pub/Sub -- Standalone 7.4·8.2 contract suite - -### Milestone C — Distributed RC - -포함 Task: 18–23 - -완료 기준: - -- Sentinel failover semantics -- Cluster slot·redirect·topology -- transaction, script, function -- Raw Gateway -- Admin Plane -- ACL tests - -### Milestone D — Extensions and Release - -포함 Task: 24–27 - -완료 기준: - -- Redis 8 extensions -- full topology and fault suite -- command catalog drift gate -- CI and operations documentation -- release verification exit code 0 - ---- - -## 5. 구현자가 임의로 변경하면 안 되는 결정 - -- `RedisOperations`와 `ReactiveRedisOperations`를 하나의 generic async abstraction으로 합치지 않는다. -- `RedisTemplate` 또는 Lettuce command interface를 application에 직접 노출하지 않는다. -- convenience를 이유로 unbounded `entries`, `members`, `rangeAll`, `keys`를 추가하지 않는다. -- R2 permit와 budget을 optional parameter로 만들지 않는다. -- Raw Gateway에 arbitrary command string overload를 추가하지 않는다. -- Cluster cross-slot write를 자동 fan-out하지 않는다. -- non-idempotent write timeout을 자동 retry하지 않는다. -- Pub/Sub을 message durability abstraction에 연결하지 않는다. -- transaction result에 rollback 의미를 추가하지 않는다. -- Java serialization fallback을 추가하지 않는다. -- metric 또는 trace에 실제 key를 추가하지 않는다. - ---- - -## 6. 계획 자체 검증 체크리스트 - -- [ ] 설계서의 모든 module이 Task 1 또는 Task 24–25에 포함되어 있다. -- [ ] 설계서의 모든 classic 자료구조가 Task 10–17에 포함되어 있다. -- [ ] Standalone·Sentinel·Cluster가 각각 test task를 가진다. -- [ ] R1·R2·R3·R4 정책이 Task 2, 9, 22, 23, 26에 연결되어 있다. -- [ ] namespace, codec, TTL, timeout, retry, error, telemetry가 구현 task를 가진다. -- [ ] transaction, pipeline, script, function의 비보장이 테스트에 포함되어 있다. -- [ ] Raw Gateway가 core guardrail 뒤에 위치한다. -- [ ] command metadata drift와 ACL upgrade regression이 CI에 포함되어 있다. -- [ ] 계획에 미확정 표식이나 구현자 재판단 지시가 없다. -- [ ] 최종 release 명령이 전체 suite를 실행한다. - diff --git a/redis-superpowers-package/docs/superpowers/specs/2026-08-07-redis-wrapper-typed-api-design.md b/redis-superpowers-package/docs/superpowers/specs/2026-08-07-redis-wrapper-typed-api-design.md deleted file mode 100644 index 0d1fe53..0000000 --- a/redis-superpowers-package/docs/superpowers/specs/2026-08-07-redis-wrapper-typed-api-design.md +++ /dev/null @@ -1,1497 +0,0 @@ -# Redis Wrapper 및 Typed API 설계서 - -- **상태:** 구현 기준선 확정 -- **작성일:** 2026-08-07 -- **대상:** Spring 기반 Backend Skeleton의 공통 Redis SDK -- **입력 근거:** `붙여넣은 마크다운(1)(7).md` — Redis Open Source, Lettuce, Spring Data Redis 공식 문서 및 운영 사례를 정리한 심층 리서치 -- **문서 목적:** 구현 중 추가 설계 판단이나 반복 질문 없이 모듈 구조, 공개 API, 명령 노출 정책, 장애 의미론, 운영 통제, 테스트 및 완료 조건을 확정한다. - ---- - -## 1. 요약 - -이 설계는 Redis 자료구조와 명령을 폭넓게 즉시 사용할 수 있도록 제공하되, 모든 명령을 동일한 권한과 형태로 노출하지 않는다. - -최종 노출 모델은 다음 네 단계다. - -1. **Typed API:** 자료구조별 R1 명령과 bounded operation을 기본 제공한다. -2. **Advanced Typed API:** R2 고비용·Blocking·다중 키 명령은 명시적 permit와 `OperationBudget`을 요구한다. -3. **Approved Raw Gateway:** Typed API에 아직 포함되지 않은 R1·R2 명령을 사전 등록된 command descriptor로만 실행한다. -4. **Admin Plane:** R3 운영·관리 명령은 별도 모듈·계정·연결·배포 경로로 분리한다. R4 파괴적 명령은 SDK에서 실행하지 못한다. - -핵심 원칙은 다음과 같다. - -> 자료구조와 명령 지원 폭은 넓히되, namespace·직렬화·TTL·timeout·Cluster slot·위험 등급·관측성·ACL을 우회할 수 있는 범용 문자열 실행 API는 제공하지 않는다. - ---- - -## 2. 범위 - -### 2.1 포함 범위 - -- Redis Open Source classic 자료구조 - - String - - Hash - - List - - Set - - Sorted Set - - Bitmap - - Bitfield - - HyperLogLog - - Geospatial - - Stream - - Pub/Sub 및 Sharded Pub/Sub - - Key·TTL -- Pipeline과 명시적 Batch -- `WATCH/MULTI/EXEC` -- 등록형 Lua Script와 Redis Function -- Standalone, Sentinel, Cluster -- 동기 API와 Reactive API -- 명령 위험 등급 R1~R4 -- ACL, namespace, 직렬화, version gate, timeout, retry, 오류 변환, metric, trace, audit -- Raw Command Gateway -- Redis 8 확장 기능의 독립 모듈 - - JSON - - Search 및 Vector Query - - Time Series - - Probabilistic 자료구조 -- 계약·통합·동시성·장애·성능·보안 테스트 - -### 2.2 제외 범위 - -- 비즈니스 정책 - - 도메인별 TTL - - 사용자 등급별 요청 제한 - - 주문·결제·채팅 등의 업무 흐름 -- Redis를 업무의 유일한 강한 정합성 저장소로 가정하는 기능 -- 임의 문자열 기반 `execute(String, byte[]...)` -- R4 파괴적 명령 실행 -- Redis Cluster에서 cross-slot 다중 키 연산의 자동 분산 실행 -- Pipeline을 transaction으로 표현하는 API -- Redis transaction을 관계형 데이터베이스 rollback 모델로 표현하는 API -- Pub/Sub을 durable messaging으로 표현하는 API -- 자동 blind retry로 결과 불명 write를 재실행하는 기능 - ---- - -## 3. 입력 자료의 제약과 처리 원칙 - -첨부된 Markdown은 309개 명령·기능 항목이 포함된 Excel 워크북을 참조하지만, 현재 작업 공간에는 Markdown만 존재한다. 따라서 다음 원칙을 적용한다. - -1. 이 설계서는 Markdown에 명시된 지원 기준, 위험 등급, API 방향, 운영 정책, 테스트 및 구현 순서를 그대로 기준선으로 사용한다. -2. 309행의 정확한 초기 분류는 구현 과정에서 Redis 공식 `COMMAND DOCS`, `COMMAND INFO`, `COMMAND GETKEYSANDFLAGS` 결과로 재생성한다. -3. 공식 metadata로 결정할 수 없는 조직 정책은 `redis-command-policy.yml` 오버레이에 명시한다. -4. 향후 Excel 워크북이 제공되면 오버레이 import 도구로 병합하되, 코드에 수작업으로 중복 입력하지 않는다. - ---- - -## 4. 설계 결정 - -| ID | 결정 | 근거와 결과 | -|---|---|---| -| D-01 | 기본 API는 자료구조별 Typed API로 한다. | 타입 안전성, namespace, codec, TTL, 위험 통제를 일관되게 강제한다. | -| D-02 | Typed API에 없는 기능은 승인형 Raw Gateway로 제공한다. | 최대 지원 폭을 확보하되 정책 우회는 차단한다. | -| D-03 | deprecated 명령명은 공개 API에 남기지 않는다. | `SETEX`, `SETNX`, 역방향 range 등은 최신 의미의 메서드와 옵션으로 통합한다. | -| D-04 | R1은 기본, R2는 permit+budget, R3는 admin plane, R4는 차단한다. | 성능과 운영 위험을 권한·구성·ACL에 반영한다. | -| D-05 | 공개 프로그래밍 모델은 동기와 Reactive 두 축이다. | Lettuce native async는 내부 구현 또는 명시적 고급 API로만 사용한다. | -| D-06 | 기능 최소 버전은 Redis 7.2다. | 주 인증은 7.4·8.2, 최신 호환성은 8.10으로 검증한다. | -| D-07 | Standalone·Sentinel은 완전 지원, Cluster는 slot 제약을 공개 계약에 반영한 조건부 완전 지원이다. | 다중 키는 same-slot을 사전 검증하고 DB 0만 허용한다. | -| D-08 | classic Redis와 Redis 8 확장 기능을 모듈로 분리한다. | Redis 7, managed Redis, Redis 8 통합 배포 간 호환성을 보존한다. | -| D-09 | 일반·Blocking·Transaction·Pub/Sub·Admin 연결을 분리한다. | shared connection 오염과 장애 전파를 방지한다. | -| D-10 | timeout 후 write는 `ambiguousExecution`을 구분한다. | 자동 retry 여부를 호출자가 정확히 판단할 수 있게 한다. | -| D-11 | command catalog와 지원 매트릭스는 서버 metadata와 정책 파일로 생성한다. | 새 명령, deprecated, ACL category, key spec 변화를 CI에서 탐지한다. | -| D-12 | 모든 collection read와 batch는 bounded API로 설계한다. | Big Key, 응답 폭증, JVM heap pressure를 구조적으로 제한한다. | - ---- - -## 5. 지원 기준 - -### 5.1 Redis 버전 - -| 프로파일 | 용도 | 지원 정책 | -|---|---|---| -| Redis 7.2 | 기능 최소선 | 기본 API가 반드시 동작해야 한다. | -| Redis 7.4 | 주 인증 | Hash field TTL 기능을 version-gated module로 인증한다. | -| Redis 8.2 | 주 인증 | Redis 8 LTS 및 향상된 Stream 기능을 인증한다. | -| Redis 8.10 | 최신 호환성 | 기존 API와 command policy가 깨지지 않는지 검증한다. | -| Redis 6.2 | 제한적 유지 | 신규 기능은 제공하지 않고 마이그레이션 호환성만 별도 job에서 확인한다. | -| Redis 7.2 미만 | 기본 비지원 | 신규 프로젝트 대상에서 제외한다. | - -### 5.2 클라이언트와 프레임워크 - -- 공개 Spring 통합: Spring Data Redis 4.1 계열 -- 드라이버: Lettuce 7.6 계열 -- 테스트: JUnit 5, Testcontainers, Toxiproxy -- Reactive 계약: Reactor `Mono`와 `Flux` -- Java 기준선: Java 21 -- 빌드: Gradle Kotlin DSL 멀티모듈 - -Java·Gradle 기준선은 현재 저장소가 제공되지 않은 상태에서 이 문서를 실행 가능한 기준으로 만들기 위한 구현 가정이다. 실제 저장소가 더 높은 기준선을 사용하면 상향 적용하되 API 계약은 변경하지 않는다. - -### 5.3 배포 모드 - -| 기능 | Standalone | Sentinel | Cluster | -|---|---|---|---| -| 단일 키 read/write | 지원 | 지원 | 지원 | -| 다중 키 명령 | 지원 | 지원 | same-slot만 지원 | -| Pipeline | 지원 | 지원 | node별 분할 | -| Transaction | 지원 | 지원 | same-slot만 지원 | -| Lua/Function | 지원 | 지원 | 선언 key same-slot | -| Blocking | 전용 연결 | 전용 연결·failover 처리 | slot별 전용 연결 | -| Pub/Sub | 지원 | 재구독 손실 의미 노출 | Sharded Pub/Sub 우선 | -| Replica read | 선택 | stale 정책 필수 | stale 정책 필수 | -| DB index | 설정 가능 | 설정 가능 | 0만 허용 | -| SCAN | instance 범위 | 현재 primary 범위 | node별 scan aggregation | - ---- - -## 6. 전체 아키텍처 - -```mermaid -flowchart TB - APP[Application Modules] - - subgraph Public API - SYNC[redis-core-api\nSync Typed API] - REACTIVE[redis-core-api\nReactive Typed API] - ADV[Advanced Typed API\nR2 Permit + Budget] - RAW[redis-raw-gateway\nApproved Commands] - end - - subgraph Policy and Runtime - CAT[Command Catalog\nVersion/Risk/Key Spec] - GUARD[Policy Guard\nNamespace/Slot/Size/ACL] - CODEC[Codec Registry\nSchema Envelope] - EXEC[Command Executor\nTimeout/Error/Retry/Telemetry] - end - - subgraph Connections - REG[Regular Connection] - BLOCK[Blocking Pool] - TX[Transaction Connection] - PUB[Pub/Sub Connection] - ADMIN[Admin Connection] - end - - subgraph Redis Deployment - STD[Standalone] - SEN[Sentinel] - CLU[Cluster] - end - - APP --> SYNC - APP --> REACTIVE - APP --> ADV - APP --> RAW - SYNC --> GUARD - REACTIVE --> GUARD - ADV --> GUARD - RAW --> GUARD - GUARD --> CAT - GUARD --> CODEC - GUARD --> EXEC - EXEC --> REG - EXEC --> BLOCK - EXEC --> TX - EXEC --> PUB - EXEC --> ADMIN - REG --> STD - REG --> SEN - REG --> CLU - BLOCK --> STD - BLOCK --> SEN - BLOCK --> CLU - TX --> STD - TX --> SEN - TX --> CLU - PUB --> STD - PUB --> SEN - PUB --> CLU -``` - -### 6.1 실행 흐름 - -1. 호출자는 자료구조별 Typed API 또는 승인형 Raw Gateway를 호출한다. -2. API는 `CommandRequest`를 생성한다. -3. `CommandPolicyGuard`가 서버 capability, 위험 등급, permit, namespace, key slot, request/reply 예산을 검증한다. -4. `RedisCodecRegistry`가 key·field·value를 직렬화한다. -5. `RedisCommandExecutor`가 명령 유형에 맞는 연결을 선택한다. -6. timeout, retry, exception translation, metric, trace, audit가 실행 경로 전체를 감싼다. -7. 결과는 드라이버 타입이 아닌 안정된 SDK 타입으로 반환한다. - ---- - -## 7. 모듈 구조 - -```text -backend-skeleton/ -├── modules/redis/ -│ ├── redis-core-api/ -│ ├── redis-core-lettuce/ -│ ├── redis-cluster/ -│ ├── redis-programmability/ -│ ├── redis-raw-gateway/ -│ ├── redis-admin-plane/ -│ ├── redis-spring-boot-starter/ -│ ├── redis-testkit/ -│ └── extensions/ -│ ├── redis-json/ -│ ├── redis-search/ -│ ├── redis-timeseries/ -│ └── redis-probabilistic/ -├── infra/redis/ -│ ├── standalone/ -│ ├── sentinel/ -│ ├── cluster/ -│ └── acl/ -└── docs/redis/ -``` - -| 모듈 | 책임 | 의존 규칙 | -|---|---|---| -| `redis-core-api` | 공개 타입, 동기·Reactive 자료구조 API, 오류 모델 | Spring Data·Lettuce에 의존하지 않는다. Reactor만 reactive package에 사용한다. | -| `redis-core-lettuce` | Spring Data Redis·Lettuce 구현, policy guard, codec, executor | `redis-core-api`에만 공개적으로 의존한다. | -| `redis-cluster` | CRC16 slot 계산, hash tag, same-slot, topology·redirect 관측 | Cluster 기능을 사용하지 않는 서비스에서 제외 가능하다. | -| `redis-programmability` | Transaction, 등록 Lua, Redis Function | 임의 script source를 받지 않는다. | -| `redis-raw-gateway` | allowlist 기반 R1·R2 Raw 실행 | core policy와 catalog를 우회하지 않는다. | -| `redis-admin-plane` | R3 진단·운영 조회 | 별도 계정·연결·배포 경로를 요구한다. | -| `redis-spring-boot-starter` | properties, auto-configuration, capability probe, bean 조건 | application module이 직접 Lettuce를 구성하지 않게 한다. | -| `redis-testkit` | Testcontainers topology, contract suite, fault injection | production module에서 의존하지 않는다. | -| `extensions/*` | Redis 8 또는 Stack 확장 기능 | classic core와 독립적으로 capability probe를 수행한다. | - ---- - -## 8. 공개 API 기본 모델 - -### 8.1 Key 모델 - -```java -package io.backend.skeleton.redis.api.key; - -public record RedisNamespace( - String environment, - String service, - String domain -) { - public RedisNamespace { - RedisKeyRules.requireToken("environment", environment); - RedisKeyRules.requireToken("service", service); - RedisKeyRules.requireToken("domain", domain); - } -} - -public record RedisKeyName(String entity, String identifier) { - public RedisKeyName { - RedisKeyRules.requireToken("entity", entity); - RedisKeyRules.requireIdentifier(identifier); - } -} - -public record RedisSlotTag(String value) { - public RedisSlotTag { - RedisKeyRules.requireIdentifier(value); - } -} - -public record QualifiedRedisKey( - RedisNamespace namespace, - RedisKeyName name, - Optional slotTag -) {} -``` - -렌더링 규칙은 다음과 같다. - -```text -일반 key: {environment}:{service}:{domain}:{entity}:{identifier} -slot key: {environment}:{service}:{domain}:{{slotTag}}:{entity}:{identifier} -``` - -제약: - -- UTF-8 기준 최대 512 bytes -- 이메일, 전화번호, access token, refresh token 원문 금지 -- 동적 전체 raw key 문자열 입력 금지 -- slot tag는 `RedisSlotTag`를 통해서만 생성 -- 저카디널리티 tag로 전체 tenant를 한 slot에 고정하는 사용을 금지 - -### 8.2 자료구조별 Typed Key - -```java -public sealed interface RedisTypedKey permits - ValueKey, HashKey, ListKey, SetKey, SortedSetKey, - BitmapKey, HyperLogLogKey, GeoKey, StreamKey { - QualifiedRedisKey key(); -} - -public record ValueKey(QualifiedRedisKey key, RedisCodec valueCodec) - implements RedisTypedKey {} - -public record HashKey( - QualifiedRedisKey key, - RedisCodec fieldCodec, - RedisCodec valueCodec -) implements RedisTypedKey {} -``` - -List, Set, Sorted Set, Bitmap, HyperLogLog, Geo, Stream도 동일한 원칙으로 자료구조별 key 타입을 제공한다. 서로 다른 자료구조 key는 컴파일 단계에서 같은 operations에 전달할 수 없다. - -### 8.3 Expiration - -```java -public sealed interface Expiration permits Expiration.Persistent, Expiration.After, Expiration.At { - record Persistent(PersistentKeyPermit permit) implements Expiration {} - record After(Duration duration) implements Expiration {} - record At(Instant instant) implements Expiration {} -} - -public enum ExpirationUpdatePolicy { - KEEP_EXISTING, - REPLACE, - ONLY_IF_NO_EXPIRY, - ONLY_IF_HAS_EXPIRY -} -``` - -- Cache, session, lock, idempotency, rate-limit API에서는 `Persistent`를 받지 않는다. -- `SET`과 TTL은 한 command 또는 등록 script로 원자화한다. -- 정확한 만료가 필요한 기능에는 TTL jitter를 적용하지 않는다. - -### 8.4 Permit 발급과 검증 - -Permit는 편의용 boolean flag가 아니라 R2·다중 키·영구 key 사용을 명시적으로 승인했다는 capability token이다. 다만 같은 JVM 안의 Java 타입만으로 보안 경계를 만들 수는 없으므로 최종 강제 수단은 Redis ACL과 bean 노출 정책이다. SDK 내부에서는 위조 permit가 guardrail을 우회하지 못하도록 발급자와 검증자를 분리한다. - -```java -public interface AdvancedOperationPermit { - String policyName(); -} - -public interface MultiKeyPermit { - String policyName(); -} - -public interface PersistentKeyPermit { - String policyName(); -} - -public interface RedisPolicyAuthority { - AdvancedOperationPermit issueAdvanced(String policyName); - MultiKeyPermit issueMultiKey(String policyName); - PersistentKeyPermit issuePersistentKey(String policyName); -} - -public interface RedisPermitVerifier { - void verify(AdvancedOperationPermit permit, String requiredPolicy); - void verify(MultiKeyPermit permit, String requiredPolicy); - void verify(PersistentKeyPermit permit, String requiredPolicy); -} -``` - -- permit 구현체는 starter 내부 package-private 클래스로 둔다. -- authority는 활성화된 정책 이름만 발급하며, 발급자 식별자와 서명을 permit 내부에 보관한다. -- verifier는 구현 타입, 발급자, 서명, 정책 이름을 모두 검사한다. -- 애플리케이션이 permit 인터페이스를 임의 구현해도 verifier를 통과하지 못한다. -- permit는 Redis ACL 권한을 확대하지 않는다. 해당 계정에 명령 권한이 없으면 실행은 실패한다. -- permit와 verifier bean은 `backend.redis.advanced.enabled=true`일 때만 등록한다. - -### 8.5 OperationBudget - -```java -public record OperationBudget( - int maxElements, - long maxRequestBytes, - long maxReplyBytes, - Duration timeout -) { - public OperationBudget { - if (maxElements < 1 || maxRequestBytes < 1 || maxReplyBytes < 1 || timeout.isZero() || timeout.isNegative()) { - throw new IllegalArgumentException("Operation budget must be positive"); - } - } -} -``` - -R2 API는 반드시 `AdvancedOperationPermit`와 `OperationBudget`을 요구한다. - -### 8.6 동기·Reactive 진입점 - -```java -public interface RedisOperations { - RedisValueOperations values(); - RedisHashOperations hashes(); - RedisListOperations lists(); - RedisSetOperations sets(); - RedisSortedSetOperations sortedSets(); - RedisBitmapOperations bitmaps(); - RedisBitFieldOperations bitFields(); - RedisHyperLogLogOperations hyperLogLogs(); - RedisGeoOperations geo(); - RedisStreamOperations streams(); - RedisKeyOperations keys(); - RedisBatchOperations batches(); -} - -public interface ReactiveRedisOperations { - ReactiveRedisValueOperations values(); - ReactiveRedisHashOperations hashes(); - ReactiveRedisListOperations lists(); - ReactiveRedisSetOperations sets(); - ReactiveRedisSortedSetOperations sortedSets(); - ReactiveRedisBitmapOperations bitmaps(); - ReactiveRedisBitFieldOperations bitFields(); - ReactiveRedisHyperLogLogOperations hyperLogLogs(); - ReactiveRedisGeoOperations geo(); - ReactiveRedisStreamOperations streams(); - ReactiveRedisKeyOperations keys(); - ReactiveRedisBatchOperations batches(); -} -``` - -동기와 Reactive API는 의미·이름·옵션 모델을 동일하게 유지한다. 반환 타입만 `Optional/List/...`와 `Mono/Flux`로 다르다. - ---- - -## 9. 명령 노출 정책 - -### 9.1 위험 등급 - -| 등급 | 의미 | 공개 정책 | -|---|---|---| -| R1 | bounded, 단일 키, 일반적인 빠른 명령 | 기본 Typed API | -| R2 | O(N), 무제한 반환 가능, Blocking, 다중 키, 큰 payload | Advanced Typed API 또는 승인형 Raw Gateway | -| R3 | 서버·클라이언트·ACL·토폴로지 운영 명령 | `redis-admin-plane`만 | -| R4 | 데이터 삭제, 서버 중단, replication/module 변경 등 파괴적 명령 | SDK 전체 차단 | - -### 9.2 명령 지원 상태 - -```java -public enum CommandSupport { - TYPED, - ADVANCED_TYPED, - RAW_ONLY, - ADMIN_ONLY, - VERSION_GATED, - BLOCKED -} -``` - -### 9.3 Command descriptor - -```java -public record RedisCommandDescriptor( - String command, - Optional subcommand, - RedisVersion minimumVersion, - RedisRiskLevel riskLevel, - CommandSupport support, - CommandAccess access, - boolean blocking, - boolean readOnly, - boolean retrySafe, - boolean mayBeAmbiguous, - KeySpec keySpec, - TimeoutProfile timeoutProfile -) {} -``` - -### 9.4 정책 SSOT - -`modules/redis/redis-core-lettuce/src/main/resources/redis-command-policy.yml`을 조직 정책 SSOT로 둔다. - -```yaml -commands: - GET: - minimum-version: "7.2" - risk: R1 - support: TYPED - access: APPLICATION - blocking: false - read-only: true - retry-safe: true - timeout-profile: FAST - HGETALL: - minimum-version: "7.2" - risk: R2 - support: ADVANCED_TYPED - access: APPLICATION_ADVANCED - blocking: false - read-only: true - retry-safe: true - timeout-profile: COLLECTION - KEYS: - minimum-version: "7.2" - risk: R4 - support: BLOCKED - access: NONE - blocking: false - read-only: true - retry-safe: false - timeout-profile: ADMIN -``` - -빌드 task는 공식 metadata와 이 파일을 비교한다. - -- 신규 command 또는 subcommand 탐지 -- deprecated 변경 탐지 -- ACL category 변경 탐지 -- key specification 변경 탐지 -- movable key 탐지 -- 위험 명령의 자동 허용 방지 - ---- - -## 10. 자료구조별 Typed API - -### 10.1 String - -```java -public interface RedisValueOperations { - Optional get(ValueKey key); - List> multiGet(List> keys, MultiKeyPermit permit); - void set(ValueKey key, V value, Expiration expiration); - boolean setIfAbsent(ValueKey key, V value, Expiration expiration); - boolean setIfPresent(ValueKey key, V value, Expiration expiration); - Optional getAndSet(ValueKey key, V value, Expiration expiration); - Optional getAndDelete(ValueKey key); - Optional getAndExpire(ValueKey key, Expiration expiration); - long increment(ValueKey key, long delta, Expiration expiration); - double increment(ValueKey key, double delta, Expiration expiration); - long append(ValueKey key, String suffix, OperationBudget budget); - long length(ValueKey key); - byte[] getRange(ValueKey key, long start, long end, OperationBudget budget); - long setRange(ValueKey key, long offset, byte[] value, OperationBudget budget); -} -``` - -정책: - -- `SETNX`, `SETEX`, `PSETEX`는 별도 메서드로 노출하지 않는다. -- `MGET/MSET/MSETNX`는 same-slot 또는 node grouping 정책을 명시하며, 원자성이 필요한 경우 same-slot만 허용한다. -- `LCS`는 R2 Advanced API로 둔다. -- `INCR`와 최초 TTL 설정은 script fallback 또는 version-gated `INCREX`로 한 번에 실행한다. - -### 10.2 Hash - -```java -public interface RedisHashOperations { - Optional get(HashKey key, F field); - Map> multiGet(HashKey key, Collection fields); - void put(HashKey key, F field, V value); - void putAll(HashKey key, Map values); - boolean putIfAbsent(HashKey key, F field, V value); - long delete(HashKey key, Collection fields); - boolean exists(HashKey key, F field); - long increment(HashKey key, F field, long delta); - double increment(HashKey key, F field, double delta); - long size(HashKey key); - ScanPage> scan(HashKey key, ScanRequest request); - Map entries(HashKey key, AdvancedOperationPermit permit, OperationBudget budget); -} -``` - -Version-gated module: - -```java -public interface RedisHashFieldExpirationOperations { - Map expireFields(HashKey key, Collection fields, Duration ttl); - Map> ttl(HashKey key, Collection fields); - Map persistFields(HashKey key, Collection fields, PersistentKeyPermit permit); -} -``` - -- `entries()`는 R2이며 budget 없이 호출할 수 없다. -- field TTL API는 Redis 7.4 이상에서만 bean이 등록된다. -- `HGETEX/HSETEX` 기반 복합 연산은 Redis 8.0 profile에서만 활성화한다. - -### 10.3 List - -```java -public interface RedisListOperations { - long pushLeft(ListKey key, Collection values); - long pushRight(ListKey key, Collection values); - long pushLeftIfPresent(ListKey key, V value); - long pushRightIfPresent(ListKey key, V value); - Optional popLeft(ListKey key); - Optional popRight(ListKey key); - List popLeft(ListKey key, int count); - List popRight(ListKey key, int count); - Optional index(ListKey key, long index); - void set(ListKey key, long index, V value); - long remove(ListKey key, long count, V value); - void trim(ListKey key, long start, long end); - List range(ListKey key, long start, long end, OperationBudget budget); - Optional move(ListKey source, ListKey destination, ListSide from, ListSide to, MultiKeyPermit permit); -} - -public interface RedisBlockingListOperations { - Optional> pop(Collection> keys, ListSide side, Duration block); - Optional move(ListKey source, ListKey destination, ListSide from, ListSide to, Duration block, MultiKeyPermit permit); -} -``` - -- Blocking API는 별도 bean과 전용 pool을 사용한다. -- 무한 block은 금지한다. -- `LRANGE 0 -1`은 budget이 충분하고 실제 length가 제한 이내일 때만 허용한다. - -### 10.4 Set - -```java -public interface RedisSetOperations { - long add(SetKey key, Collection values); - long remove(SetKey key, Collection values); - boolean isMember(SetKey key, V value); - Map multiIsMember(SetKey key, Collection values); - long size(SetKey key); - Optional pop(SetKey key); - List pop(SetKey key, int count); - List randomMembers(SetKey key, int count, boolean distinct); - ScanPage scan(SetKey key, ScanRequest request); - boolean move(SetKey source, SetKey destination, V value, MultiKeyPermit permit); - Set difference(Collection> keys, AdvancedOperationPermit permit, OperationBudget budget); - Set intersection(Collection> keys, AdvancedOperationPermit permit, OperationBudget budget); - Set union(Collection> keys, AdvancedOperationPermit permit, OperationBudget budget); -} -``` - -- `SMEMBERS` 대응 전체 반환은 제공하지 않는다. `scan` 또는 budget이 있는 set operation을 사용한다. -- 다중 키 연산은 same-slot을 사전 검증한다. -- store variants는 Advanced API로 제공한다. - -### 10.5 Sorted Set - -```java -public interface RedisSortedSetOperations { - boolean add(SortedSetKey key, V value, double score, SortedSetAddOptions options); - long addAll(SortedSetKey key, Collection> values, SortedSetAddOptions options); - double incrementScore(SortedSetKey key, V value, double delta); - long remove(SortedSetKey key, Collection values); - OptionalDouble score(SortedSetKey key, V value); - Map scores(SortedSetKey key, Collection values); - OptionalLong rank(SortedSetKey key, V value, SortDirection direction); - long size(SortedSetKey key); - long countByScore(SortedSetKey key, ScoreRange range); - List> rangeByRank(SortedSetKey key, RankRange range, SortDirection direction, OperationBudget budget); - List> rangeByScore(SortedSetKey key, ScoreRange range, PageRequest page, SortDirection direction, OperationBudget budget); - List rangeByLex(SortedSetKey key, LexRange range, PageRequest page, SortDirection direction, OperationBudget budget); - List> popMin(SortedSetKey key, int count); - List> popMax(SortedSetKey key, int count); - ScanPage> scan(SortedSetKey key, ScanRequest request); -} -``` - -Union, intersection, difference, store, blocking pop은 Advanced/Blocking API로 분리한다. - -### 10.6 Bitmap 및 Bitfield - -```java -public interface RedisBitmapOperations { - boolean get(BitmapKey key, long offset); - boolean set(BitmapKey key, long offset, boolean value); - long count(BitmapKey key, Optional byteRange); - OptionalLong position(BitmapKey key, boolean value, Optional byteRange); - long bitOperation(BitmapOperation operation, BitmapKey destination, Collection sources, MultiKeyPermit permit, OperationBudget budget); -} - -public interface RedisBitFieldOperations { - List execute(BitmapKey key, List commands, BitFieldOverflow overflow, OperationBudget budget); -} -``` - -- 최대 offset은 설정값으로 제한한다. -- `BITOP`은 same-slot과 reply budget을 검증한다. -- Bitfield overflow mode는 호출 시 명시한다. - -### 10.7 HyperLogLog - -```java -public interface RedisHyperLogLogOperations { - boolean add(HyperLogLogKey key, Collection values); - long count(Collection> keys, MultiKeyPermit permit); - void merge(HyperLogLogKey destination, Collection> sources, MultiKeyPermit permit); -} -``` - -반환값은 근사치이며 정확 cardinality 용도로 사용하지 않는다는 계약을 API 문서에 고정한다. - -### 10.8 Geospatial - -```java -public interface RedisGeoOperations { - long add(GeoKey key, Collection> locations); - Optional distance(GeoKey key, V from, V to, DistanceUnit unit); - Map> positions(GeoKey key, Collection members); - List> search(GeoKey key, GeoSearchRequest request, OperationBudget budget); - long searchStore(GeoKey source, GeoKey destination, GeoSearchRequest request, MultiKeyPermit permit, OperationBudget budget); -} -``` - -deprecated radius 계열은 공개하지 않고 `GEOSEARCH` 의미로 통합한다. - -### 10.9 Stream - -```java -public interface RedisStreamOperations { - StreamId append(StreamKey key, V value, StreamAppendOptions options); - long delete(StreamKey key, Collection ids); - long trim(StreamKey key, StreamTrimPolicy policy); - List> range(StreamKey key, StreamRange range, int count); - List> reverseRange(StreamKey key, StreamRange range, int count); - List> read(StreamKey key, StreamReadOffset offset, int count); - List> readGroup(StreamKey key, StreamGroup group, StreamConsumer consumer, StreamReadOffset offset, int count); - long acknowledge(StreamKey key, StreamGroup group, Collection ids); - PendingSummary pendingSummary(StreamKey key, StreamGroup group); - List pending(StreamKey key, StreamGroup group, PendingQuery query); - ClaimResult autoClaim(StreamKey key, StreamGroup group, StreamConsumer consumer, Duration minIdle, StreamId start, int count); - void createGroup(StreamKey key, StreamGroup group, StreamReadOffset offset, boolean createStream); - void destroyGroup(StreamKey key, StreamGroup group); - void createConsumer(StreamKey key, StreamGroup group, StreamConsumer consumer); - void deleteConsumer(StreamKey key, StreamGroup group, StreamConsumer consumer); -} - -public interface RedisBlockingStreamOperations { - List> read(StreamKey key, StreamReadOffset offset, int count, Duration block); - List> readGroup(StreamKey key, StreamGroup group, StreamConsumer consumer, StreamReadOffset offset, int count, Duration block); -} -``` - -정책: - -- `StreamAppendOptions`는 `MAXLEN` 또는 `MINID`를 반드시 요구한다. -- Consumer group 사용 시 pending age와 count metric을 제공한다. -- 중복 전달 가능성을 계약에 명시한다. -- Redis 8.2의 `XACKDEL/XDELEX`, 8.8의 `XNACK`은 별도 capability bean으로 제공한다. - -### 10.10 Pub/Sub - -```java -public interface RedisPubSubOperations { - long publish(PubSubChannel channel, V message); - Subscription subscribe(Collection> channels, RedisMessageHandler handler); - Subscription patternSubscribe(Collection> patterns, RedisMessageHandler handler); -} - -public interface RedisShardedPubSubOperations { - long publish(ShardedPubSubChannel channel, V message); - Subscription subscribe(Collection> channels, RedisMessageHandler handler); -} -``` - -- at-most-once 의미를 인터페이스 Javadoc과 문서에 명시한다. -- durable 업무 이벤트, 결제·주문·재처리 작업에는 사용하지 않는다. -- Cluster에서는 Sharded Pub/Sub을 기본 bean으로 우선한다. - -### 10.11 Key·TTL - -```java -public interface RedisKeyOperations { - boolean exists(QualifiedRedisKey key); - long exists(Collection keys, MultiKeyPermit permit); - RedisDataType type(QualifiedRedisKey key); - boolean touch(QualifiedRedisKey key); - long delete(Collection keys, MultiKeyPermit permit); - long unlink(Collection keys, MultiKeyPermit permit); - ExpirationResult expire(QualifiedRedisKey key, Duration ttl, ExpirationCondition condition); - ExpirationResult expireAt(QualifiedRedisKey key, Instant instant, ExpirationCondition condition); - Optional ttl(QualifiedRedisKey key); - boolean persist(QualifiedRedisKey key, PersistentKeyPermit permit); - boolean rename(QualifiedRedisKey source, QualifiedRedisKey destination, RenameMode mode, MultiKeyPermit permit); - ScanPage scan(ScanRequest request, AdvancedOperationPermit permit); -} -``` - -- `KEYS`는 차단한다. -- `SCAN`도 전체 비용이 O(N)이므로 R2 permit, page size, rate limit을 요구한다. -- 대형 key 삭제는 `UNLINK`를 우선하지만 batch와 rate limit을 적용한다. - ---- - -## 11. Batch와 Pipeline - -```java -public interface RedisBatchOperations { - RedisBatchResult execute(RedisBatch batch, BatchOptions options); -} - -public record BatchOptions( - int maxCommands, - long maxRequestBytes, - long maxReplyBytes, - int maxInFlightPerNode, - Duration timeout -) {} - -public record RedisBatchResult(List> items) { - public boolean hasPartialFailure() { - return items.stream().anyMatch(BatchItemResult::failed); - } -} -``` - -정책: - -- Pipeline은 원자적이지 않다. -- input index와 result index를 보존한다. -- Cluster에서는 node별로 분할하고 결과를 원래 순서로 재조합한다. -- write batch는 자동 retry하지 않는다. -- 최대 command 수, request bytes, 예상 reply bytes, in-flight를 모두 제한한다. -- 기본값: - - 최대 500 commands - - request 4 MiB - - reply 16 MiB - - node별 in-flight 2 - - timeout 2초 - ---- - -## 12. Transaction 및 서버 프로그래밍 - -### 12.1 Transaction - -```java -public interface RedisTransactionOperations { - TransactionResult watchAndExecute( - Collection watchedKeys, - RedisTransactionCallback callback, - TransactionOptions options - ); -} -``` - -- 전용 connection을 사용한다. -- `finally`에서 `DISCARD` 또는 connection reset을 보장한다. -- rollback이 없음을 공개 계약에 명시한다. -- Cluster에서는 watched key와 transaction key가 same-slot이어야 한다. -- `EXEC` 응답 유실은 `RedisAmbiguousExecutionException`으로 반환한다. - -### 12.2 등록 Lua Script - -```java -public record RegisteredRedisScript( - String id, - String sha256, - int maxKeys, - Duration timeout, - long maxReplyBytes, - RedisResultDecoder decoder -) {} - -public interface RedisScriptOperations { - R execute(RegisteredRedisScript script, List keys, List arguments); -} -``` - -- 런타임 script source 문자열을 받지 않는다. -- key는 전부 `KEYS` 인자로 선언한다. -- Cluster same-slot을 사전 검증한다. -- loop bound, 실행시간, reply size를 리뷰한다. -- `NOSCRIPT`는 등록 script에 한해 load 후 한 번 재실행한다. - -### 12.3 Redis Function - -Function library는 ID와 semantic version으로 관리한다. 배포 시 capability probe와 library checksum을 확인하며, 운영 중 동적 임의 function 등록은 지원하지 않는다. - ---- - -## 13. Raw Command Gateway - -### 13.1 공개 계약 - -```java -public interface RedisRawGateway { - R execute( - ApprovedRawCommand command, - List arguments, - RawCommandPolicyToken policyToken - ); -} - -public record ApprovedRawCommand( - String policyId, - RedisCommandDescriptor descriptor, - RedisResultDecoder decoder -) {} -``` - -### 13.2 강제 통제 - -1. command와 subcommand allowlist -2. 최소 Redis version 확인 -3. 공식 key specification 또는 `COMMAND GETKEYSANDFLAGS`로 key 추출 -4. namespace 확인 -5. same-slot 확인 -6. R3·R4 거부 -7. argument 수·request bytes·reply bytes 제한 -8. timeout profile 적용 -9. 등록 decoder만 허용 -10. 호출자·policyId·command family·결과·latency audit -11. key/value 원문 로그 금지 -12. Raw Gateway 전용 ACL user 사용 가능 - -일반 애플리케이션에는 `execute(String, byte[]...)` 형태를 제공하지 않는다. - ---- - -## 14. Admin Plane - -`redis-admin-plane`은 애플리케이션 request path와 분리한다. - -### 14.1 제공 범위 - -- read-only 진단 - - `INFO` - - `MEMORY USAGE` - - `SLOWLOG GET` - - `LATENCY LATEST` - - `CLIENT LIST`의 제한된 projection - - `CLUSTER INFO`, `CLUSTER SLOTS`, `CLUSTER SHARDS` - - `ACL DRYRUN` - - `COMMAND INFO` -- 운영 도구가 사용하는 node-local scan 및 big-key 후보 수집 - -### 14.2 차단 범위 - -- `FLUSHDB`, `FLUSHALL` -- `SHUTDOWN` -- `DEBUG` -- module unload -- replication·topology 변경 -- 광범위한 `CONFIG SET` -- 일반 애플리케이션 계정으로 ACL 변경 - -관리 plane은 별도 ACL account, 별도 connection factory, 별도 deployment profile을 요구한다. - ---- - -## 15. Connection 및 실행 모델 - -| 연결 종류 | 용도 | 공유 여부 | -|---|---|---| -| Regular | 일반 R1/R2 non-blocking 명령 | thread-safe shared 또는 제한 pool | -| Blocking | `BLPOP`, `BZPOP*`, `XREAD BLOCK` | 전용 pool | -| Transaction | `WATCH/MULTI/EXEC` | 호출당 전용 connection | -| Pub/Sub | subscribe lifecycle | subscription별 또는 제한 pool | -| Admin | R3 진단 | 별도 account·factory | - -기본 pool 제한: - -- Regular pending command queue: 1,000 -- Blocking 최대 동시 연결: 32 -- Transaction 최대 동시 연결: 16 -- Pub/Sub subscription connection: 16 -- queue 상한 초과 시 즉시 `RedisCommandRejectedException` - -Lettuce offline queue는 무제한으로 사용하지 않는다. timeout되거나 이미 취소된 command는 reconnect 후 replay하지 않는다. - ---- - -## 16. Timeout, Retry, 오류 의미론 - -### 16.1 Timeout profile - -| Profile | 기본값 | 대상 | -|---|---:|---| -| FAST | 500 ms | 단일 키 GET/SET, membership, score | -| COLLECTION | 2 s | bounded range, scan page, union/intersection | -| SCRIPT | 1 s | 등록 Lua/Function | -| BATCH | 2 s | pipeline/batch | -| ADMIN | 3 s | read-only 운영 조회 | -| BLOCKING | server block + 2 s | blocking API | - -기본값은 skeleton guardrail이며 서비스 SLO에 따라 더 짧게 재정의할 수 있다. 더 길게 설정할 때는 configuration validation 경고를 낸다. - -### 16.2 Retry matrix - -| 상황 | 자동 retry | -|---|---| -| 전송 전 실패가 확인된 read | 최대 2회, jittered backoff | -| idempotent read | 최대 2회 | -| `MOVED`, `ASK` | cluster client 처리 | -| resharding 중 `TRYAGAIN` | 최대 2회, 짧은 backoff | -| write 후 timeout | 금지 | -| `INCR`, `LPUSH`, `XADD` 결과 불명 | 금지 | -| transaction `EXEC` 결과 유실 | 금지 | -| script 결과 불명 | 금지 | -| 등록 script의 `NOSCRIPT` | load 후 1회 | - -### 16.3 예외 모델 - -```java -public class RedisOperationException extends RuntimeException { - private final RedisFailureMetadata metadata; -} - -public record RedisFailureMetadata( - String commandCategory, - CommandAccess access, - boolean readOperation, - boolean retryable, - boolean ambiguousExecution, - RedisVersion serverVersion, - RedisDeploymentMode deploymentMode, - OptionalInt slot, - Duration elapsed -) {} -``` - -하위 예외: - -- `RedisTimeoutException` -- `RedisConnectionException` -- `RedisAccessDeniedException` -- `RedisCrossSlotException` -- `RedisRedirectionException` -- `RedisBusyException` -- `RedisNoScriptException` -- `RedisSerializationException` -- `RedisDataTypeMismatchException` -- `RedisCommandRejectedException` -- `RedisCapabilityUnavailableException` -- `RedisAmbiguousExecutionException` - -key, value, credential, 전체 argument는 메시지에 포함하지 않는다. - ---- - -## 17. 직렬화와 schema - -### 17.1 기본 codec - -- key: UTF-8 String -- counter: Redis integer/double native representation -- object: versioned JSON 기본 -- 선택: CBOR, Protobuf -- Java native serialization: 금지 - -```java -public interface RedisCodec { - String id(); - byte[] encode(T value); - T decode(byte[] bytes) throws RedisSerializationException; -} - -public record RedisEnvelope( - String schema, - int version, - Instant createdAt, - byte[] payload -) {} -``` - -### 17.2 schema 변경 - -1. 호환 reader를 먼저 배포한다. -2. 필요 시 dual write 또는 read repair를 사용한다. -3. migration은 rate-limited SCAN으로 실행한다. -4. version별 read와 deserialize failure를 관측한다. -5. 기존 TTL 만료 또는 migration 완료 후 old reader를 제거한다. - -역직렬화 실패 처리: - -- Cache: miss fallback + corruption metric -- Session·idempotency·workflow: data corruption 예외 -- Raw Gateway: decoder failure로 명시 - -### 17.3 기본 크기 제한 - -- key: 512 bytes -- object value: 1 MiB -- Stream payload: 256 KiB -- Hash field value: 512 KiB -- Raw argument total: 4 MiB -- Raw reply: 16 MiB - -초과 시 Redis 호출 전에 거부한다. - ---- - -## 18. Cluster 설계 - -### 18.1 Slot-aware key codec - -- CRC16 slot을 client side에서 계산한다. -- multi-key 요청은 서버 호출 전에 same-slot을 검증한다. -- hash tag는 `RedisSlotTag`를 통해서만 지정한다. -- 모든 key가 동일 slot이어야 하는 API에는 `MultiKeyPermit`을 요구한다. - -### 18.2 Redirect와 topology - -관측 항목: - -- `MOVED` -- `ASK` -- `TRYAGAIN` -- topology refresh -- slot cache refresh -- node connection failure -- replica promotion - -### 18.3 제한 - -- DB 0 이외 설정은 startup failure -- cluster-wide SCAN은 node별 cursor를 가진 `ClusterScanCursor`로만 제공 -- node-local command를 전체 cluster 결과로 오인하지 않도록 결과 타입에 node id를 포함 -- cross-slot operation 자동 fan-out은 조회-only batch에서만 허용하고 원자성을 보장하지 않는다고 표시 - ---- - -## 19. Sentinel 및 failover - -- primary·replica·Sentinel endpoint를 startup에 검증한다. -- promotion 구간의 결과를 다음 네 가지로 분류한다. - - confirmed success - - confirmed failure - - safe-to-retry failure - - ambiguous failure -- non-idempotent write는 자동 retry하지 않는다. -- reconnect queue는 상한을 가진다. -- failover 후 stale replica read 허용 여부는 별도 `ReadConsistencyPolicy`로 명시한다. -- `WAIT`는 durability 가능성을 높이는 선택 기능일 뿐 강한 일관성으로 표현하지 않는다. - ---- - -## 20. ACL과 접근 제한 - -### 20.1 계정 분리 - -| 계정 | 권한 | -|---|---| -| application | R1 Typed API | -| application-advanced | 승인된 R2 command | -| raw-gateway | 등록된 R1·R2 command 및 namespace | -| admin-readonly | R3 read-only diagnostics | -| extension-* | JSON/Search/TimeSeries 등 사용 명령만 | - -### 20.2 원칙 - -- allowlist 방식 -- key pattern과 Pub/Sub channel pattern 제한 -- `+@all -@dangerous` 사용 금지 -- Redis 업그레이드 시 ACL regression test -- `ACL DRYRUN`과 실제 제한 계정 integration test를 모두 수행 - -예시: - -```text -on ->secret-from-runtime -~prod:order-service:* -&prod:order-events:* -+get +set +del +unlink -+hget +hset +hdel +hscan -+xadd +xreadgroup +xack +xautoclaim -``` - ---- - -## 21. 관측성 - -### 21.1 Metric - -| 이름 | 핵심 tag | -|---|---| -| `backend.redis.command.duration` | family, outcome, mode, risk | -| `backend.redis.command.request.bytes` | family, mode | -| `backend.redis.command.reply.bytes` | family, mode | -| `backend.redis.connection.active` | connection-kind, node | -| `backend.redis.connection.pending` | connection-kind | -| `backend.redis.connection.reconnects` | mode, node | -| `backend.redis.cluster.redirects` | type | -| `backend.redis.retry.count` | reason, ambiguous | -| `backend.redis.batch.size` | mode, outcome | -| `backend.redis.stream.pending` | namespace, group | -| `backend.redis.policy.rejections` | reason, risk | -| `backend.redis.serialization.failures` | codec, schema | - -실제 key, field, member, user ID는 tag에 넣지 않는다. - -### 21.2 Trace - -Span 이름: `redis.command` - -속성: - -- command family -- risk level -- read/write -- deployment mode -- connection kind -- slot 또는 node의 low-cardinality projection -- outcome -- retry count -- ambiguous execution - -### 21.3 Log와 audit - -- key와 value는 기본 마스킹 -- 식별이 필요하면 HMAC fingerprint -- Raw/Admin 호출은 caller, policyId, command family, result, elapsed를 audit -- authentication material은 절대 기록하지 않는다. - ---- - -## 22. Redis 8 확장 모듈 - -| 모듈 | 범위 | 활성화 조건 | -|---|---|---| -| `redis-json` | JSON get/set/path/array/object operations | capability probe 성공 | -| `redis-search` | index lifecycle, query, aggregation, vector query | Search capability와 schema 선언 | -| `redis-timeseries` | series create/add/range/aggregation/rules | Time Series capability | -| `redis-probabilistic` | Bloom, Cuckoo, CMS, Top-K, t-digest | capability별 bean | - -원칙: - -- classic core에 명령을 섞지 않는다. -- 시작 시 `COMMAND INFO` 또는 capability probe를 수행한다. -- 명시적으로 enable한 모듈의 capability가 없으면 startup failure다. -- Redis 8 통합 배포와 Redis 7 Stack 환경을 모두 테스트한다. -- 새 자료구조는 client 지원과 운영 안정성 검증 후 독립 API로 추가한다. - ---- - -## 23. Spring Boot 설정 - -```yaml -backend: - redis: - enabled: true - mode: standalone - nodes: - - localhost:6379 - database: 0 - ssl: - enabled: false - namespace: - environment: local - service: sample-service - domain: shared - timeout: - fast: 500ms - collection: 2s - script: 1s - batch: 2s - admin: 3s - limits: - max-key-bytes: 512 - max-value-bytes: 1MiB - max-stream-payload-bytes: 256KiB - max-collection-elements: 1000 - max-scan-count: 500 - max-batch-commands: 500 - max-batch-request-bytes: 4MiB - max-batch-reply-bytes: 16MiB - offline-queue-commands: 1000 - blocking: - max-connections: 32 - max-block: 30s - transaction: - max-connections: 16 - raw: - enabled: false - admin: - enabled: false -``` - -Validation: - -- Cluster에서 `database != 0`이면 startup failure -- namespace token 형식 위반 시 startup failure -- Fast timeout이 5초를 넘으면 warning, 30초를 넘으면 startup failure -- 무한 blocking 금지 -- Raw Gateway enable 시 allowlist와 별도 ACL credential 필수 -- extension enable 시 capability 미지원이면 startup failure - ---- - -## 24. 테스트 전략 - -### 24.1 토폴로지 매트릭스 - -| 실행 주기 | 환경 | -|---|---| -| PR | Standalone 7.4, Standalone 8.2 | -| Nightly | Standalone 7.2·7.4·8.2·8.10, Sentinel 7.4·8.2, Cluster 7.4·8.2 | -| Release | Nightly 전체 + Toxiproxy 장애 + Redis 8 extensions | -| Compatibility | Redis 6.2 제한 job | - -### 24.2 계약 테스트 - -각 Typed API 구현체는 동일한 contract suite를 통과한다. - -- 정상 결과 -- 없는 key/field -- WRONGTYPE -- 잘못된 argument -- 크기 경계 -- serialization 실패 -- ACL 거부 -- version 미지원 -- CROSSSLOT -- timeout - -### 24.3 동시성·원자성 - -- `INCR` -- `SET NX` -- `WATCH` conflict -- 등록 Lua conditional update -- rate limit window boundary -- idempotency script -- Stream duplicate delivery - -### 24.4 장애 - -- connection refused -- DNS 실패 -- connect/read timeout -- half-open TCP -- packet loss·latency -- 응답만 유실 -- Sentinel promotion -- Cluster replica promotion -- resharding과 `TRYAGAIN` -- 일부 node partition - -### 24.5 성능과 guardrail - -- p50/p95/p99/max -- Redis CPU·memory·output buffer -- JVM heap·allocation·GC -- request/reply bytes -- pipeline batch size와 in-flight -- big key delete/expire tail latency -- 대형 String, Hash, Set, ZSet, Stream, pipeline - -### 24.6 보안 - -- 금지 command/subcommand -- Raw Gateway 우회 -- Lua/Function 우회 -- namespace 밖 key -- channel pattern 위반 -- movable key extraction -- Redis 업그레이드 후 ACL category 변화 -- R3/R4 deny - ---- - -## 25. CI 품질 Gate - -모든 release는 다음을 통과해야 한다. - -1. command metadata diff가 승인됨 -2. Typed API와 support matrix가 일치함 -3. sync/reactive API parity test 통과 -4. unit/contract/integration test 통과 -5. Sentinel·Cluster 장애 test 통과 -6. ACL regression test 통과 -7. forbidden API 검사 통과 - - raw string command - - native Java serialization - - key/value metric tag - - 무한 blocking -8. API binary compatibility 검사 통과 -9. 문서의 support matrix와 생성된 catalog가 일치함 -10. performance baseline의 허용 regression 이내 - ---- - -## 26. 배포 및 사용 방식 - -### 26.1 기본 서비스 - -```kotlin -dependencies { - implementation(project(":modules:redis:redis-spring-boot-starter")) -} -``` - -기본으로 노출: - -- R1 Typed API -- Sync/Reactive -- Standalone/Sentinel -- 설정 시 Cluster -- metric, trace, health - -### 26.2 Advanced API - -```yaml -backend.redis.advanced.enabled: true -``` - -- R2 bean 등록 -- `AdvancedOperationPermit` 발급 bean 필요 -- ACL account에 승인된 R2 command만 추가 - -### 26.3 Raw Gateway - -```yaml -backend.redis.raw.enabled: true -backend.redis.raw.policy-resource: classpath:redis/raw-command-allowlist.yml -``` - -- 별도 credential 필수 -- 임의 command string 불가 - -### 26.4 Admin Plane - -일반 service process에는 포함하지 않는다. 운영 tool 또는 별도 profile에서만 실행한다. - ---- - -## 27. 비지원 및 오해 방지 문구 - -문서와 Javadoc에 다음 내용을 명시한다. - -- Redis Sentinel·Cluster의 승인 write가 failover 중 유실될 수 있다. -- timeout 후 write 결과는 알 수 없을 수 있다. -- Pipeline은 원자적이지 않다. -- Redis transaction은 rollback을 제공하지 않는다. -- `SCAN`은 snapshot이 아니며 중복·변경 영향을 받을 수 있다. -- Pub/Sub은 at-most-once이며 재연결 중 메시지가 유실된다. -- Stream consumer는 중복 전달을 처리해야 한다. -- HyperLogLog는 근사치다. -- Cluster multi-key는 same-slot이 필요하다. -- Raw Gateway는 안전성 보장이 아니라 제한된 확장 경로다. - ---- - -## 28. 완료 정의 - -| 산출물 | 완료 조건 | -|---|---| -| command 지원 매트릭스 | target Redis metadata와 자동 비교되고 신규 command가 CI를 실패시킨다. | -| 자료구조별 Typed API | classic 자료구조 전체에 sync/reactive API가 있으며 contract test를 통과한다. | -| 위험 등급 정책 | R1~R4가 code, bean exposure, ACL, Raw Gateway에 반영된다. | -| version gate | 7.2·7.4·8.2·8.10 capability가 자동 판별된다. | -| topology | Standalone·Sentinel·Cluster test가 통과한다. | -| common policy | namespace, codec, TTL, timeout, retry, error, telemetry가 모든 경로에 적용된다. | -| Blocking 분리 | 일반 connection과 blocking/transaction/pubsub/admin 연결이 격리된다. | -| Raw Gateway | allowlist, key extraction, slot, size, version, audit가 강제된다. | -| Extensions | 독립 module과 capability probe가 존재한다. | -| 테스트 | 계약·동시성·장애·성능·ACL suite가 CI 또는 정기 job에 연결된다. | -| 운영 문서 | 사용 기준, 비보장, alert, upgrade, rollback 절차가 포함된다. | - ---- - -## 29. 구현 순서 - -1. Gradle 모듈과 공통 규칙 -2. command catalog와 policy schema -3. core type, key, codec, exception, version capability -4. Spring Data/Lettuce 연결과 auto-configuration -5. policy-aware executor와 telemetry -6. String, Hash, Set, Sorted Set, Key·TTL -7. Batch·Pipeline -8. List, Bitmap, Bitfield, HLL, Geo -9. Stream과 Blocking connection -10. Pub/Sub과 Sharded Pub/Sub -11. Sentinel failover 의미론 -12. Cluster slot·redirect·topology -13. Transaction, Lua, Function -14. Raw Gateway -15. Admin Plane -16. Redis 8 확장 모듈 -17. CI matrix, chaos, performance, release documentation - -이 순서는 정책 우회 경로인 Raw Gateway가 core guardrail보다 먼저 생기지 않도록 강제한다. diff --git a/scripts/verify-httpclient-docs.py b/scripts/verify-httpclient-docs.py deleted file mode 100755 index 6fa8726..0000000 --- a/scripts/verify-httpclient-docs.py +++ /dev/null @@ -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()) From e615c2415251f10c4e785839da6a73692b9b6761 Mon Sep 17 00:00:00 2001 From: DongHyeonka Date: Wed, 19 Aug 2026 18:26:34 +0900 Subject: [PATCH 03/10] fix: serve Studio at the contract path instead of /api/api/v1/... MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PresentationWebConfig prefixes every controller mapping with ca-skeleton.presentation.api-base-path ("/api"), which is why every other controller in this repository declares its path without it — healthcheck is "/healthcheck", uploads are "/v1/uploads", files are "/v1/files". The two Studio controllers declared "/api/v1/studio/..." instead, so the prefix landed on top of a path that already had it and both operations were served at /api/api/v1/studio/... — nowhere near the address studio-v1.yaml declares (servers: "/", paths: /api/v1/studio/...). The frontend calls the contract path, so nothing connected. Verified against a running backend on PostgreSQL behind a real Keycloak: /api/v1/studio/catalog?type=TOPIC 200, 3 items (was 404) /api/api/v1/studio/catalog?type=TOPIC 404 (was 200) Why the tests were green while production was broken: the three slice tests and both nested apps in StudioContractDriftTest build contexts that never include PresentationWebConfig, so no prefix was applied and the controllers' literal "/api/v1/..." matched. They now import it and supply the same "/api" the real app uses, which makes the paths they exercise the effective ones. Re-introducing the bug fails five of them. StudioContractDriftTest needed two more repairs to stay meaningful: springdoc's own endpoint is prefixed too, so the published document is read from /api/v3/api-docs; and publishedStudioOperationsMatchTheContract skips any path not starting with /api/v1/studio/, so a missing prefix would have made it compare nothing and pass. It now asserts it compared at least one path — a gate that cannot see drift is not the same as no drift. Co-Authored-By: Claude Opus 5 (1M context) --- .../controller/StudioCatalogController.java | 8 +- .../controller/StudioSessionController.java | 8 +- ...StudioCatalogBindingErrorEnvelopeTest.java | 13 ++ .../StudioSessionCsrfDisabledTest.java | 13 ++ .../controller/StudioSessionEnvelopeTest.java | 13 ++ .../config/PersistenceVendorSettings.java | 5 +- .../h2/H2IdempotencyClaimRepository.java | 4 +- .../h2/H2LocalTimeoutConfigurer.java | 12 +- .../persistence/h2/H2PersistenceConfig.java | 16 +- .../persistence/h2/H2ClaimSqlTest.java | 9 +- .../contract/StudioContractDriftTest.java | 186 +++++++++++------- .../architecture/StudioErrorRegistryTest.java | 38 ++-- .../architecture/TechLogBoundaryArchTest.java | 4 +- .../ProfileSeparationContractTest.java | 17 +- ...oSessionCsrfHeaderProfileContractTest.java | 9 +- 15 files changed, 227 insertions(+), 128 deletions(-) diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/controller/StudioCatalogController.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/controller/StudioCatalogController.java index 6490190..6ccdf4c 100644 --- a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/controller/StudioCatalogController.java +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/controller/StudioCatalogController.java @@ -29,7 +29,13 @@ public class StudioCatalogController { this.listCatalog = listCatalog; } - @GetMapping("/api/v1/studio/catalog") + /** + * 경로에 {@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, diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/controller/StudioSessionController.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/controller/StudioSessionController.java index 2068679..3f0e585 100644 --- a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/controller/StudioSessionController.java +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/controller/StudioSessionController.java @@ -55,7 +55,13 @@ public class StudioSessionController { this.csrfHeaderName = configured; } - @GetMapping("/api/v1/studio/session") + /** + * 경로에 {@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) { diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/techlog/studio/controller/StudioCatalogBindingErrorEnvelopeTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/techlog/studio/controller/StudioCatalogBindingErrorEnvelopeTest.java index ecc264e..cee2c3e 100644 --- a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/techlog/studio/controller/StudioCatalogBindingErrorEnvelopeTest.java +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/techlog/studio/controller/StudioCatalogBindingErrorEnvelopeTest.java @@ -5,8 +5,10 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. 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; @@ -58,6 +60,7 @@ import org.springframework.test.web.servlet.MockMvc; excludeAutoConfiguration = SecurityAutoConfiguration.class) @AutoConfigureMockMvc(addFilters = false) @Import({ + PresentationWebConfig.class, StudioCatalogController.class, StudioExceptionHandler.class, GlobalExceptionHandler.class, @@ -98,6 +101,16 @@ class StudioCatalogBindingErrorEnvelopeTest { 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 = diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/techlog/studio/controller/StudioSessionCsrfDisabledTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/techlog/studio/controller/StudioSessionCsrfDisabledTest.java index aff5c33..41e6995 100644 --- a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/techlog/studio/controller/StudioSessionCsrfDisabledTest.java +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/techlog/studio/controller/StudioSessionCsrfDisabledTest.java @@ -6,9 +6,11 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. 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; @@ -46,6 +48,7 @@ import org.springframework.test.web.servlet.MockMvc; */ @WebMvcTest(controllers = StudioSessionController.class) @Import({ + PresentationWebConfig.class, StudioSessionController.class, EnvelopeBodyAdvice.class, StudioExceptionHandler.class, @@ -89,6 +92,16 @@ class StudioSessionCsrfDisabledTest { @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()) diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/techlog/studio/controller/StudioSessionEnvelopeTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/techlog/studio/controller/StudioSessionEnvelopeTest.java index f42b732..242afe8 100644 --- a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/techlog/studio/controller/StudioSessionEnvelopeTest.java +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/techlog/studio/controller/StudioSessionEnvelopeTest.java @@ -7,8 +7,10 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. 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; @@ -68,6 +70,7 @@ import org.springframework.test.web.servlet.MockMvc; */ @WebMvcTest(controllers = StudioSessionController.class) @Import({ + PresentationWebConfig.class, StudioSessionController.class, EnvelopeBodyAdvice.class, StudioSessionEnvelopeTest.SecurityTestConfig.class @@ -111,6 +114,16 @@ class StudioSessionEnvelopeTest { @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()); diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/config/PersistenceVendorSettings.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/config/PersistenceVendorSettings.java index eba19cb..e08dbc9 100644 --- a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/config/PersistenceVendorSettings.java +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/config/PersistenceVendorSettings.java @@ -12,9 +12,8 @@ import org.springframework.boot.context.properties.ConfigurationProperties; * *

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 - * missing SPI bean would surface as a {@code NoSuchBeanDefinitionException} naming - * {@code OutboxClaimRepository} — a symptom several layers away from the misspelled value that - * caused it. + * missing SPI bean would surface as a {@code NoSuchBeanDefinitionException} naming {@code + * OutboxClaimRepository} — a symptom several layers away from the misspelled value that caused it. */ @ConfigurationProperties(prefix = PersistenceVendorSettings.PREFIX) public record PersistenceVendorSettings(Vendor vendor) { diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/h2/H2IdempotencyClaimRepository.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/h2/H2IdempotencyClaimRepository.java index bcfa66c..7c7cac5 100644 --- a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/h2/H2IdempotencyClaimRepository.java +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/h2/H2IdempotencyClaimRepository.java @@ -12,8 +12,8 @@ import org.jspecify.annotations.Nullable; * H2 atomic scope claim. * *

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 - * meaning in one statement: + * statement does not port. The standard {@code MERGE ... USING} does, and carries the same meaning + * in one statement: * *

    *
  • no row for the scope → {@code WHEN NOT MATCHED} inserts the claim (1 row); diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/h2/H2LocalTimeoutConfigurer.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/h2/H2LocalTimeoutConfigurer.java index ef7fd5c..b17781e 100644 --- a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/h2/H2LocalTimeoutConfigurer.java +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/h2/H2LocalTimeoutConfigurer.java @@ -15,12 +15,12 @@ import org.springframework.jdbc.core.JdbcOperations; *
  • Session scope, not transaction scope. PostgreSQL takes {@code set_config(..., true)} * — 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 - * transaction port applies these before every transaction, so each one overwrites the last; - * a connection borrowed outside that path keeps the previous transaction's guard. - *
  • No idle-in-transaction guard. H2 has no counterpart to - * {@code idle_in_transaction_session_timeout}, so that budget cannot be pushed into the - * database here. It is left to the caller-side deadline the transaction port already - * enforces, rather than silently reported as applied. + * transaction port applies these before every transaction, so each one overwrites the last; a + * connection borrowed outside that path keeps the previous transaction's guard. + *
  • No idle-in-transaction guard. H2 has no counterpart to {@code + * idle_in_transaction_session_timeout}, so that budget cannot be pushed into the database + * here. It is left to the caller-side deadline the transaction port already enforces, rather + * than silently reported as applied. *
* *

The millisecond values are inlined because H2's {@code SET} takes no bind parameter. They diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/h2/H2PersistenceConfig.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/h2/H2PersistenceConfig.java index 2c1c70a..e59f4cd 100644 --- a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/h2/H2PersistenceConfig.java +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/h2/H2PersistenceConfig.java @@ -15,13 +15,13 @@ import org.springframework.jdbc.core.JdbcOperations; /** * 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 - * {@code local} profile sets. + * implemented against H2. Selected by {@code ca-skeleton.persistence.vendor=h2}, which the {@code + * local} profile sets. * *

No Flyway location customizer, deliberately. The PostgreSQL vendor points Flyway at * {@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 - * consequences worth stating out loud: + * profile turns Flyway off and lets Hibernate derive the schema from the entities. Two consequences + * worth stating out loud: * *

    *
  • 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. *
  • 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 - * PostgreSQL DDL H2 cannot parse. Such a fork should register its own - * {@code FlywayConfigurationCustomizer} naming an H2 location. + * PostgreSQL DDL H2 cannot parse. Such a fork should register its own {@code + * FlywayConfigurationCustomizer} naming an H2 location. *
* - *

Local therefore verifies wiring and behaviour, not migrations. Migration and vendor-concurrency - * fidelity stay with the real-PostgreSQL integration suites. + *

Local therefore verifies wiring and behaviour, not migrations. Migration and + * vendor-concurrency fidelity stay with the real-PostgreSQL integration suites. */ @Configuration(proxyBeanMethods = false) @ConditionalOnProperty( diff --git a/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/h2/H2ClaimSqlTest.java b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/h2/H2ClaimSqlTest.java index a18dd28..8ada120 100644 --- a/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/h2/H2ClaimSqlTest.java +++ b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/h2/H2ClaimSqlTest.java @@ -31,8 +31,8 @@ import org.springframework.transaction.support.TransactionTemplate; * USING}, and only an execution proves that the substitution kept the three outcomes intact. * *

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 - * {@code postgresqlIntegrationTest} source set. + * nothing to skip when Docker is absent. Real-PostgreSQL fidelity remains the job of the {@code + * postgresqlIntegrationTest} source set. */ class H2ClaimSqlTest { @@ -142,8 +142,9 @@ class H2ClaimSqlTest { List claimed = claimEligible(now, 10); - assertThat(claimed).extracting(OutboxEventEntity::getEventId).containsExactly("evt-old", - "evt-other"); + assertThat(claimed) + .extracting(OutboxEventEntity::getEventId) + .containsExactly("evt-old", "evt-other"); } @Test diff --git a/src/app-bootstrap/src/functionalTest/java/dev/caskeleton/bootstrap/contract/StudioContractDriftTest.java b/src/app-bootstrap/src/functionalTest/java/dev/caskeleton/bootstrap/contract/StudioContractDriftTest.java index 8d4a876..74b5f1e 100644 --- a/src/app-bootstrap/src/functionalTest/java/dev/caskeleton/bootstrap/contract/StudioContractDriftTest.java +++ b/src/app-bootstrap/src/functionalTest/java/dev/caskeleton/bootstrap/contract/StudioContractDriftTest.java @@ -6,7 +6,9 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. 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; @@ -40,72 +42,73 @@ import org.yaml.snakeyaml.Yaml; /** * feature-techlog-studio-backend Task 10 — the drift gate spec §5.5 calls for: springdoc's * published {@code /v3/api-docs} is diffed against the vendored {@code - * config/openapi/studio-v1.yaml} for every {@code /api/v1/studio/**} path it actually exposes. - * Only implemented operations are checked (direction is "published ⊆ contract", never the - * reverse), so this stays green as slices 2-5 add the other 17 operations — on one condition: - * the new controllers must live somewhere under {@code dev.caskeleton.adapter.inbound.web.techlog}, - * the package {@link ContractSurface.ContractSurfaceApp} and {@link EnvelopeWrapping.EnvelopeApp} - * {@code @ComponentScan}. A controller placed there is picked up automatically, with no edit to this - * file. A controller placed outside that package tree is invisible to both minimal contexts - * — springdoc never sees it, so this gate stays green even if its path/method/operationId contradicts - * the contract — and the {@code @ComponentScan} base package below must be widened (or the new - * controller moved) before this gate can be trusted again. (An earlier draft of this class named the - * two controllers directly via {@code @Import} instead of scanning; that hardcoded list had exactly - * this blind spot — confirmed by temporarily reintroducing it and observing a controller with an - * out-of-contract mapping pass silently, see task-10-report.md.) This test also fails the moment an - * in-scan controller's method name drifts from its {@code operationId} or ships an endpoint outside - * the contract. + * config/openapi/studio-v1.yaml} for every {@code /api/v1/studio/**} path it actually exposes. Only + * implemented operations are checked (direction is "published ⊆ contract", never the + * reverse), so this stays green as slices 2-5 add the other 17 operations — on one + * condition: the new controllers must live somewhere under {@code + * dev.caskeleton.adapter.inbound.web.techlog}, the package {@link + * ContractSurface.ContractSurfaceApp} and {@link EnvelopeWrapping.EnvelopeApp} + * {@code @ComponentScan}. A controller placed there is picked up automatically, with no edit to + * this file. A controller placed outside that package tree is invisible to both minimal + * contexts — springdoc never sees it, so this gate stays green even if its path/method/operationId + * contradicts the contract — and the {@code @ComponentScan} base package below must be widened (or + * the new controller moved) before this gate can be trusted again. (An earlier draft of this class + * named the two controllers directly via {@code @Import} instead of scanning; that hardcoded list + * had exactly this blind spot — confirmed by temporarily reintroducing it and observing a + * controller with an out-of-contract mapping pass silently, see task-10-report.md.) This test also + * fails the moment an in-scan controller's method name drifts from its {@code operationId} or ships + * an endpoint outside the contract. * *

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

* *

This repository's own tests never boot the full app under test: {@code * FileserverRoundTripContractTest} and {@code ActuatorSecurityHttpTest} (both in this module's - * {@code src/test}) spell out why — {@code application.yml} resolves ~50 {@code ${...}} 자리표시자 - * from {@code src/.env} (datasource, OIDC issuer, Redis, messaging, ...), so a full boot drags in + * {@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 + * 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. + * excluded and MockMvc filters off ({@code addFilters = false}) — the exclude/addFilters + * combination {@code EnvelopeBodyAdviceTest} and {@code NoResourceFoundErrorHandlingTest} + * (adapter-inbound-web's own test sourceSet) already use for this class of test. * *

Because this functionalTest module depends only on {@code :adapter:inbound:web} and {@code * :application-core} (not {@code :adapter:outbound:persistence-jpa}, cache, or messaging), none of - * the DataSource/Flyway/Redis auto-configuration classes are even on the classpath for {@code - * @EnableAutoConfiguration} to attempt — there is nothing to exclude for them, unlike {@code + * the DataSource/Flyway/Redis auto-configuration classes are even on the classpath for + * {@code @EnableAutoConfiguration} to attempt — there is nothing to exclude for them, unlike {@code * ActuatorSecurityHttpTest}'s explicit JPA/Flyway exclude list. * *

{@code @SpringBootTest} (full, unsliced {@code @EnableAutoConfiguration}) is used instead of * {@code @WebMvcTest}: springdoc's own auto-configuration is a third-party {@code * AutoConfiguration.imports} entry, not part of Boot's curated {@code @WebMvcTest} slice allowlist, * so {@code /v3/api-docs} would not be exposed under a sliced test. A full, unsliced context that - * only sees two modules' worth of dependencies keeps the cost bounded to "start web MVC + springdoc" - * without paying for DB/security infrastructure. + * only sees two modules' worth of dependencies keeps the cost bounded to "start web MVC + + * springdoc" without paying for DB/security infrastructure. * *

Why two nested contexts instead of one

* *

The obvious design is one shared {@code @SpringBootTest} context for both tests. That does not - * work here, and the reason is worth recording: springdoc's {@code /v3/api-docs} handler - * ({@code OpenApiWebMvcResource.openapiJson}) returns raw {@code byte[]} — it serializes the OpenAPI - * model itself and hands Spring MVC already-encoded bytes. {@link EnvelopeBodyAdvice#supports} - * returns {@code true} unconditionally (by design — it wraps every controller response in the real - * app, not just Studio's), so if it is on the classpath of *that* request it rewrites the body from - * {@code byte[]} to {@code Envelope} — but Spring MVC picks the {@code HttpMessageConverter} - * from the *original* return type before the advice runs, so {@code ByteArrayHttpMessageConverter} - * (already selected for {@code byte[]}) is then asked to write an {@code Envelope}, and - * {@code writeInternal} throws {@code ClassCastException: Envelope cannot be cast to [B}. This - * reproduced with a full stack trace during this task (see task-10-report.md) — it is a real, - * pre-existing defect in shared skeleton code ({@code EnvelopeBodyAdvice} is not Studio-owned and - * not part of this task's brief), not an artifact of this test's plumbing: any app that boots both - * springdoc and {@code EnvelopeBodyAdvice} together and serves {@code /v3/api-docs} unauthenticated - * would hit the same crash. Fixing that advice is out of scope for a contract-regression test, so - * {@link ContractSurface} boots a context *without* {@link EnvelopeBodyAdvice} (springdoc doesn't - * invoke it for anything test 1 checks anyway — introspection is pure reflection over the mapping), - * and {@link EnvelopeWrapping} boots a separate context *with* it, hitting - * {@link StudioCatalogController} instead, whose {@code CatalogPage} return type is an ordinary POJO - * that the same JSON converter handles before and after wrapping. + * work here, and the reason is worth recording: springdoc's {@code /v3/api-docs} handler ({@code + * OpenApiWebMvcResource.openapiJson}) returns raw {@code byte[]} — it serializes the OpenAPI model + * itself and hands Spring MVC already-encoded bytes. {@link EnvelopeBodyAdvice#supports} returns + * {@code true} unconditionally (by design — it wraps every controller response in the real app, not + * just Studio's), so if it is on the classpath of *that* request it rewrites the body from {@code + * byte[]} to {@code Envelope} — but Spring MVC picks the {@code HttpMessageConverter} from + * the *original* return type before the advice runs, so {@code ByteArrayHttpMessageConverter} + * (already selected for {@code byte[]}) is then asked to write an {@code Envelope}, and {@code + * writeInternal} throws {@code ClassCastException: Envelope cannot be cast to [B}. This reproduced + * with a full stack trace during this task (see task-10-report.md) — it is a real, pre-existing + * defect in shared skeleton code ({@code EnvelopeBodyAdvice} is not Studio-owned and not part of + * this task's brief), not an artifact of this test's plumbing: any app that boots both springdoc + * and {@code EnvelopeBodyAdvice} together and serves {@code /v3/api-docs} unauthenticated would hit + * the same crash. Fixing that advice is out of scope for a contract-regression test, so {@link + * ContractSurface} boots a context *without* {@link EnvelopeBodyAdvice} (springdoc doesn't invoke + * it for anything test 1 checks anyway — introspection is pure reflection over the mapping), and + * {@link EnvelopeWrapping} boots a separate context *with* it, hitting {@link + * StudioCatalogController} instead, whose {@code CatalogPage} return type is an ordinary POJO that + * the same JSON converter handles before and after wrapping. * *

Why {@link ListCatalogUseCase} is real, not mocked

* @@ -125,12 +128,12 @@ import org.yaml.snakeyaml.Yaml; * 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. + * 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 { @@ -142,9 +145,8 @@ class StudioContractDriftTest { @Autowired private MockMvc mvc; /** - * "구현된 것만 검사" 방향 고정: 계약(19개 operation)이 아니라 published(springdoc이 실제로 내놓는 것, 지금은 2개)를 - * 순회한다. 슬라이스 2~5가 나머지 17개를 추가해도 이 순회 방향 덕분에 이 테스트는 그대로 통과한다 — 반대로 순회했다면 미구현 operation마다 - * 매번 실패했을 것이다. + * "구현된 것만 검사" 방향 고정: 계약(19개 operation)이 아니라 published(springdoc이 실제로 내놓는 것, 지금은 2개)를 순회한다. 슬라이스 + * 2~5가 나머지 17개를 추가해도 이 순회 방향 덕분에 이 테스트는 그대로 통과한다 — 반대로 순회했다면 미구현 operation마다 매번 실패했을 것이다. */ @Test void publishedStudioOperationsMatchTheContract() throws Exception { @@ -152,11 +154,13 @@ class StudioContractDriftTest { JsonNode published = readPublishedApiDocs(); List problems = new ArrayList<>(); + List compared = new ArrayList<>(); JsonNode publishedPaths = published.path("paths"); for (Map.Entry 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()); @@ -184,6 +188,16 @@ class StudioContractDriftTest { } } 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(); } /** @@ -204,9 +218,14 @@ class StudioContractDriftTest { 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("/v3/api-docs")) + mvc.perform(get("/api/v3/api-docs")) .andExpect(status().isOk()) .andReturn() .getResponse() @@ -215,20 +234,30 @@ class StudioContractDriftTest { } /** - * {@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엔 애초에 관여하지 않는다. + * {@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) 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(); @@ -262,8 +291,8 @@ class StudioContractDriftTest { } /** - * {@code adapter-inbound-web}에 실제 애플리케이션이 없어({@code CaSkeletonApplication}은 app-bootstrap - * 소유) {@code @SpringBootTest}가 부트스트랩할 {@code @SpringBootConfiguration}이 필요하다 — {@code + * {@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 @@ -272,9 +301,19 @@ class StudioContractDriftTest { @SpringBootConfiguration @EnableAutoConfiguration(exclude = SecurityAutoConfiguration.class) @ComponentScan("dev.caskeleton.adapter.inbound.web.techlog") - @Import(EnvelopeBodyAdvice.class) + @Import({EnvelopeBodyAdvice.class, PresentationWebConfig.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(); @@ -287,18 +326,25 @@ class StudioContractDriftTest { } } - /** {@code csrfHeaderName}이 계약 const {@code X-CSRF-TOKEN}과 다르면 {@link StudioSessionController}의 생성자가 즉시 실패한다. */ + /** + * {@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); + SecuritySettings.AuthenticationMode.JWT, + "https://issuer.example", + null, + List.of(), + session); } /** - * 실제 영속성 어댑터 대신 최소 stub 포트로 구성한 진짜 {@link ListCatalogUseCase}. springdoc은 컨트롤러 메서드를 호출하지 않고 - * 리플렉션만 하므로 첫 번째 테스트에는 아예 관여하지 않고, 두 번째 테스트(봉투 확인)는 결과 내용이 아니라 감싸는 모양만 보므로 빈 목록으로 충분하다. + * 실제 영속성 어댑터 대신 최소 stub 포트로 구성한 진짜 {@link ListCatalogUseCase}. springdoc은 컨트롤러 메서드를 호출하지 않고 리플렉션만 + * 하므로 첫 번째 테스트에는 아예 관여하지 않고, 두 번째 테스트(봉투 확인)는 결과 내용이 아니라 감싸는 모양만 보므로 빈 목록으로 충분하다. */ private static ListCatalogUseCase listCatalogUseCaseForTest() { return new ListCatalogUseCase(new StubCatalogQueryPort(), new PassThroughTransactionPort()); diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/StudioErrorRegistryTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/StudioErrorRegistryTest.java index 08b8e6a..9721c05 100644 --- a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/StudioErrorRegistryTest.java +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/StudioErrorRegistryTest.java @@ -27,11 +27,11 @@ import org.yaml.snakeyaml.Yaml; * *
    *
  1. every {@link StudioError} constant has a matching registry row (code presence); - *
  2. that row's {@code category}/{@code http_status}/{@code retryable} match the enum's - * declared values exactly — a standing drift gate. Nothing else in the suite checks this for - * {@link StudioError}: {@code ErrorCodeRegistryMappingTest} (a different branch's template) - * only walks {@code OperationalError}. Its absence let the {@code VALIDATION_FAILED} ↔ - * {@code OperationalError.VALIDATION_FAILED} code-name collision ship once already (see + *
  3. 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; *
  4. {@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 @@ -45,8 +45,8 @@ import org.yaml.snakeyaml.Yaml; * 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. + * this test needs structured field access (category/http_status/retryable/client_safe_message), not + * just the {@code code:} key. */ class StudioErrorRegistryTest { @@ -102,8 +102,8 @@ class StudioErrorRegistryTest { /** * {@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). + * 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() { @@ -118,14 +118,14 @@ class StudioErrorRegistryTest { } /** - * 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. + * 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 { @@ -180,8 +180,8 @@ class StudioErrorRegistryTest { } /** - * Parses lines shaped {@code }, skipping {@code #}-prefixed comment - * lines such as MANIFEST.sha256's {@code # source: ...} provenance line. + * Parses lines shaped {@code }, skipping {@code #}-prefixed comment lines + * such as MANIFEST.sha256's {@code # source: ...} provenance line. */ private static String recordedSha256(Path manifest, String filename) throws IOException { return Files.readAllLines(manifest).stream() diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/TechLogBoundaryArchTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/TechLogBoundaryArchTest.java index 4821656..7d8b223 100644 --- a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/TechLogBoundaryArchTest.java +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/TechLogBoundaryArchTest.java @@ -7,8 +7,8 @@ import com.tngtech.archunit.junit.ArchTest; import com.tngtech.archunit.lang.ArchRule; /** - * 설계 08장의 bounded context 경계를 빌드로 강제한다. Gradle leaf를 늘리지 않고 - * 패키지로 나눴으므로(spec D1/D2) 경계는 이 규칙이 유일한 방어선이다. + * 설계 08장의 bounded context 경계를 빌드로 강제한다. Gradle leaf를 늘리지 않고 패키지로 나눴으므로(spec D1/D2) 경계는 이 규칙이 유일한 + * 방어선이다. */ @AnalyzeClasses(packages = "dev.caskeleton", importOptions = ProductionClassImportOption.class) class TechLogBoundaryArchTest { diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/ProfileSeparationContractTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/ProfileSeparationContractTest.java index 026e7f7..396cffe 100644 --- a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/ProfileSeparationContractTest.java +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/ProfileSeparationContractTest.java @@ -30,11 +30,11 @@ import org.yaml.snakeyaml.constructor.SafeConstructor; * composition root inside this source set is not currently possible — the component scan that makes * {@code CaSkeletonApplication} the composition root also finds the nested {@code @Configuration} * classes that dozens of tests here declare, and they collide. The behaviour behind these files is - * covered where it can be: {@code H2ClaimSqlTest} runs the H2 statements against a real H2, - * {@code PersistenceVendorSelectionTest} covers the selector, and - * {@code PersistenceVendorProdSafetyValidatorTest} covers the prod refusal. What is left, and what - * this test guards, is the wiring between them drifting — a profile quietly changing vendor, or - * local regaining a migration expectation it cannot satisfy. + * covered where it can be: {@code H2ClaimSqlTest} runs the H2 statements against a real H2, {@code + * PersistenceVendorSelectionTest} covers the selector, and {@code + * PersistenceVendorProdSafetyValidatorTest} covers the prod refusal. What is left, and what this + * test guards, is the wiring between them drifting — a profile quietly changing vendor, or local + * regaining a migration expectation it cannot satisfy. */ class ProfileSeparationContractTest { @@ -136,7 +136,8 @@ class ProfileSeparationContractTest { return path; } } - throw new IllegalStateException("repository root not found from " + Paths.get("").toAbsolutePath()); + throw new IllegalStateException( + "repository root not found from " + Paths.get("").toAbsolutePath()); } /** @@ -197,7 +198,9 @@ class ProfileSeparationContractTest { private record Placeholder(String variable, String inlineDefault) {} - /** Property path → the {@code ${VAR}} or {@code ${VAR:default}} application.yml resolves it from. */ + /** + * Property path → the {@code ${VAR}} or {@code ${VAR:default}} application.yml resolves it from. + */ private static Map placeholders() throws IOException { Pattern syntax = Pattern.compile("^\\$\\{([A-Z0-9_]+)(?::(.*))?}$"); Map found = new LinkedHashMap<>(); diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/StudioSessionCsrfHeaderProfileContractTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/StudioSessionCsrfHeaderProfileContractTest.java index e2d241c..2cbb28f 100644 --- a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/StudioSessionCsrfHeaderProfileContractTest.java +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/StudioSessionCsrfHeaderProfileContractTest.java @@ -23,9 +23,9 @@ import org.yaml.snakeyaml.constructor.SafeConstructor; * configured header name disagrees with the contract const. That controller is an unconditional * {@code @RestController} bean, so the constructor failure becomes a {@code BeanCreationException} * during context refresh — the process never starts, taking healthcheck/actuator/fileserver down - * with it. Only {@code application-dev.yml} declared the override before this fix; - * {@code application-local.yml} (the profile {@code src/.env:8} actually activates) and - * {@code application-prod.yml} both resolved to the template default {@code X-XSRF-TOKEN} + * with it. Only {@code application-dev.yml} declared the override before this fix; {@code + * application-local.yml} (the profile {@code src/.env:8} actually activates) and {@code + * application-prod.yml} both resolved to the template default {@code X-XSRF-TOKEN} * (application.yml:498's inline default, restated verbatim by {@code src/.env:125}), so both * profiles could not boot. * @@ -54,8 +54,7 @@ class StudioSessionCsrfHeaderProfileContractTest { } private static String csrfHeaderNameOf(Map configuration) { - Map session = - child(child(child(configuration, "ca-skeleton"), "security"), "session"); + Map session = child(child(child(configuration, "ca-skeleton"), "security"), "session"); Object value = session == null ? null : session.get("csrf-header-name"); return value == null ? null : value.toString(); } From 48ff648112b4ba88573b06cbe3ebd3ebeb3ea227 Mon Sep 17 00:00:00 2001 From: DongHyeonka Date: Wed, 19 Aug 2026 23:21:43 +0900 Subject: [PATCH 04/10] =?UTF-8?q?feat:=20Tech=20Log=20Studio=20=EB=B0=B1?= =?UTF-8?q?=EC=97=94=EB=93=9C=20=E2=80=94=20=EB=82=A8=EC=9D=80=2017?= =?UTF-8?q?=EA=B0=9C=20operation=20=EA=B5=AC=ED=98=84=20(=EC=8A=AC?= =?UTF-8?q?=EB=9D=BC=EC=9D=B4=EC=8A=A4=202~5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit studio-v1.yaml 19개 operation 중 Plan 01이 남긴 17개를 구현한다. 문서 CRUD, 검증·미리보기, 게시, Asset. 이로써 studio-v1은 19/19다. Plan 01이 Plan 02로 미룬 생성기 union 차단 요인 - 계약 원본은 그대로 두고 prepareStudioCodegenSpec이 생성 직전에 사본을 파생시킨다. oneOf+discriminator를 가진 스키마의 하위 타입에 x-implements를 주입하고, union 자체는 생성을 억제한 뒤 같은 package에 Jackson 다형성 인터페이스를 계약에서 파생해 써 넣는다 - 파생 규칙을 계약의 oneOf/discriminator.mapping에서 읽으므로 union 목록을 손으로 관리하지 않는다. 계약에 union이 늘면 따라온다 - openApiNullable=false. JsonNullable을 읽는 모듈은 Jackson 2용인데 이 앱의 HTTP 변환기는 Jackson 3(tools.jackson)다 — 등록될 수 없어 직렬화가 POJO로 새고 역직렬화가 깨진다. 해당 필드는 계약상 required라 "없음"과 "null"을 구분할 필요도 없다 - 모든 분기가 type:string인 이름 없는 oneOf는 접는다. 안 접으면 필드 0개 껍데기 클래스가 나와 slug가 {}로 직렬화된다 - oneOf:[X,null]도 접는다. 그대로 두면 같은 모양의 래퍼 타입이 7벌 더 생긴다 - StudioContractUnionJacksonTest가 이 배선을 지킨다. 파생이 깨지면 컴파일이 깨진다 설계 스키마의 구멍 — V8__techlog_studio_working_copy.sql V7(설계 패키지 database/V1__init.sql)은 유형마다 다른 물리 모델인데 계약은 네 유형을 공통 base + 유형별 확장이라는 하나의 편집 흐름으로 다룬다. 계약이 요구하는데 없던 것: - document.summary / case_detail.environment,reproduction / reference_detail.rules,examples - open_question.options,resolution_evidence_target_id,resolution_link_label - project_decision.title,slug,summary,primary_topic_id - problem/conclusion/scope_summary/statement가 varchar라 계약의 100000자를 담을 수 없어 text로 넓힘 - project_decision.project_id NOT NULL은 계약이 명시적으로 허용한 초안 저장을 구조적으로 막고 있었다(게시 필수 여부는 검증이 판단한다) — 풀었다 - studio_relation: 계약의 relations[]는 네 유형 공통이고 항목마다 자체 id와 reason이 있다. document_relation은 복합 PK라 둘 다 없고 문서끼리만 성립한다. (source_kind, source_id) 다형 참조는 studio_validation/studio_preview가 이미 쓰는 방식이다 영속은 JdbcClient spec §8.3은 쓰기에 JPA @Version을 적었지만 이 네 aggregate는 Studio 저장 경로에서만 쓰이고 UPDATE ... WHERE version = :expectedVersion의 갱신 행 수가 정확히 같은 의미를 준다. 여덟 개 넘는 테이블에 엔티티를 세우는 비용에 상응하는 이득이 없다. 포트 계약이 같으므로 나중에 JPA가 필요하면 어댑터만 바뀐다. nextAction/dependencyRevision 계산은 SQL 한 벌(StudioDocumentSql) 목록과 상세가 각자 계산하면 "목록에선 게시하라더니 열어보니 검증하라"가 된다. 계약의 nextAction 필터도 SQL이라야 페이지네이션을 깨지 않고 걸 수 있다. 렌더러 (ADR-005) - commonmark + GFM 확장. 설계 05장 §16대로 라이브러리는 render 패키지 밖으로 안 나간다. 프론트가 remark 계열로 같은 CommonMark+GFM 기준을 쓰므로 동등성이 유지된다 - ::: directive는 줄 단위 스캔이다. v1 문법에서 중첩이 없고 줄 맨 앞에서만 열린다. 코드 펜스 안의 :::는 directive로 보지 않는다 - 컨테이너/leaf 판정은 닫는 줄이 실제로 있는지로 한다. 이름 목록으로 정하면 directive를 더할 때마다 목록을 고쳐야 하고, "닫는 줄 없으면 문서 끝까지"면 닫기를 빠뜨린 directive 하나가 뒤 내용을 통째로 삼킨다 - 계약이 표현 못 하는 것은 조용히 바꾸지 않고 경고로 남긴다 — 수평선, 머리글 없는 표, 알 수 없는 directive, 미해결 asset key(경로를 지어내지 않고 버린다) - RenderModelPort 구현이 inbound web에 있다. 렌더 모델은 계약 DTO이고 그 타입을 소유한 모듈이 거기다. application에 같은 모양을 한 벌 더 두면 두 정의가 갈라진다 검증 체인 판정 기준은 하나다 — 이 편집본으로 계약이 요구하는 PublicRenderModel을 만들 수 있는가. 각 규칙은 렌더 모델의 required/minLength/minItems에서 나온다. 다른 기준을 쓰면 검증을 통과한 문서가 렌더 단계에서 계약을 위반한다. 첫 오류에서 멈추지 않고 끝까지 모은다. 게시 (spec §7.5 20단계) - Snapshot의 렌더 모델은 게시 시점에 다시 렌더링하지 않고 사용자가 확인한 미리보기의 것을 그대로 쓴다. 다시 렌더링하면 승인한 화면과 공개된 화면이 달라질 수 있다 - 단계별 실패가 서로 다른 계약 코드로 나간다. DOCUMENT_VALIDATION_FAILED(지금 검증하면 실패)와 VALIDATION_STALE(통과했으나 전제가 바뀜)은 다른 사건이고 할 일도 다르다 - 게시 취소는 route도 Snapshot도 지우지 않는다. 지우면 공개된 링크가 끊긴다 Asset - 확장자와 클라이언트 Content-Type을 신뢰하지 않고 파일 시작 바이트로 판정한다. 모르는 형식은 저장하지 않고 415로 거절한다 - 바이너리는 기존 object storage 어댑터에 위임한다(spec §9). ObjectStoragePort는 deprecated지만 이 저장소에서 실제 구현이 붙어 있는 유일한 포트다 — 선택을 브리지 한 클래스에 가뒀다. 저장 백엔드가 없는 배포는 업로드·삭제만 503이고 나머지는 동작한다 - 공개 이력이 있거나 사용 중인 Asset은 hard delete하지 않는다 검증 — "통과하는데 동작 안 함"을 세 겹으로 막았다 - StudioContractDriftTest에 반대 방향(계약 → published)을 추가했다. 기존 한 방향은 사라진 operation을 못 잡는다. 양방향 모두 실제로 RED가 되는 것을 확인했다 - postgresqlTechLogStudioPersistenceIntegrationTest 신규 11개. 이 저장소의 check는 Testcontainers를 돌리지 않아 이 테스트가 없으면 SQL이 한 번도 실행되지 않는다. 첫 실행에서 실제 결함을 잡았다: fk_publication_latest_event의 지연 검사는 트랜잭션 끝에 일어나므로 autocommit이면 첫 INSERT에서 위반된다 → 어댑터가 진입 시 활성 트랜잭션을 확인하고 아니면 원인을 그대로 말하며 실패한다 - 실제 앱 부팅으로 두 건을 더 잡았다. check에 전체 앱 부팅 테스트가 없어 생긴 구멍이다 1. 생성자 모호성 — 프로덕션/테스트 두 생성자에 표시가 없어 기본 생성자를 찾다 실패 2. final 클래스 + AOP — @RequiresPermission은 CGLIB 프록시를 쓰는데 final은 subclass 불가. 템플릿의 NotificationDispatchUseCase가 final이면서 무사한 것은 그 능력이 꺼진 배포에서 빈으로 등록되지 않아서다. Studio use case는 항상 등록된다 가드레일이 잡은 것 - MUTATING_USE_CASES_DECLARE_REQUIRED_PERMISSION → studio:write 부여, role 매핑은 프로파일에. application.yml의 role-permissions:{} 기준선은 SampleRemovalSmokeContractTest가 지킨다 - NO_CONTEXT_DEPENDS_ON_STUDIO_FACADE 223건 → 어댑터 패키지를 persistence.techlog.studio.*로 옮겼다. 규칙을 고치지 않았고, 그 이름이 우회가 아니라 더 정확하다 - verifyEnvKeys → 새 APP_ 키 4건 등록 범위 studio-v1의 19개 전부. public-v1(18) / studio-management-v1(79)은 spec §2.2가 선언한 out of scope다 — 전자는 소비자가 아직 없고 후자는 secondary capability 보존 계약이다. 검증: ./gradlew check BUILD SUCCESSFUL (245 task), 전체 3,721 테스트 실패 0, techlog PostgreSQL 통합 테스트 3종 통과, 실제 앱 부팅 확인. AGENTS.md의 commit 정책은 human-only다. 이 커밋은 사용자가 "전부 커밋하고 머지 진행하세요"로 명시적으로 지시해 예외로 수행한다. Co-Authored-By: Claude Opus 5 (1M context) --- docs/registries/env-keys.yaml | 63 ++ src/adapter/inbound/web/build.gradle | 247 +++++- src/adapter/inbound/web/gradle.lockfile | 5 + .../controller/StudioAssetController.java | 220 ++++++ .../controller/StudioDocumentController.java | 226 ++++++ .../controller/StudioPreviewController.java | 121 +++ .../StudioPublicationController.java | 177 +++++ .../studio/mapper/StudioDetailMapper.java | 262 +++++++ .../studio/mapper/StudioRequestMapper.java | 203 +++++ .../studio/mapper/StudioResponseMapper.java | 258 +++++++ .../techlog/studio/render/BlockRenderer.java | 236 ++++++ .../web/techlog/studio/render/HeadingIds.java | 71 ++ .../techlog/studio/render/InlineRenderer.java | 117 +++ .../studio/render/MarkdownSegments.java | 92 +++ .../render/PublicRenderModelFactory.java | 166 ++++ .../studio/render/RenderModelJsonAdapter.java | 47 ++ .../studio/render/StudioContentRenderer.java | 181 +++++ .../studio/render/StudioDirective.java | 47 ++ .../techlog/studio/support/StudioCursors.java | 122 +++ .../studio/support/StudioIdempotency.java | 90 +++ .../studio/support/StudioPrincipals.java | 23 + .../studio/support/StudioSettings.java | 44 ++ .../StudioContractUnionJacksonTest.java | 142 ++++ .../render/StudioContentRendererTest.java | 223 ++++++ .../outbound/persistence-jpa/build.gradle | 14 + .../outbound/persistence-jpa/gradle.lockfile | 10 +- .../artifact/JdbcPreviewArtifactAdapter.java | 91 +++ .../JdbcValidationArtifactAdapter.java | 143 ++++ .../query/JdbcDependencyRevisionAdapter.java | 41 + .../query/JdbcPublicationQueryAdapter.java | 42 + .../JdbcStudioDashboardQueryAdapter.java | 57 ++ .../JdbcStudioDependencyResolverAdapter.java | 225 ++++++ .../query/JdbcStudioDocumentQueryAdapter.java | 141 ++++ .../query/StudioDocumentRowMapper.java | 48 ++ .../techlog/query/StudioDocumentSql.java | 130 ++++ .../asset/JdbcAssetRepositoryAdapter.java | 244 ++++++ .../JdbcPublicationHistoryQueryAdapter.java | 192 +++++ .../JdbcPublicationWriterAdapter.java | 514 +++++++++++++ .../publication/PublicationRowMappers.java | 25 + .../workingcopy/DocumentWorkingCopyStore.java | 255 +++++++ .../JdbcWorkingCopyRepositoryAdapter.java | 128 ++++ .../ProjectDecisionWorkingCopyStore.java | 180 +++++ .../techlog/workingcopy/ProjectLinkStore.java | 81 ++ .../workingcopy/QuestionWorkingCopyStore.java | 296 ++++++++ .../techlog/workingcopy/StudioJson.java | 131 ++++ .../workingcopy/StudioRelationStore.java | 89 +++ .../techlog/workingcopy/StudioSqlSupport.java | 49 ++ .../V8__techlog_studio_working_copy.sql | 92 +++ .../StudioPersistenceIntegrationTest.java | 716 ++++++++++++++++++ src/app-bootstrap/gradle.lockfile | 5 + .../contract/StudioContractDriftTest.java | 517 ++++++++++++- .../ObjectStorageAssetBinaryAdapter.java | 51 ++ .../techlog/TechLogStudioConfig.java | 235 ++++++ .../src/main/resources/application-dev.yml | 11 + .../src/main/resources/application-local.yml | 19 + .../src/main/resources/application-prod.yml | 11 + .../src/main/resources/application.yml | 10 + .../architecture/TechLogBoundaryArchTest.java | 7 + .../studio/command/CreateDocumentCommand.java | 12 + .../studio/command/CreatePreviewCommand.java | 9 + .../studio/command/DeleteAssetCommand.java | 7 + .../command/PublishDocumentCommand.java | 22 + .../studio/command/SaveDocumentCommand.java | 10 + .../command/UnpublishPublicationCommand.java | 8 + .../studio/command/UpdateAssetCommand.java | 23 + .../studio/command/UploadAssetCommand.java | 74 ++ .../command/ValidateDocumentCommand.java | 8 + .../techlog/studio/model/AssetDetailView.java | 16 + .../techlog/studio/model/AssetKindView.java | 8 + .../model/AssetManagementStatusView.java | 13 + .../studio/model/AssetManifestEntry.java | 18 + .../techlog/studio/model/AssetPageView.java | 11 + .../techlog/studio/model/AssetUsageView.java | 7 + .../techlog/studio/model/AssetView.java | 28 + .../studio/model/DashboardTotalsView.java | 5 + .../techlog/studio/model/DashboardView.java | 17 + .../studio/model/DecisionStatusView.java | 7 + .../studio/model/DisplayTargetView.java | 6 + .../studio/model/DocumentPageView.java | 11 + .../techlog/studio/model/DocumentSort.java | 8 + .../studio/model/DocumentSummaryView.java | 22 + .../techlog/studio/model/NextAction.java | 11 + .../techlog/studio/model/OrderedTextView.java | 6 + .../studio/model/PreviewDetailView.java | 10 + .../techlog/studio/model/PreviewState.java | 8 + .../techlog/studio/model/PublicPaths.java | 33 + .../studio/model/PublicPreviewView.java | 21 + .../studio/model/PublicationActionView.java | 8 + .../model/PublicationAggregateStatus.java | 7 + .../model/PublicationAggregateView.java | 15 + .../model/PublicationEventTypeView.java | 8 + .../studio/model/PublicationEventView.java | 19 + .../studio/model/PublicationListItemView.java | 15 + .../studio/model/PublicationPageView.java | 11 + .../studio/model/PublicationSnapshotView.java | 10 + .../studio/model/PublicationStatusView.java | 8 + .../studio/model/PublishResultView.java | 4 + .../studio/model/QuestionOptionView.java | 6 + .../studio/model/QuestionResolutionView.java | 6 + .../studio/model/QuestionStatusView.java | 10 + .../techlog/studio/model/RecordKind.java | 12 + .../studio/model/ReferenceRuleView.java | 6 + .../techlog/studio/model/RelationView.java | 9 + .../techlog/studio/model/RenderInput.java | 29 + .../studio/model/ResolvedAssetView.java | 18 + .../studio/model/ResolvedRelationView.java | 18 + .../studio/model/ValidationIssueView.java | 9 + .../studio/model/ValidationReportView.java | 24 + .../studio/model/ValidationSeverity.java | 7 + .../studio/model/ValidationStatus.java | 8 + .../studio/model/WorkingCopyBaseInput.java | 26 + .../studio/model/WorkingCopyDetailView.java | 15 + .../studio/model/WorkingCopyInputView.java | 86 +++ .../techlog/studio/model/WorkingCopyView.java | 83 ++ .../port/out/AssetBinaryStoragePort.java | 20 + .../studio/port/out/AssetRepositoryPort.java | 63 ++ .../studio/port/out/ContentAnalyzerPort.java | 35 + .../port/out/DependencyRevisionPort.java | 17 + .../studio/port/out/PreviewArtifactPort.java | 16 + .../port/out/PublicationHistoryQueryPort.java | 18 + .../studio/port/out/PublicationQueryPort.java | 12 + .../port/out/PublicationWriterPort.java | 64 ++ .../studio/port/out/RenderModelPort.java | 19 + .../port/out/StudioDashboardQueryPort.java | 14 + .../out/StudioDependencyResolverPort.java | 51 ++ .../port/out/StudioDocumentQueryPort.java | 14 + .../port/out/ValidationArtifactPort.java | 17 + .../port/out/WorkingCopyRepositoryPort.java | 31 + .../studio/query/DocumentCursorPosition.java | 14 + .../techlog/studio/query/GetAssetQuery.java | 7 + .../studio/query/GetDashboardQuery.java | 6 + .../studio/query/GetDocumentQuery.java | 7 + .../techlog/studio/query/GetPreviewQuery.java | 7 + .../studio/query/GetSnapshotQuery.java | 7 + .../techlog/studio/query/ListAssetsQuery.java | 17 + .../studio/query/ListDocumentsQuery.java | 25 + .../studio/query/ListPublicationsQuery.java | 15 + .../studio/service/AssetMediaTypes.java | 71 ++ .../service/CreateStudioDocumentUseCase.java | 56 ++ .../service/CreateStudioPreviewUseCase.java | 171 +++++ .../service/DeleteStudioAssetUseCase.java | 78 ++ .../GetCurrentStudioPreviewUseCase.java | 93 +++ .../studio/service/GetStudioAssetUseCase.java | 42 + .../service/GetStudioDashboardUseCase.java | 61 ++ .../service/GetStudioDocumentUseCase.java | 55 ++ .../GetStudioPublicationSnapshotUseCase.java | 45 ++ .../service/ListStudioAssetsUseCase.java | 47 ++ .../service/ListStudioDocumentsUseCase.java | 55 ++ .../ListStudioPublicationsUseCase.java | 43 ++ .../studio/service/NextActionCalculator.java | 88 +++ .../service/PublishStudioDocumentUseCase.java | 249 ++++++ .../techlog/studio/service/SaveOutcome.java | 18 + .../service/SaveStudioDocumentUseCase.java | 102 +++ .../studio/service/StudioDocumentLoader.java | 46 ++ .../studio/service/StudioPermissions.java | 16 + .../UnpublishStudioPublicationUseCase.java | 90 +++ .../service/UpdateStudioAssetUseCase.java | 97 +++ .../service/UploadStudioAssetUseCase.java | 140 ++++ .../ValidateStudioDocumentUseCase.java | 116 +++ .../service/WorkingCopyDetailAssembler.java | 59 ++ .../service/WorkingCopyInputValidator.java | 76 ++ .../validation/StudioDocumentValidator.java | 262 +++++++ .../studio/validation/ValidationIssues.java | 46 ++ 163 files changed, 11807 insertions(+), 10 deletions(-) create mode 100644 src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/controller/StudioAssetController.java create mode 100644 src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/controller/StudioDocumentController.java create mode 100644 src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/controller/StudioPreviewController.java create mode 100644 src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/controller/StudioPublicationController.java create mode 100644 src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/mapper/StudioDetailMapper.java create mode 100644 src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/mapper/StudioRequestMapper.java create mode 100644 src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/mapper/StudioResponseMapper.java create mode 100644 src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/render/BlockRenderer.java create mode 100644 src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/render/HeadingIds.java create mode 100644 src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/render/InlineRenderer.java create mode 100644 src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/render/MarkdownSegments.java create mode 100644 src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/render/PublicRenderModelFactory.java create mode 100644 src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/render/RenderModelJsonAdapter.java create mode 100644 src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/render/StudioContentRenderer.java create mode 100644 src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/render/StudioDirective.java create mode 100644 src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/support/StudioCursors.java create mode 100644 src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/support/StudioIdempotency.java create mode 100644 src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/support/StudioPrincipals.java create mode 100644 src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/support/StudioSettings.java create mode 100644 src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/techlog/studio/contract/StudioContractUnionJacksonTest.java create mode 100644 src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/techlog/studio/render/StudioContentRendererTest.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/artifact/JdbcPreviewArtifactAdapter.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/artifact/JdbcValidationArtifactAdapter.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/query/JdbcDependencyRevisionAdapter.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/query/JdbcPublicationQueryAdapter.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/query/JdbcStudioDashboardQueryAdapter.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/query/JdbcStudioDependencyResolverAdapter.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/query/JdbcStudioDocumentQueryAdapter.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/query/StudioDocumentRowMapper.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/query/StudioDocumentSql.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/studio/asset/JdbcAssetRepositoryAdapter.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/studio/publication/JdbcPublicationHistoryQueryAdapter.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/studio/publication/JdbcPublicationWriterAdapter.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/studio/publication/PublicationRowMappers.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/workingcopy/DocumentWorkingCopyStore.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/workingcopy/JdbcWorkingCopyRepositoryAdapter.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/workingcopy/ProjectDecisionWorkingCopyStore.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/workingcopy/ProjectLinkStore.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/workingcopy/QuestionWorkingCopyStore.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/workingcopy/StudioJson.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/workingcopy/StudioRelationStore.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/workingcopy/StudioSqlSupport.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/resources/db/migration/postgresql/V8__techlog_studio_working_copy.sql create mode 100644 src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/techlog/studio/StudioPersistenceIntegrationTest.java create mode 100644 src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/techlog/ObjectStorageAssetBinaryAdapter.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/command/CreateDocumentCommand.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/command/CreatePreviewCommand.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/command/DeleteAssetCommand.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/command/PublishDocumentCommand.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/command/SaveDocumentCommand.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/command/UnpublishPublicationCommand.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/command/UpdateAssetCommand.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/command/UploadAssetCommand.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/command/ValidateDocumentCommand.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/AssetDetailView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/AssetKindView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/AssetManagementStatusView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/AssetManifestEntry.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/AssetPageView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/AssetUsageView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/AssetView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/DashboardTotalsView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/DashboardView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/DecisionStatusView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/DisplayTargetView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/DocumentPageView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/DocumentSort.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/DocumentSummaryView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/NextAction.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/OrderedTextView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/PreviewDetailView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/PreviewState.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/PublicPaths.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/PublicPreviewView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/PublicationActionView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/PublicationAggregateStatus.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/PublicationAggregateView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/PublicationEventTypeView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/PublicationEventView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/PublicationListItemView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/PublicationPageView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/PublicationSnapshotView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/PublicationStatusView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/PublishResultView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/QuestionOptionView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/QuestionResolutionView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/QuestionStatusView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/RecordKind.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/ReferenceRuleView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/RelationView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/RenderInput.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/ResolvedAssetView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/ResolvedRelationView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/ValidationIssueView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/ValidationReportView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/ValidationSeverity.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/ValidationStatus.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/WorkingCopyBaseInput.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/WorkingCopyDetailView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/WorkingCopyInputView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/WorkingCopyView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/port/out/AssetBinaryStoragePort.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/port/out/AssetRepositoryPort.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/port/out/ContentAnalyzerPort.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/port/out/DependencyRevisionPort.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/port/out/PreviewArtifactPort.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/port/out/PublicationHistoryQueryPort.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/port/out/PublicationQueryPort.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/port/out/PublicationWriterPort.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/port/out/RenderModelPort.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/port/out/StudioDashboardQueryPort.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/port/out/StudioDependencyResolverPort.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/port/out/StudioDocumentQueryPort.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/port/out/ValidationArtifactPort.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/port/out/WorkingCopyRepositoryPort.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/query/DocumentCursorPosition.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/query/GetAssetQuery.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/query/GetDashboardQuery.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/query/GetDocumentQuery.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/query/GetPreviewQuery.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/query/GetSnapshotQuery.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/query/ListAssetsQuery.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/query/ListDocumentsQuery.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/query/ListPublicationsQuery.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/AssetMediaTypes.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/CreateStudioDocumentUseCase.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/CreateStudioPreviewUseCase.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/DeleteStudioAssetUseCase.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/GetCurrentStudioPreviewUseCase.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/GetStudioAssetUseCase.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/GetStudioDashboardUseCase.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/GetStudioDocumentUseCase.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/GetStudioPublicationSnapshotUseCase.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/ListStudioAssetsUseCase.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/ListStudioDocumentsUseCase.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/ListStudioPublicationsUseCase.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/NextActionCalculator.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/PublishStudioDocumentUseCase.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/SaveOutcome.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/SaveStudioDocumentUseCase.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/StudioDocumentLoader.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/StudioPermissions.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/UnpublishStudioPublicationUseCase.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/UpdateStudioAssetUseCase.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/UploadStudioAssetUseCase.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/ValidateStudioDocumentUseCase.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/WorkingCopyDetailAssembler.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/WorkingCopyInputValidator.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/validation/StudioDocumentValidator.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/validation/ValidationIssues.java diff --git a/docs/registries/env-keys.yaml b/docs/registries/env-keys.yaml index bb941e7..4d66011 100644 --- a/docs/registries/env-keys.yaml +++ b/docs/registries/env-keys.yaml @@ -4249,3 +4249,66 @@ env_keys: validation: positive_int_bounded compatibility_impact: behavior-change 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 diff --git a/src/adapter/inbound/web/build.gradle b/src/adapter/inbound/web/build.gradle index fc6bf52..9bdc136 100644 --- a/src/adapter/inbound/web/build.gradle +++ b/src/adapter/inbound/web/build.gradle @@ -22,6 +22,11 @@ plugins { id 'org.openapi.generator' } sourceSets { generatedOpenapi { java.srcDir(layout.buildDirectory.dir('generated/openapi/src/main/java')) + // 계약의 discriminator union을 Java interface로 파생한 소스(prepareStudioCodegenSpec). + // 생성 DTO와 같은 sourceSet이어야 한다 — 생성된 하위 타입이 `implements ` 하므로 + // 이 인터페이스가 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을 @@ -64,6 +69,15 @@ dependencies { // never a hand-maintained stale schema). The release-blocking drift gate is // owned by feature-contract-verification-test-suite (planned). 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 — // 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 @@ -121,6 +135,225 @@ tasks.named('check') { 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} 에서 파생한다. 손으로 고치지 않는다. + * + *

    {@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). // @@ -155,16 +388,24 @@ tasks.named('check') { // --------------------------------------------------------------------------- openApiGenerate { generatorName = 'spring' - inputSpec = "${rootDir}/config/openapi/studio-v1.yaml".toString() + // 원본이 아니라 prepareStudioCodegenSpec 가 파생한 사본을 먹인다 — 이유는 그 태스크의 주석 참고. + inputSpec = studioCodegenSpecFile.get().asFile.path + ignoreFileOverride = studioCodegenIgnoreFile.get().asFile.path outputDir = layout.buildDirectory.dir('generated/openapi').get().asFile.path - modelPackage = 'dev.caskeleton.adapter.inbound.web.techlog.studio.api.model' + modelPackage = studioModelPackage globalProperties.set(['models': '']) generateModelTests = false generateModelDocumentation = false configOptions = [ useSpringBoot3: 'true', useJakartaEe: 'true', - openApiNullable: 'true', + // false 다. openApiNullable=true 는 nullable 필드를 JsonNullable 로 만드는데, + // 그 타입을 읽는 모듈(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', ] } diff --git a/src/adapter/inbound/web/gradle.lockfile b/src/adapter/inbound/web/gradle.lockfile index c0429f4..4dc2699 100644 --- a/src/adapter/inbound/web/gradle.lockfile +++ b/src/adapter/inbound/web/gradle.lockfile @@ -91,6 +91,10 @@ org.codehaus.plexus:plexus-classworlds:2.6.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-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.hamcrest:hamcrest:3.0=testCompileClasspath,testRuntimeClasspath org.hibernate.validator:hibernate-validator:9.0.1.Final=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath @@ -108,6 +112,7 @@ org.junit:junit-bom:6.0.1=testCompileClasspath,testRuntimeClasspath org.junit:junit-bom:6.1.0=spotbugs org.mockito:mockito-core:5.20.0=mockitoAgent,testCompileClasspath,testRuntimeClasspath org.mockito:mockito-junit-jupiter:5.20.0=testCompileClasspath,testRuntimeClasspath +org.nibor.autolink:autolink:0.10.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.objenesis:objenesis:3.3=testRuntimeClasspath org.openapitools:jackson-databind-nullable:0.2.6=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/controller/StudioAssetController.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/controller/StudioAssetController.java new file mode 100644 index 0000000..10a665a --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/controller/StudioAssetController.java @@ -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. + * + *

    메서드 이름이 곧 {@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 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 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 updateStudioAsset( + @PathVariable("assetId") UUID assetId, + @Valid @RequestBody UpdateAssetCommand command, + @AuthenticationPrincipal AuthenticatedPrincipal principal, + HttpServletRequest request) { + + StudioIdempotency.Outcome 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 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(); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/controller/StudioDocumentController.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/controller/StudioDocumentController.java new file mode 100644 index 0000000..89b454a --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/controller/StudioDocumentController.java @@ -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. + * + *

    메서드 이름이 곧 {@code operationId}다 — {@code StudioContractDriftTest}가 springdoc이 게시한 이름과 계약을 대조한다. + * 이름을 바꾸면 그 게이트가 빨간불이 된다. + * + *

    반환값을 봉투로 감싸지 않는다 — {@code EnvelopeBodyAdvice}가 감싼다(ADR-006). + * + *

    경로에 {@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 createStudioDocument( + @Valid @RequestBody WorkingCopyInput document, + @AuthenticationPrincipal AuthenticatedPrincipal principal, + HttpServletRequest request) { + + StudioIdempotency.Outcome 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 saveStudioDocument( + @PathVariable("documentId") UUID documentId, + @Valid @RequestBody SaveDocumentCommand command, + @AuthenticationPrincipal AuthenticatedPrincipal principal, + HttpServletRequest request) { + + StudioIdempotency.Outcome 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 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"); + } + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/controller/StudioPreviewController.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/controller/StudioPreviewController.java new file mode 100644 index 0000000..8ef1eb7 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/controller/StudioPreviewController.java @@ -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. + * + *

    메서드 이름이 곧 {@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 validateStudioDocument( + @PathVariable("documentId") UUID documentId, + @Valid @RequestBody ValidateDocumentCommand command, + @AuthenticationPrincipal AuthenticatedPrincipal principal, + HttpServletRequest request) { + + StudioIdempotency.Outcome 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 createStudioPreview( + @PathVariable("documentId") UUID documentId, + @Valid @RequestBody CreatePreviewCommand command, + @AuthenticationPrincipal AuthenticatedPrincipal principal, + HttpServletRequest request) { + + StudioIdempotency.Outcome 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; + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/controller/StudioPublicationController.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/controller/StudioPublicationController.java new file mode 100644 index 0000000..8984d54 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/controller/StudioPublicationController.java @@ -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. + * + *

    메서드 이름이 곧 {@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 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 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 unpublishStudioPublication( + @PathVariable("publicationId") UUID publicationId, + @Valid @RequestBody UnpublishCommand command, + @AuthenticationPrincipal AuthenticatedPrincipal principal, + HttpServletRequest request) { + + StudioIdempotency.Outcome 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; + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/mapper/StudioDetailMapper.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/mapper/StudioDetailMapper.java new file mode 100644 index 0000000..7ebcd1a --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/mapper/StudioDetailMapper.java @@ -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}가 모두 이 결과를 쓴다. + * + *

    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); + } + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/mapper/StudioRequestMapper.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/mapper/StudioRequestMapper.java new file mode 100644 index 0000000..a6ca6ee --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/mapper/StudioRequestMapper.java @@ -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 입력 모델. + * + *

    계약 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 relations) { + return new WorkingCopyBaseInput( + kind, title, slug, summary, topicId, projectId, relations(relations)); + } + + private static List relations(List 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 orderedText(List items) { + return items == null + ? List.of() + : items.stream() + .map(item -> new OrderedTextView(item.getId(), item.getText(), order(item.getOrder()))) + .toList(); + } + + private static List rules(List 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 options(List 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; + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/mapper/StudioResponseMapper.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/mapper/StudioResponseMapper.java new file mode 100644 index 0000000..4d7d971 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/mapper/StudioResponseMapper.java @@ -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 title, + java.util.function.Consumer slug, + java.util.function.Consumer summary) { + title.accept(base.title()); + slug.accept(base.slug()); + summary.accept(base.summary()); + } + + private static List relations(List 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 orderedTextToApi(List 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 rulesToApi(List 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 optionsToApi(List 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 List map(List source, Function mapper) { + return source == null ? List.of() : source.stream().map(mapper).toList(); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/render/BlockRenderer.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/render/BlockRenderer.java new file mode 100644 index 0000000..e06d45e --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/render/BlockRenderer.java @@ -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 으로 옮긴다. + * + *

    계약이 표현할 수 없는 것은 조용히 다른 것으로 바꾸지 않고 경고로 남긴다(설계 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 warnings; + private int tableSequence; + private int listItemSequence; + + BlockRenderer(HeadingIds headingIds, List warnings) { + this.headingIds = headingIds; + this.warnings = warnings; + } + + List render(Node document) { + List 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 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 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 content = new ArrayList<>(); + for (Node child = value.getFirstChild(); child != null; child = child.getNext()) { + List 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 listItems(Node list) { + List items = new ArrayList<>(); + for (Node child = list.getFirstChild(); child != null; child = child.getNext()) { + ListItem item = new ListItem(); + listItemSequence++; + item.setId("li-" + listItemSequence); + List 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 columns = new ArrayList<>(); + List 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 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; + }; + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/render/HeadingIds.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/render/HeadingIds.java new file mode 100644 index 0000000..bd087db --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/render/HeadingIds.java @@ -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에서 같아야 하므로 구현은 한 곳에만 둔다. + * + *

    {@code
    + * "Authorization Code Flow" -> authorization-code-flow
    + * "JPA N+1 문제"             -> jpa-n-1-문제
    + * "결론"                     -> 결론
    + * "결론" (두 번째)           -> 결론-2
    + * }
    + */ +final class HeadingIds { + + private static final String FALLBACK = "section"; + + private final Map 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); // 호환용 자모 + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/render/InlineRenderer.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/render/InlineRenderer.java new file mode 100644 index 0000000..03e23f2 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/render/InlineRenderer.java @@ -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 render(Node parent) { + List 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; + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/render/MarkdownSegments.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/render/MarkdownSegments.java new file mode 100644 index 0000000..4633036 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/render/MarkdownSegments.java @@ -0,0 +1,92 @@ +package dev.caskeleton.adapter.inbound.web.techlog.studio.render; + +import java.util.ArrayList; +import java.util.List; + +/** + * 본문을 "평범한 Markdown" 조각과 "directive" 조각으로 순서대로 자른다. + * + *

    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 split(String source) { + List 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 ...)인지는 닫는 줄이 + // 실제로 있는지로 정한다. 이름으로 정하면 새 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 segments, StringBuilder markdown) { + if (!markdown.isEmpty()) { + String text = markdown.toString(); + if (!text.isBlank()) { + segments.add(new Segment(text, null, null)); + } + markdown.setLength(0); + } + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/render/PublicRenderModelFactory.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/render/PublicRenderModelFactory.java new file mode 100644 index 0000000..2d57bf1 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/render/PublicRenderModelFactory.java @@ -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}. + * + *

    본문 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 warnings) {} + + public Rendered create(RenderInput input) { + RenderContext context = new RenderContext(); + context.setGeneratedAt(StudioResponseMapper.offsetDateTime(input.generatedAt())); + context.setDependencyRevision(input.dependencyRevision()); + + List 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; + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/render/RenderModelJsonAdapter.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/render/RenderModelJsonAdapter.java new file mode 100644 index 0000000..ba3980f --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/render/RenderModelJsonAdapter.java @@ -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 으로 만든다. + * + *

    렌더 경고는 여기서 버리지 않고 로그로 남긴다 — 계약의 {@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); + } + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/render/StudioContentRenderer.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/render/StudioContentRenderer.java new file mode 100644 index 0000000..0eae476 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/render/StudioContentRenderer.java @@ -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) — + * 화면마다 다른 경로를 두면 작성자가 확인한 것과 공개된 것이 달라진다. + * + *

    Asset 해석은 렌더러가 직접 조회하지 않고 {@code assetsByKey} 로 주입받는다. Snapshot 만 게시 시점에 고정된 manifest 를 넣고 + * 나머지는 현재 상태를 넣으며, 그것이 ADR-002 가 요구하는 유일하게 허용된 차이다. + */ +@Component +public class StudioContentRenderer implements ContentAnalyzerPort { + + /** 설계 05장 §4가 허용한 callout 종류. 그 밖의 이름은 경고를 만들고 일반 인용으로 처리한다. */ + private static final Set INFO_CALLOUTS = Set.of("note", "tip"); + + private static final Set 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 blocks, List warnings) {} + + public RenderedContent render(String bodyMarkdown, Map assetsByKey) { + List blocks = new ArrayList<>(); + List 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 usages = new ArrayList<>(); + Set 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 assetsByKey, + HeadingIds headingIds, + List 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 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 assetsByKey, + List 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; + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/render/StudioDirective.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/render/StudioDirective.java new file mode 100644 index 0000000..09b11b6 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/render/StudioDirective.java @@ -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" ...} 한 줄을 이름과 속성으로 나눈다. + * + *

    directive 를 commonmark 확장이 아니라 줄 단위 스캔으로 다루는 이유: v1 문법에서 directive 는 중첩이 없고 줄 맨 앞에서만 열린다(설계 + * 05장 §3.2, §4). 줄 스캔이면 동작이 눈으로 확인되고, 지원하지 않는 directive 를 "조용히 다른 것으로 해석"하는 일이 구조적으로 생기지 않는다. + */ +record StudioDirective(String name, String argument, Map 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 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)); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/support/StudioCursors.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/support/StudioCursors.java new file mode 100644 index 0000000..3dfb3a6 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/support/StudioCursors.java @@ -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 하고 정규화된 필터·정렬에 결합된 커서"라고 정한 것을 실제로 그렇게 만든다. + * + *

    필터 지문을 커서 안에 함께 서명한다 — 그래야 필터를 바꾼 뒤 옛 커서를 재사용하는 요청을 거절할 수 있다. 거절하지 않으면 정렬 키의 의미가 달라진 채로 페이지가 + * 이어져 사용자에게는 항목이 조용히 사라지거나 중복돼 보인다. + */ +@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"); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/support/StudioIdempotency.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/support/StudioIdempotency.java new file mode 100644 index 0000000..0a24690 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/support/StudioIdempotency.java @@ -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). + * + *

    재생 여부를 스스로 판단하지 않고 동작이 실제로 실행됐는지로 안다 — 저장소를 미리 들여다보고 판정하면 그 사이에 다른 요청이 끼어들 수 있어 헤더가 + * 거짓말을 하게 된다. 실행되지 않았다면 결과는 재생된 것이다. + * + *

    {@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 executors; + private final IdempotencyKeySupport keys; + + public StudioIdempotency( + ObjectProvider executors, IdempotencyKeySupport keys) { + this.executors = executors; + this.keys = keys; + } + + /** + * 결과와 그 결과가 재생된 것인지 여부. + * + * @param 동작의 결과 타입 + */ + public record Outcome(R result, boolean replayed) {} + + public Outcome run( + HttpServletRequest request, + String operationId, + Object requestPayload, + Class responseType, + Supplier 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 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(); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/support/StudioPrincipals.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/support/StudioPrincipals.java new file mode 100644 index 0000000..a136ca6 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/support/StudioPrincipals.java @@ -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})에 남길 주체를 뽑는다. + * + *

    {@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(); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/support/StudioSettings.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/support/StudioSettings.java new file mode 100644 index 0000000..e913875 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/support/StudioSettings.java @@ -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; + } + } +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/techlog/studio/contract/StudioContractUnionJacksonTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/techlog/studio/contract/StudioContractUnionJacksonTest.java new file mode 100644 index 0000000..3421e0c --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/techlog/studio/contract/StudioContractUnionJacksonTest.java @@ -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 양방향으로 계약대로 동작하는지 고정한다. + * + *

    이 게이트가 필요한 이유는 컴파일이 이걸 못 잡기 때문이다. 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를 파생시켜 고쳤고, 이 테스트가 그 파생 배선이 + * 살아 있는지를 지킨다. 파생이 깨지면 여기서 빨간불이 난다. + * + *

    {@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 blocks = ((CasePublicRenderModel) back).getBodyBlocks(); + assertThat(blocks).hasSize(1).first().isInstanceOf(HeadingBlock.class); + + List 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(); + } +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/techlog/studio/render/StudioContentRendererTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/techlog/studio/render/StudioContentRendererTest.java new file mode 100644 index 0000000..f9a58ee --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/techlog/studio/render/StudioContentRendererTest.java @@ -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장이 정한 문법을 계약 블록으로 옮기는지 고정한다. + * + *

    여기서 지키는 것은 "그럴듯하게 렌더링된다"가 아니라 계약 제약을 어기지 않는다이다 — 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 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 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"); + } +} diff --git a/src/adapter/outbound/persistence-jpa/build.gradle b/src/adapter/outbound/persistence-jpa/build.gradle index 151578b..a718442 100644 --- a/src/adapter/outbound/persistence-jpa/build.gradle +++ b/src/adapter/outbound/persistence-jpa/build.gradle @@ -26,6 +26,12 @@ dependencies { implementation project(':shared-contract') 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 // multi-instance distributedLockProvider. Version managed by Spring Boot BOM. implementation 'org.springframework.integration:spring-integration-jdbc' @@ -117,6 +123,14 @@ def postgresqlTechLogCatalogQueryIntegrationTest = registerPostgreSqlReadinessTe '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') { group = 'verification' description = 'Rejects concatenated SQL construction and non-parameterized PostgreSQL timeout configuration.' diff --git a/src/adapter/outbound/persistence-jpa/gradle.lockfile b/src/adapter/outbound/persistence-jpa/gradle.lockfile index 0e04cce..3155196 100644 --- a/src/adapter/outbound/persistence-jpa/gradle.lockfile +++ b/src/adapter/outbound/persistence-jpa/gradle.lockfile @@ -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-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-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-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 @@ -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-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: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-logging:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,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.xmlunit:xmlunit-core:2.10.4=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,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-databind:3.0.2=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -tools.jackson:jackson-bom: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=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +tools.jackson:jackson-bom:3.0.2=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath empty= diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/artifact/JdbcPreviewArtifactAdapter.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/artifact/JdbcPreviewArtifactAdapter.java new file mode 100644 index 0000000..53f4219 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/artifact/JdbcPreviewArtifactAdapter.java @@ -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} 접근. + * + *

    {@code render_model}은 렌더러가 만든 계약 모양 그대로를 문자열로 저장하고 그대로 돌려준다 — 중간에서 파싱했다 다시 직렬화하면 사용자가 확인한 화면과 + * 저장된 화면이 미묘하게 달라질 수 있고, 게시 시점에 그대로 snapshot 으로 옮겨야 하는 값이라 그 차이가 공개 결과까지 간다. + */ +@Repository +public class JdbcPreviewArtifactAdapter implements PreviewArtifactPort { + + private final JdbcClient jdbcClient; + + public JdbcPreviewArtifactAdapter(JdbcClient jdbcClient) { + this.jdbcClient = jdbcClient; + } + + @Override + public Optional 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 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")); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/artifact/JdbcValidationArtifactAdapter.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/artifact/JdbcValidationArtifactAdapter.java new file mode 100644 index 0000000..137f9eb --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/artifact/JdbcValidationArtifactAdapter.java @@ -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). + * + *

    {@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 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 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 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 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); + } + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/query/JdbcDependencyRevisionAdapter.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/query/JdbcDependencyRevisionAdapter.java new file mode 100644 index 0000000..ecb3d14 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/query/JdbcDependencyRevisionAdapter.java @@ -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}의 정의로 계산한다. + * + *

    목록이 쓰는 SQL과 같은 식을 쓴다. 여기서 다른 식을 쓰면 상세 화면이 계산한 값과 목록이 계산한 값이 달라져, 검증을 막 통과한 문서가 목록에서는 + * "다시 검증하라"로 보인다. + */ +@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)); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/query/JdbcPublicationQueryAdapter.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/query/JdbcPublicationQueryAdapter.java new file mode 100644 index 0000000..52bc70a --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/query/JdbcPublicationQueryAdapter.java @@ -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 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(); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/query/JdbcStudioDashboardQueryAdapter.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/query/JdbcStudioDashboardQueryAdapter.java new file mode 100644 index 0000000..51be086 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/query/JdbcStudioDashboardQueryAdapter.java @@ -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; + +/** + * 대시보드 집계. 목록과 같은 {@code studio_document} 정의를 쓴다 — 대시보드가 "게시 준비됨"이라고 센 문서와 목록에서 그 필터로 나오는 + * 문서가 달라지면 숫자를 믿을 수 없다. + */ +@Repository +public class JdbcStudioDashboardQueryAdapter implements StudioDashboardQueryPort { + + private final JdbcClient jdbcClient; + + public JdbcStudioDashboardQueryAdapter(JdbcClient jdbcClient) { + this.jdbcClient = jdbcClient; + } + + @Override + public List topByNextAction(List 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(); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/query/JdbcStudioDependencyResolverAdapter.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/query/JdbcStudioDependencyResolverAdapter.java new file mode 100644 index 0000000..8e571c7 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/query/JdbcStudioDependencyResolverAdapter.java @@ -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 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 relations = new ArrayList<>(); + List missingTargets = new ArrayList<>(); + resolveRelations(document, relations, missingTargets); + + Map assetsByKey = new LinkedHashMap<>(); + Map 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 resolved, List 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 keys, Map assets, Map 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); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/query/JdbcStudioDocumentQueryAdapter.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/query/JdbcStudioDocumentQueryAdapter.java new file mode 100644 index 0000000..e385e05 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/query/JdbcStudioDocumentQueryAdapter.java @@ -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). + * + *

    정렬 키에 항상 {@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 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 param : params.entrySet()) { + spec = spec.param(param.getKey(), param.getValue()); + } + + List rows = spec.query((rs, rowNum) -> readRow(rs)).list(); + boolean hasMore = rows.size() > query.limit(); + List page = hasMore ? rows.subList(0, query.limit()) : rows; + + List 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 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")); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/query/StudioDocumentRowMapper.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/query/StudioDocumentRowMapper.java new file mode 100644 index 0000000..c6d8342 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/query/StudioDocumentRowMapper.java @@ -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}. + * + *

    목록·대시보드·게시 이력이 모두 이 매퍼를 쓴다. 화면마다 따로 만들면 같은 문서가 화면마다 다른 {@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"))); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/query/StudioDocumentSql.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/query/StudioDocumentSql.java new file mode 100644 index 0000000..e0cd077 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/query/StudioDocumentSql.java @@ -0,0 +1,130 @@ +package dev.caskeleton.adapter.outbound.persistence.techlog.query; + +/** + * Studio 문서 union projection의 SQL 정의. 목록·대시보드·단건 조회가 같은 정의를 쓴다. + * + *

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

    마지막 CTE {@code studio_document}가 최종 결과이며 컬럼은 다음과 같다. + * + *

    {@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
    +   * }
    + */ + 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); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/studio/asset/JdbcAssetRepositoryAdapter.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/studio/asset/JdbcAssetRepositoryAdapter.java new file mode 100644 index 0000000..ea2b867 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/studio/asset/JdbcAssetRepositoryAdapter.java @@ -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} 메타데이터 접근. + * + *

    {@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 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 param : params.entrySet()) { + spec = spec.param(param.getKey(), param.getValue()); + } + List rows = spec.query(JdbcAssetRepositoryAdapter::mapAsset).list(); + + boolean hasMore = rows.size() > query.limit(); + List 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 find(UUID assetId) { + return jdbcClient + .sql(selectColumns() + " WHERE a.id = :id") + .param("id", assetId) + .query(JdbcAssetRepositoryAdapter::mapAsset) + .optional(); + } + + @Override + public Optional 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 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 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 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)); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/studio/publication/JdbcPublicationHistoryQueryAdapter.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/studio/publication/JdbcPublicationHistoryQueryAdapter.java new file mode 100644 index 0000000..878c785 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/studio/publication/JdbcPublicationHistoryQueryAdapter.java @@ -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; + +/** + * 게시 이력 조회. 이력은 항상 최신순이며 정렬 선택지가 없다 — 계약에도 정렬 파라미터가 없다. + * + *

    목록의 {@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 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 param : params.entrySet()) { + spec = spec.param(param.getKey(), param.getValue()); + } + + List rows = spec.query((rs, rowNum) -> readRow(rs)).list(); + boolean hasMore = rows.size() > query.limit(); + List page = hasMore ? rows.subList(0, query.limit()) : rows; + + Map summaries = summariesFor(page); + List 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 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 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 summariesFor(List rows) { + if (rows.isEmpty()) { + return Map.of(); + } + List ids = rows.stream().map(row -> row.event().documentId()).distinct().toList(); + Map 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 actionsFor(Row row) { + List 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); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/studio/publication/JdbcPublicationWriterAdapter.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/studio/publication/JdbcPublicationWriterAdapter.java new file mode 100644 index 0000000..6d7f64f --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/studio/publication/JdbcPublicationWriterAdapter.java @@ -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단계. + * + *

    {@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 idGenerator; + + /** + * 생성자가 둘이라 Spring 이 어느 쪽을 쓸지 스스로 정하지 못한다 — 표시가 없으면 기본 생성자를 찾다 실패해 컨텍스트가 뜨지 않는다(실제로 부팅 검증에서 그렇게 + * 실패했다). 두 번째 생성자는 테스트가 id 생성기를 주입하기 위한 것이며 프로덕션 배선은 항상 이쪽이다. + */ + @Autowired + public JdbcPublicationWriterAdapter(JdbcClient jdbcClient, ObjectMapper objectMapper) { + this(jdbcClient, objectMapper, UUID::randomUUID); + } + + JdbcPublicationWriterAdapter( + JdbcClient jdbcClient, ObjectMapper objectMapper, Supplier idGenerator) { + this.jdbcClient = jdbcClient; + this.objectMapper = objectMapper; + this.idGenerator = idGenerator; + } + + @Override + public Optional 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 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); + } + + /** + * 이 어댑터는 열린 트랜잭션 안에서만 올바르게 동작한다. + * + *

    {@code publication.latest_event_id} 와 {@code publication_event.publication_id} 가 서로를 가리키고, 그 + * 순환은 {@code fk_publication_latest_event} 의 {@code DEFERRABLE INITIALLY DEFERRED} 로만 성립한다. 지연 검사는 + * 트랜잭션 끝에 일어나므로, autocommit 이면 각 구문이 곧 트랜잭션이라 첫 INSERT 에서 바로 위반이 된다. + * + *

    이 사실을 주석으로만 남기면 트랜잭션 없이 호출한 코드가 "외래 키 위반"이라는, 원인과 한참 떨어진 오류를 만난다. 통합 테스트를 처음 돌렸을 때 실제로 그렇게 + * 실패했다. 그래서 전제를 여기서 확인하고 무엇이 잘못됐는지 그대로 말한다. + */ + 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 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); + } + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/studio/publication/PublicationRowMappers.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/studio/publication/PublicationRowMappers.java new file mode 100644 index 0000000..97a3851 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/studio/publication/PublicationRowMappers.java @@ -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")); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/workingcopy/DocumentWorkingCopyStore.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/workingcopy/DocumentWorkingCopyStore.java new file mode 100644 index 0000000..e85d4a6 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/workingcopy/DocumentWorkingCopyStore.java @@ -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 idGenerator; + private final ProjectLinkStore projectLinks; + + DocumentWorkingCopyStore( + JdbcClient jdbcClient, + StudioRelationStore relations, + StudioJson json, + Supplier idGenerator, + ProjectLinkStore projectLinks) { + this.jdbcClient = jdbcClient; + this.relations = relations; + this.json = json; + this.idGenerator = idGenerator; + this.projectLinks = projectLinks; + } + + Optional 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 relationsOf(RecordKind kind, UUID id) { + return relations.findBySource(kind, id); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/workingcopy/JdbcWorkingCopyRepositoryAdapter.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/workingcopy/JdbcWorkingCopyRepositoryAdapter.java new file mode 100644 index 0000000..3f97925 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/workingcopy/JdbcWorkingCopyRepositoryAdapter.java @@ -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한다. + * + *

    공통 CRUD repository가 아니다 — {@code kind}마다 소유 테이블이 다르고, 그 구분을 유지하는 것이 ADR-003의 결정이다. 여기서 하는 일은 + * "어느 저장소로 보낼지" 뿐이다. + * + *

    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 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 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 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 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 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); + }; + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/workingcopy/ProjectDecisionWorkingCopyStore.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/workingcopy/ProjectDecisionWorkingCopyStore.java new file mode 100644 index 0000000..0721f1b --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/workingcopy/ProjectDecisionWorkingCopyStore.java @@ -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}). + * + *

    계약의 {@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 idGenerator; + + ProjectDecisionWorkingCopyStore( + JdbcClient jdbcClient, + StudioRelationStore relations, + StudioJson json, + Supplier idGenerator) { + this.jdbcClient = jdbcClient; + this.relations = relations; + this.json = json; + this.idGenerator = idGenerator; + } + + Optional 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; + }; + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/workingcopy/ProjectLinkStore.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/workingcopy/ProjectLinkStore.java new file mode 100644 index 0000000..5853d71 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/workingcopy/ProjectLinkStore.java @@ -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})에 옮긴다. + * + *

    링크 테이블은 한 문서가 여러 프로젝트에 붙는 것을 허용하지만 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 findPrimaryProjectForDocument(UUID documentId) { + return findPrimary("project_document_link", "document_id", documentId); + } + + Optional 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 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, ) 이라 같은 쌍이 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(); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/workingcopy/QuestionWorkingCopyStore.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/workingcopy/QuestionWorkingCopyStore.java new file mode 100644 index 0000000..3bcb19a --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/workingcopy/QuestionWorkingCopyStore.java @@ -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}). + * + *

    계약의 {@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 idGenerator; + private final ProjectLinkStore projectLinks; + + QuestionWorkingCopyStore( + JdbcClient jdbcClient, + StudioRelationStore relations, + StudioJson json, + Supplier idGenerator, + ProjectLinkStore projectLinks) { + this.jdbcClient = jdbcClient; + this.relations = relations; + this.json = json; + this.idGenerator = idGenerator; + this.projectLinks = projectLinks; + } + + Optional 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 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 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 items, Set 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(); + } + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/workingcopy/StudioJson.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/workingcopy/StudioJson.java new file mode 100644 index 0000000..f0faab3 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/workingcopy/StudioJson.java @@ -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 배열로 정한 순서 있는 항목들을 읽고 쓴다. + * + *

    Jackson의 자동 POJO 바인딩을 쓰지 않고 필드를 직접 읽고 쓴다. 이 값들은 DB에 영속되는 형태라 application record의 필드 이름이 + * 바뀌면 이미 저장된 행을 읽지 못하게 된다 — 그 결합을 만들지 않으려고 컬럼 안의 key 이름을 여기서 명시적으로 고정한다. + */ +final class StudioJson { + + private final ObjectMapper mapper; + + StudioJson(ObjectMapper mapper) { + this.mapper = mapper; + } + + String orderedTextToJson(List 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 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 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 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 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 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(""); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/workingcopy/StudioRelationStore.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/workingcopy/StudioRelationStore.java new file mode 100644 index 0000000..e379cb8 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/workingcopy/StudioRelationStore.java @@ -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})를 읽고 통째로 교체한다. + * + *

    부분 갱신이 아니라 교체인 이유: 계약의 {@code relations}는 배열 전체가 편집 대상이고, 클라이언트가 보낸 배열이 곧 최종 상태다. 지운 줄을 알아내려고 + * diff를 뜨면 순서 재배열과 삭제를 구분하지 못한다. + */ +final class StudioRelationStore { + + private final JdbcClient jdbcClient; + private final Supplier idGenerator; + + StudioRelationStore(JdbcClient jdbcClient, Supplier idGenerator) { + this.jdbcClient = jdbcClient; + this.idGenerator = idGenerator; + } + + List 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 relations) { + // 이 source 가 지금 소유한 관계 id 집합. 클라이언트가 보낸 id 중 여기 있는 것만 유지한다. + Set 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()); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/workingcopy/StudioSqlSupport.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/workingcopy/StudioSqlSupport.java new file mode 100644 index 0000000..288d170 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/workingcopy/StudioSqlSupport.java @@ -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(); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/resources/db/migration/postgresql/V8__techlog_studio_working_copy.sql b/src/adapter/outbound/persistence-jpa/src/main/resources/db/migration/postgresql/V8__techlog_studio_working_copy.sql new file mode 100644 index 0000000..c979203 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/resources/db/migration/postgresql/V8__techlog_studio_working_copy.sql @@ -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; diff --git a/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/techlog/studio/StudioPersistenceIntegrationTest.java b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/techlog/studio/StudioPersistenceIntegrationTest.java new file mode 100644 index 0000000..2a1de36 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/techlog/studio/StudioPersistenceIntegrationTest.java @@ -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 위에서 돌린다. + * + *

    이 테스트가 필요한 이유는 분명하다 — 이 저장소의 표준 {@code check} 는 Testcontainers 통합 테스트를 돌리지 않으므로, 여기 있는 SQL 은 이 + * 테스트 없이는 한 번도 실행되지 않은 채 통과한다. 컴파일과 단위 테스트는 컬럼 이름 오타도, jsonb 캐스팅 누락도, 순환 FK 의 지연 검사도 검증하지 + * 못한다. + * + *

    {@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 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 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 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 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 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(); + } +} diff --git a/src/app-bootstrap/gradle.lockfile b/src/app-bootstrap/gradle.lockfile index ccddc13..be9cf56 100644 --- a/src/app-bootstrap/gradle.lockfile +++ b/src/app-bootstrap/gradle.lockfile @@ -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-container-default:2.1.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.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 @@ -248,6 +252,7 @@ org.junit:junit-bom:6.1.0=spotbugs org.latencyutils:LatencyUtils:2.0.3=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath org.mockito:mockito-core:5.20.0=functionalTestCompileClasspath,functionalTestRuntimeClasspath,mockitoAgent,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.mockito:mockito-junit-jupiter:5.20.0=functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.nibor.autolink:autolink:0.10.0=functionalTestRuntimeClasspath,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 diff --git a/src/app-bootstrap/src/functionalTest/java/dev/caskeleton/bootstrap/contract/StudioContractDriftTest.java b/src/app-bootstrap/src/functionalTest/java/dev/caskeleton/bootstrap/contract/StudioContractDriftTest.java index 74b5f1e..32e857f 100644 --- a/src/app-bootstrap/src/functionalTest/java/dev/caskeleton/bootstrap/contract/StudioContractDriftTest.java +++ b/src/app-bootstrap/src/functionalTest/java/dev/caskeleton/bootstrap/contract/StudioContractDriftTest.java @@ -200,6 +200,39 @@ class StudioContractDriftTest { .isNotEmpty(); } + /** + * 반대 방향 — 계약의 operation 이 전부 published 표면에 있는가. + * + *

    슬라이스 2~5 가 끝나 19개 operation 이 모두 구현됐으므로 이제 "published ⊆ 계약" 한 방향만으로는 부족하다. 그 방향은 + * 사라진 operation 을 잡지 못한다 — 컨트롤러를 지우거나 매핑을 잘못 옮겨도 남은 것들이 계약과 맞으면 통과한다. 양방향이 되어야 게이트가 + * 완성된다. + * + *

    새 operation 을 계약에 추가하면 이 테스트가 먼저 빨간불이 된다. 그게 의도다 — 계약이 약속한 것을 서버가 아직 제공하지 않는다는 사실이 배포 전에 + * 드러나야 한다. + */ + @Test + void everyContractOperationIsPublished() throws Exception { + JsonNode contract = readContract(); + JsonNode published = readPublishedApiDocs(); + + List missing = new ArrayList<>(); + for (Map.Entry path : contract.path("paths").properties()) { + for (Map.Entry 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}을 새 컴파일 @@ -245,7 +278,7 @@ class StudioContractDriftTest { @SpringBootConfiguration @EnableAutoConfiguration(exclude = SecurityAutoConfiguration.class) @ComponentScan("dev.caskeleton.adapter.inbound.web.techlog") - @Import(PresentationWebConfig.class) + @Import({PresentationWebConfig.class, StudioContractDriftTest.StudioDocumentTestBeans.class}) static class ContractSurfaceApp { /** @@ -301,7 +334,11 @@ class StudioContractDriftTest { @SpringBootConfiguration @EnableAutoConfiguration(exclude = SecurityAutoConfiguration.class) @ComponentScan("dev.caskeleton.adapter.inbound.web.techlog") - @Import({EnvelopeBodyAdvice.class, PresentationWebConfig.class}) + @Import({ + EnvelopeBodyAdvice.class, + PresentationWebConfig.class, + StudioContractDriftTest.StudioDocumentTestBeans.class + }) static class EnvelopeApp { /** @@ -350,6 +387,482 @@ class StudioContractDriftTest { return new ListCatalogUseCase(new StubCatalogQueryPort(), new PassThroughTransactionPort()); } + /** + * 슬라이스 2의 {@code StudioDocumentController}가 {@code @ComponentScan}에 걸리면서 필요해진 협력자들. + * + *

    이 비용은 이 게이트가 "패키지를 스캔한다"는 성질의 뒷면이다 — 새 컨트롤러가 자동으로 감시 대상이 되는 대신, 그 컨트롤러의 협력자를 여기에 채워야 컨텍스트가 + * 뜬다. 채우지 않으면 게이트가 통과가 아니라 실패로 알려준다. + * + *

    포트 구현은 전부 빈 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 findKind( + java.util.UUID documentId) { + return java.util.Optional.empty(); + } + + @Override + public java.util.Optional 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 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 + latestFor( + dev.caskeleton.application.techlog.studio.model.RecordKind kind, + java.util.UUID documentId) { + return java.util.Optional.empty(); + } + + @Override + public java.util.Optional + 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 + latestFor( + dev.caskeleton.application.techlog.studio.model.RecordKind kind, + java.util.UUID documentId) { + return java.util.Optional.empty(); + } + + @Override + public java.util.Optional + 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 + topByNextAction( + List 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 find( + java.util.UUID assetId) { + return java.util.Optional.empty(); + } + + @Override + public java.util.Optional + 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 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 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) { diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/techlog/ObjectStorageAssetBinaryAdapter.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/techlog/ObjectStorageAssetBinaryAdapter.java new file mode 100644 index 0000000..82ac561 --- /dev/null +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/techlog/ObjectStorageAssetBinaryAdapter.java @@ -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 — 새 저장 계층을 만들지 않는다). + * + *

    이 브리지가 app-bootstrap 에 있는 이유: 두 기존 포트를 잇는 구성이라 어느 한쪽 어댑터 모듈의 소유가 아니다. objectstorage 모듈은 + * techlog 를 모르고, techlog 영속 모듈은 저장 백엔드를 모른다. + * + *

    {@code ObjectStoragePort} 는 {@code @Deprecated(forRemoval = true)} 다. 그럼에도 쓰는 이유는 이 저장소에서 실제로 + * 동작하는 어댑터(filesystem/S3)가 붙어 있는 유일한 포트이기 때문이다 — 후속 {@code objectstorage.port.*} 계열에는 아직 구현이 + * 없다(실측). 선택을 이 한 클래스에 가둬 두었으므로 새 API 로 옮길 때 바뀌는 것은 여기뿐이다. + * + *

    저장 백엔드가 구성되지 않은 배포에서는 빈이 없다. 그때는 업로드·삭제만 {@code STUDIO_UNAVAILABLE} 로 거절하고 목록·조회·메타데이터 수정은 그대로 + * 동작한다 — 없는 기능 때문에 있는 기능까지 막지 않는다. + */ +@SuppressWarnings("removal") +final class ObjectStorageAssetBinaryAdapter implements AssetBinaryStoragePort { + + private final ObjectProvider objectStorage; + + ObjectStorageAssetBinaryAdapter(ObjectProvider 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; + } +} diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/techlog/TechLogStudioConfig.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/techlog/TechLogStudioConfig.java index 6256934..884fcf8 100644 --- a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/techlog/TechLogStudioConfig.java +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/techlog/TechLogStudioConfig.java @@ -1,8 +1,46 @@ 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; @@ -15,4 +53,201 @@ public class TechLogStudioConfig { 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 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); + } } diff --git a/src/app-bootstrap/src/main/resources/application-dev.yml b/src/app-bootstrap/src/main/resources/application-dev.yml index b14cd2a..b5d7378 100644 --- a/src/app-bootstrap/src/main/resources/application-dev.yml +++ b/src/app-bootstrap/src/main/resources/application-dev.yml @@ -22,6 +22,17 @@ spring: ca-skeleton: persistence: 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. diff --git a/src/app-bootstrap/src/main/resources/application-local.yml b/src/app-bootstrap/src/main/resources/application-local.yml index 07818f2..2e8a117 100644 --- a/src/app-bootstrap/src/main/resources/application-local.yml +++ b/src/app-bootstrap/src/main/resources/application-local.yml @@ -65,6 +65,25 @@ spring: properties: hibernate: 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: oauth2: resourceserver: diff --git a/src/app-bootstrap/src/main/resources/application-prod.yml b/src/app-bootstrap/src/main/resources/application-prod.yml index efa917e..9cde678 100644 --- a/src/app-bootstrap/src/main/resources/application-prod.yml +++ b/src/app-bootstrap/src/main/resources/application-prod.yml @@ -26,6 +26,17 @@ spring: ca-skeleton: persistence: 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. diff --git a/src/app-bootstrap/src/main/resources/application.yml b/src/app-bootstrap/src/main/resources/application.yml index 39dbe93..42a6d32 100644 --- a/src/app-bootstrap/src/main/resources/application.yml +++ b/src/app-bootstrap/src/main/resources/application.yml @@ -446,6 +446,16 @@ ca-skeleton: # 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. 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: # feature-rate-limit-idempotency-contract D6/§E. ttl is env-driven (<=72h, # validated in IdempotencyProperties); reaper-interval is literal operational tuning. diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/TechLogBoundaryArchTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/TechLogBoundaryArchTest.java index 7d8b223..aeaa90c 100644 --- a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/TechLogBoundaryArchTest.java +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/TechLogBoundaryArchTest.java @@ -28,6 +28,13 @@ class TechLogBoundaryArchTest { // (publication과 같은 성격) NO_CONTEXT_DEPENDS_ON_PUBLICATION_DOMAIN과 같은 모양의 // 전용 규칙도 검토한다. + // 이 규칙들의 패키지 패턴(`..techlog...`)은 계층을 가리지 않는다 — application 뿐 아니라 + // adapter 쪽 패키지도 같은 이름을 쓰면 걸린다. 그래서 어댑터 패키지를 bounded context 이름으로 + // 짓지 않는다: Studio 의 outbound 포트를 구현하는 영속 어댑터는 + // `...persistence.techlog.studio.` 에 둔다. 그 이름이 규칙을 피하려는 우회가 아니라 실제로 더 + // 정확하다 — 그 어댑터들은 asset/publication context 의 소유물이 아니라 Studio 포트의 구현이다. + // (슬라이스 4~5 에서 `...persistence.techlog.asset` 로 지었다가 이 규칙이 223건을 잡아냈다.) + @ArchTest static final ArchRule CONTENT_DOES_NOT_DEPEND_ON_SIBLING_CONTEXTS = noClasses() diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/command/CreateDocumentCommand.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/command/CreateDocumentCommand.java new file mode 100644 index 0000000..35d48b3 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/command/CreateDocumentCommand.java @@ -0,0 +1,12 @@ +package dev.caskeleton.application.techlog.studio.command; + +import dev.caskeleton.application.command.Command; +import dev.caskeleton.application.techlog.studio.model.WorkingCopyInputView; + +/** + * {@code createStudioDocument}의 입력. + * + * @param principal 감사 컬럼({@code created_by}/{@code updated_by})에 남길 주체 + */ +public record CreateDocumentCommand(WorkingCopyInputView document, String principal) + implements Command {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/command/CreatePreviewCommand.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/command/CreatePreviewCommand.java new file mode 100644 index 0000000..a3770a6 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/command/CreatePreviewCommand.java @@ -0,0 +1,9 @@ +package dev.caskeleton.application.techlog.studio.command; + +import dev.caskeleton.application.command.Command; +import java.util.UUID; + +/** {@code createStudioPreview}의 입력. */ +public record CreatePreviewCommand( + UUID documentId, long expectedVersion, UUID validationId, String principal) + implements Command {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/command/DeleteAssetCommand.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/command/DeleteAssetCommand.java new file mode 100644 index 0000000..2395c14 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/command/DeleteAssetCommand.java @@ -0,0 +1,7 @@ +package dev.caskeleton.application.techlog.studio.command; + +import dev.caskeleton.application.command.Command; +import java.util.UUID; + +/** {@code deleteStudioAsset}의 입력. */ +public record DeleteAssetCommand(UUID assetId, String principal) implements Command {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/command/PublishDocumentCommand.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/command/PublishDocumentCommand.java new file mode 100644 index 0000000..5a2c58a --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/command/PublishDocumentCommand.java @@ -0,0 +1,22 @@ +package dev.caskeleton.application.techlog.studio.command; + +import dev.caskeleton.application.command.Command; +import java.util.List; +import java.util.UUID; + +/** {@code publishStudioDocument}의 입력. */ +public record PublishDocumentCommand( + UUID documentId, + long expectedVersion, + UUID validationId, + UUID previewId, + List acknowledgedWarningCodes, + String idempotencyKey, + String principal) + implements Command { + + public PublishDocumentCommand { + acknowledgedWarningCodes = + acknowledgedWarningCodes == null ? List.of() : List.copyOf(acknowledgedWarningCodes); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/command/SaveDocumentCommand.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/command/SaveDocumentCommand.java new file mode 100644 index 0000000..0a2efef --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/command/SaveDocumentCommand.java @@ -0,0 +1,10 @@ +package dev.caskeleton.application.techlog.studio.command; + +import dev.caskeleton.application.command.Command; +import dev.caskeleton.application.techlog.studio.model.WorkingCopyInputView; +import java.util.UUID; + +/** {@code saveStudioDocument}의 입력. */ +public record SaveDocumentCommand( + UUID documentId, long expectedVersion, WorkingCopyInputView document, String principal) + implements Command {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/command/UnpublishPublicationCommand.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/command/UnpublishPublicationCommand.java new file mode 100644 index 0000000..9044d1f --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/command/UnpublishPublicationCommand.java @@ -0,0 +1,8 @@ +package dev.caskeleton.application.techlog.studio.command; + +import dev.caskeleton.application.command.Command; +import java.util.UUID; + +/** {@code unpublishStudioPublication}의 입력. */ +public record UnpublishPublicationCommand( + UUID publicationId, long expectedPublicationRevision, String principal) implements Command {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/command/UpdateAssetCommand.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/command/UpdateAssetCommand.java new file mode 100644 index 0000000..8f99f6d --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/command/UpdateAssetCommand.java @@ -0,0 +1,23 @@ +package dev.caskeleton.application.techlog.studio.command; + +import dev.caskeleton.application.command.Command; +import dev.caskeleton.application.techlog.studio.model.AssetKindView; +import dev.caskeleton.application.techlog.studio.model.AssetManagementStatusView; +import java.util.UUID; + +/** + * {@code updateStudioAsset}의 입력. 계약이 허용하는 것은 {@code altText}, {@code decorative}, {@code kind}, 그리고 + * {@code READY ↔ ARCHIVED} 전환뿐이다. + * + * @param altTextProvided {@code altText}가 요청에 실렸는지. null 로 지우는 것과 아예 안 보낸 것을 구분한다. + */ +public record UpdateAssetCommand( + UUID assetId, + long expectedVersion, + AssetKindView kind, + String altText, + boolean altTextProvided, + Boolean decorative, + AssetManagementStatusView managementStatus, + String principal) + implements Command {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/command/UploadAssetCommand.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/command/UploadAssetCommand.java new file mode 100644 index 0000000..7d07e9d --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/command/UploadAssetCommand.java @@ -0,0 +1,74 @@ +package dev.caskeleton.application.techlog.studio.command; + +import dev.caskeleton.application.command.Command; +import dev.caskeleton.application.techlog.studio.model.AssetKindView; +import java.util.Objects; + +/** + * {@code uploadStudioAsset}의 입력. + * + *

    record 가 아니라 class 인 이유는 바이트 배열 때문이다 — 배열을 record 구성요소로 두면 {@code equals} 가 내용이 아니라 참조를 비교해 + * "같은 파일"을 다르다고 판정한다. + */ +public final class UploadAssetCommand implements Command { + + private final String originalFilename; + private final String declaredMediaType; + private final byte[] content; + private final AssetKindView kind; + private final String altText; + private final boolean decorative; + private final String principal; + + /** + * @param declaredMediaType 클라이언트가 말한 것. Backend 는 이 값을 신뢰하지 않고 내용으로 다시 판정한다 (계약 설명). + */ + public UploadAssetCommand( + String originalFilename, + String declaredMediaType, + byte[] content, + AssetKindView kind, + String altText, + boolean decorative, + String principal) { + this.originalFilename = originalFilename; + this.declaredMediaType = declaredMediaType; + this.content = Objects.requireNonNull(content, "content").clone(); + this.kind = kind; + this.altText = altText; + this.decorative = decorative; + this.principal = principal; + } + + public String originalFilename() { + return originalFilename; + } + + public String declaredMediaType() { + return declaredMediaType; + } + + public byte[] content() { + return content.clone(); + } + + public int byteSize() { + return content.length; + } + + public AssetKindView kind() { + return kind; + } + + public String altText() { + return altText; + } + + public boolean decorative() { + return decorative; + } + + public String principal() { + return principal; + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/command/ValidateDocumentCommand.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/command/ValidateDocumentCommand.java new file mode 100644 index 0000000..d871e98 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/command/ValidateDocumentCommand.java @@ -0,0 +1,8 @@ +package dev.caskeleton.application.techlog.studio.command; + +import dev.caskeleton.application.command.Command; +import java.util.UUID; + +/** {@code validateStudioDocument}의 입력. */ +public record ValidateDocumentCommand(UUID documentId, long expectedVersion, String principal) + implements Command {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/AssetDetailView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/AssetDetailView.java new file mode 100644 index 0000000..dd7c1cc --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/AssetDetailView.java @@ -0,0 +1,16 @@ +package dev.caskeleton.application.techlog.studio.model; + +import java.util.List; + +/** + * 계약 {@code AssetDetail}. + * + * @param hasPublicationHistory 참이면 hard delete 를 금지하고 {@code ARCHIVED} 전환만 허용한다 + */ +public record AssetDetailView( + AssetView asset, List usages, boolean hasPublicationHistory) { + + public AssetDetailView { + usages = usages == null ? List.of() : List.copyOf(usages); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/AssetKindView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/AssetKindView.java new file mode 100644 index 0000000..cdc8340 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/AssetKindView.java @@ -0,0 +1,8 @@ +package dev.caskeleton.application.techlog.studio.model; + +/** 계약 {@code AssetKind}. */ +public enum AssetKindView { + IMAGE, + DIAGRAM, + ATTACHMENT +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/AssetManagementStatusView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/AssetManagementStatusView.java new file mode 100644 index 0000000..b2f4ff8 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/AssetManagementStatusView.java @@ -0,0 +1,13 @@ +package dev.caskeleton.application.techlog.studio.model; + +/** + * 계약 {@code AssetManagementStatus}. + * + *

    {@link #REJECTED}/{@link #QUARANTINED}는 서버 검증 결과이며 클라이언트가 지정할 수 없다. + */ +public enum AssetManagementStatusView { + READY, + ARCHIVED, + REJECTED, + QUARANTINED +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/AssetManifestEntry.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/AssetManifestEntry.java new file mode 100644 index 0000000..cb9a6aa --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/AssetManifestEntry.java @@ -0,0 +1,18 @@ +package dev.caskeleton.application.techlog.studio.model; + +import java.util.UUID; + +/** + * 게시 시점에 고정하는 Asset descriptor 한 건({@code publication_snapshot.asset_manifest}). + * + *

    이후 Asset 이 교체돼도 과거 Snapshot 의 표현이 변하지 않게 하는 장치다 — ADR-002 가 요구하는 역사적 불변성이며, ADR-005 가 인정한 "네 + * 화면 중 Snapshot 만 다른 유일한 지점"이다. + */ +public record AssetManifestEntry( + UUID assetId, + String assetKey, + String mediaType, + String publicPath, + Integer width, + Integer height, + boolean decorative) {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/AssetPageView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/AssetPageView.java new file mode 100644 index 0000000..17b9948 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/AssetPageView.java @@ -0,0 +1,11 @@ +package dev.caskeleton.application.techlog.studio.model; + +import java.util.List; + +/** 계약 {@code AssetPage}. */ +public record AssetPageView(List items, String nextCursor) { + + public AssetPageView { + items = items == null ? List.of() : List.copyOf(items); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/AssetUsageView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/AssetUsageView.java new file mode 100644 index 0000000..526b621 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/AssetUsageView.java @@ -0,0 +1,7 @@ +package dev.caskeleton.application.techlog.studio.model; + +import java.util.UUID; + +/** 계약 {@code AssetUsage}. 이 Asset 을 쓰는 문서 한 건. */ +public record AssetUsageView( + UUID documentId, RecordKind documentKind, String title, boolean published) {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/AssetView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/AssetView.java new file mode 100644 index 0000000..a445182 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/AssetView.java @@ -0,0 +1,28 @@ +package dev.caskeleton.application.techlog.studio.model; + +import java.time.Instant; +import java.util.UUID; + +/** + * 계약 {@code Asset}. + * + * @param assetKey Public content 가 쓰는 안정적인 key. object storage key 도 raw URL 도 아니며 immutable 이다 — + * 공개 이력이 있는 key 의 재사용은 금지한다. + */ +public record AssetView( + UUID id, + String assetKey, + AssetKindView kind, + String mediaType, + String originalFilename, + long byteSize, + Integer width, + Integer height, + String altText, + boolean decorative, + AssetManagementStatusView managementStatus, + String publicPath, + int usageCount, + long version, + Instant createdAt, + Instant updatedAt) {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/DashboardTotalsView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/DashboardTotalsView.java new file mode 100644 index 0000000..deb651d --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/DashboardTotalsView.java @@ -0,0 +1,5 @@ +package dev.caskeleton.application.techlog.studio.model; + +/** 계약 {@code DashboardTotals}. */ +public record DashboardTotalsView( + int documents, int needsValidation, int readyToPublish, int publications) {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/DashboardView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/DashboardView.java new file mode 100644 index 0000000..9a69946 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/DashboardView.java @@ -0,0 +1,17 @@ +package dev.caskeleton.application.techlog.studio.model; + +import java.util.List; + +/** 계약 {@code StudioDashboard}. */ +public record DashboardView( + List continueWriting, + List readyToPublish, + List recentPublications, + DashboardTotalsView totals) { + + public DashboardView { + continueWriting = continueWriting == null ? List.of() : List.copyOf(continueWriting); + readyToPublish = readyToPublish == null ? List.of() : List.copyOf(readyToPublish); + recentPublications = recentPublications == null ? List.of() : List.copyOf(recentPublications); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/DecisionStatusView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/DecisionStatusView.java new file mode 100644 index 0000000..62b73fd --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/DecisionStatusView.java @@ -0,0 +1,7 @@ +package dev.caskeleton.application.techlog.studio.model; + +/** 계약의 UI 용어(ADR-003). Domain의 {@code ACCEPTED}가 {@link #ADOPTED}로 보인다. */ +public enum DecisionStatusView { + PROPOSED, + ADOPTED +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/DisplayTargetView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/DisplayTargetView.java new file mode 100644 index 0000000..82814d8 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/DisplayTargetView.java @@ -0,0 +1,6 @@ +package dev.caskeleton.application.techlog.studio.model; + +import java.util.UUID; + +/** 계약 {@code DisplayTarget}. */ +public record DisplayTargetView(UUID id, String label, String publicPath) {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/DocumentPageView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/DocumentPageView.java new file mode 100644 index 0000000..407bd7a --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/DocumentPageView.java @@ -0,0 +1,11 @@ +package dev.caskeleton.application.techlog.studio.model; + +import java.util.List; + +/** 계약 {@code DocumentPage}. */ +public record DocumentPageView(List items, String nextCursor) { + + public DocumentPageView { + items = items == null ? List.of() : List.copyOf(items); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/DocumentSort.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/DocumentSort.java new file mode 100644 index 0000000..ada29f4 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/DocumentSort.java @@ -0,0 +1,8 @@ +package dev.caskeleton.application.techlog.studio.model; + +/** 계약 파라미터 {@code sort} (studio-v1.yaml components.parameters.DocumentSort). */ +public enum DocumentSort { + UPDATED_DESC, + UPDATED_ASC, + TITLE_ASC +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/DocumentSummaryView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/DocumentSummaryView.java new file mode 100644 index 0000000..38c3631 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/DocumentSummaryView.java @@ -0,0 +1,22 @@ +package dev.caskeleton.application.techlog.studio.model; + +import java.time.Instant; +import java.util.UUID; + +/** + * 계약 {@code DocumentSummary}. 목록·대시보드가 쓰는 요약이다. + * + * @param publishedVersion 게시한 적이 없으면 null + * @param hasUnpublishedChanges {@code version != publishedVersion}. 게시 취소 상태에서도 과거 + * publishedVersion과 비교한다(계약 주석). + */ +public record DocumentSummaryView( + UUID id, + String title, + RecordKind kind, + DisplayTargetView project, + Instant updatedAt, + PublicationStatusView publicationStatus, + Long publishedVersion, + boolean hasUnpublishedChanges, + NextAction nextAction) {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/NextAction.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/NextAction.java new file mode 100644 index 0000000..3ef89d0 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/NextAction.java @@ -0,0 +1,11 @@ +package dev.caskeleton.application.techlog.studio.model; + +/** 계약 {@code NextAction}. 서버가 조회 시점에 계산하는 Studio projection이며 domain 컬럼에 저장하지 않는다 (spec §6.3). */ +public enum NextAction { + CONTINUE_EDITING, + VALIDATE, + FIX_VALIDATION, + CREATE_PREVIEW, + PUBLISH, + NONE +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/OrderedTextView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/OrderedTextView.java new file mode 100644 index 0000000..582ddce --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/OrderedTextView.java @@ -0,0 +1,6 @@ +package dev.caskeleton.application.techlog.studio.model; + +import java.util.UUID; + +/** 계약 {@code OrderedText}. */ +public record OrderedTextView(UUID id, String text, int order) {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/PreviewDetailView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/PreviewDetailView.java new file mode 100644 index 0000000..79b9074 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/PreviewDetailView.java @@ -0,0 +1,10 @@ +package dev.caskeleton.application.techlog.studio.model; + +import java.util.UUID; + +/** 계약 {@code PreviewDetail}. */ +public record PreviewDetailView( + PublicPreviewView preview, + PreviewState state, + long currentDocumentVersion, + UUID currentValidationId) {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/PreviewState.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/PreviewState.java new file mode 100644 index 0000000..95e4ac8 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/PreviewState.java @@ -0,0 +1,8 @@ +package dev.caskeleton.application.techlog.studio.model; + +/** 계약 {@code PreviewDetail.state}. 저장하지 않고 조회 시 계산한다(spec §7.3). */ +public enum PreviewState { + CURRENT, + STALE, + EXPIRED +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/PublicPaths.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/PublicPaths.java new file mode 100644 index 0000000..50ea669 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/PublicPaths.java @@ -0,0 +1,33 @@ +package dev.caskeleton.application.techlog.studio.model; + +/** + * 공개 경로 규칙(설계 01장 §3, {@code public-v1.yaml}의 path). + * + *

    한 곳에 둔다 — 렌더 모델의 {@code publicPath}, 카탈로그의 {@code publicPath}, 게시 시 만드는 {@code public_route}가 + * 서로 다른 규칙으로 만들어지면 미리보기의 링크와 실제 공개 주소가 달라진다. + */ +public final class PublicPaths { + + private PublicPaths() {} + + /** + * 이 유형과 slug 의 공개 경로. + * + * @param projectSlug {@code PROJECT_DECISION}에만 쓰인다. 없으면 결정 경로를 만들 수 없어 null을 준다. + * @return slug가 비어 있으면 null — 아직 공개 주소가 없는 초안이다. + */ + public static String forKind(RecordKind kind, String slug, String projectSlug) { + if (slug == null || slug.isBlank()) { + return null; + } + return switch (kind) { + case CASE -> "/cases/" + slug; + case REFERENCE -> "/references/" + slug; + case QUESTION -> "/questions/" + slug; + case PROJECT_DECISION -> + (projectSlug == null || projectSlug.isBlank()) + ? null + : "/projects/" + projectSlug + "/decisions/" + slug; + }; + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/PublicPreviewView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/PublicPreviewView.java new file mode 100644 index 0000000..938b150 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/PublicPreviewView.java @@ -0,0 +1,21 @@ +package dev.caskeleton.application.techlog.studio.model; + +import java.time.Instant; +import java.util.UUID; + +/** + * 계약 {@code PublicPreview}. + * + *

    {@code renderModel}을 application 계층에서 구조화된 타입으로 다시 모델링하지 않고 직렬화된 JSON 문자열로 들고 다닌다. 이유: 이 값은 + * {@code studio_preview.render_model}(jsonb)에 그대로 저장되고 웹 계층에서 계약 DTO로 그대로 나가는 통과 데이터이며, 중간에 한 번 더 + * 도메인 타입으로 접었다 펴면 렌더러가 만든 모양과 계약 모양 사이에 조용한 손실이 생길 수 있다. 렌더링 자체의 타입 안전성은 렌더러가 계약 DTO를 직접 만들며 책임진다. + */ +public record PublicPreviewView( + UUID previewId, + UUID documentId, + long previewVersion, + UUID validationId, + String dependencyRevision, + Instant createdAt, + Instant expiresAt, + String renderModelJson) {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/PublicationActionView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/PublicationActionView.java new file mode 100644 index 0000000..3c5565d --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/PublicationActionView.java @@ -0,0 +1,8 @@ +package dev.caskeleton.application.techlog.studio.model; + +/** 계약 {@code PublicationAction} (studio-v1.yaml:1661). */ +public enum PublicationActionView { + VIEW_SNAPSHOT, + VIEW_SOURCE_SNAPSHOT, + UNPUBLISH +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/PublicationAggregateStatus.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/PublicationAggregateStatus.java new file mode 100644 index 0000000..0288e08 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/PublicationAggregateStatus.java @@ -0,0 +1,7 @@ +package dev.caskeleton.application.techlog.studio.model; + +/** 계약 {@code PublicationAggregate.status}. */ +public enum PublicationAggregateStatus { + PUBLISHED, + UNPUBLISHED +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/PublicationAggregateView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/PublicationAggregateView.java new file mode 100644 index 0000000..1eaa80a --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/PublicationAggregateView.java @@ -0,0 +1,15 @@ +package dev.caskeleton.application.techlog.studio.model; + +import java.time.Instant; +import java.util.UUID; + +/** 계약 {@code PublicationAggregate}. 현재 게시 상태이며 게시 이력과 구분한다. */ +public record PublicationAggregateView( + UUID publicationId, + UUID documentId, + PublicationAggregateStatus status, + long publishedVersion, + long publicationRevision, + UUID latestEventId, + String publicPath, + Instant updatedAt) {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/PublicationEventTypeView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/PublicationEventTypeView.java new file mode 100644 index 0000000..7c9acb8 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/PublicationEventTypeView.java @@ -0,0 +1,8 @@ +package dev.caskeleton.application.techlog.studio.model; + +/** 계약 {@code PublicationEventType}. */ +public enum PublicationEventTypeView { + PUBLISHED, + REPUBLISHED, + UNPUBLISHED +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/PublicationEventView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/PublicationEventView.java new file mode 100644 index 0000000..51b2806 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/PublicationEventView.java @@ -0,0 +1,19 @@ +package dev.caskeleton.application.techlog.studio.model; + +import java.time.Instant; +import java.util.UUID; + +/** + * 계약 {@code PublicationEvent}. 불변 이력이며 생성 후 수정하지 않는다. + * + * @param sourcePublishedEventId {@code UNPUBLISHED} 이벤트가 참조하는 마지막 공개 Snapshot의 Event id + */ +public record PublicationEventView( + UUID publicationEventId, + UUID publicationId, + UUID documentId, + PublicationEventTypeView type, + Instant occurredAt, + long publishedVersion, + UUID sourcePublishedEventId, + boolean snapshotAvailable) {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/PublicationListItemView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/PublicationListItemView.java new file mode 100644 index 0000000..2e2f0d7 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/PublicationListItemView.java @@ -0,0 +1,15 @@ +package dev.caskeleton.application.techlog.studio.model; + +import java.util.List; + +/** 계약 {@code PublicationListItem}. */ +public record PublicationListItemView( + PublicationEventView event, + PublicationAggregateView publication, + DocumentSummaryView document, + List availableActions) { + + public PublicationListItemView { + availableActions = availableActions == null ? List.of() : List.copyOf(availableActions); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/PublicationPageView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/PublicationPageView.java new file mode 100644 index 0000000..8d5a354 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/PublicationPageView.java @@ -0,0 +1,11 @@ +package dev.caskeleton.application.techlog.studio.model; + +import java.util.List; + +/** 계약 {@code PublicationPage}. */ +public record PublicationPageView(List items, String nextCursor) { + + public PublicationPageView { + items = items == null ? List.of() : List.copyOf(items); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/PublicationSnapshotView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/PublicationSnapshotView.java new file mode 100644 index 0000000..b57d54d --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/PublicationSnapshotView.java @@ -0,0 +1,10 @@ +package dev.caskeleton.application.techlog.studio.model; + +/** + * 계약 {@code PublicationSnapshot}. 게시 시점의 불변 {@code PublicRenderModel} 이며 현재 source 로 다시 만들지 않는다. + */ +public record PublicationSnapshotView( + PublicationEventView event, + String renderModelJson, + String contentFormatVersion, + String rendererContractVersion) {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/PublicationStatusView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/PublicationStatusView.java new file mode 100644 index 0000000..a6addc6 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/PublicationStatusView.java @@ -0,0 +1,8 @@ +package dev.caskeleton.application.techlog.studio.model; + +/** 계약 {@code PublicationStatus}. */ +public enum PublicationStatusView { + NEVER_PUBLISHED, + PUBLISHED, + UNPUBLISHED +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/PublishResultView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/PublishResultView.java new file mode 100644 index 0000000..992c29d --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/PublishResultView.java @@ -0,0 +1,4 @@ +package dev.caskeleton.application.techlog.studio.model; + +/** 계약 {@code PublishResult}. */ +public record PublishResultView(PublicationAggregateView publication, PublicationEventView event) {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/QuestionOptionView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/QuestionOptionView.java new file mode 100644 index 0000000..6fba33e --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/QuestionOptionView.java @@ -0,0 +1,6 @@ +package dev.caskeleton.application.techlog.studio.model; + +import java.util.UUID; + +/** 계약 {@code QuestionOption}. */ +public record QuestionOptionView(UUID id, String title, String description, int order) {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/QuestionResolutionView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/QuestionResolutionView.java new file mode 100644 index 0000000..51de31e --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/QuestionResolutionView.java @@ -0,0 +1,6 @@ +package dev.caskeleton.application.techlog.studio.model; + +import java.util.UUID; + +/** 계약 {@code QuestionResolution}. */ +public record QuestionResolutionView(String summary, UUID evidenceTargetId, String linkLabel) {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/QuestionStatusView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/QuestionStatusView.java new file mode 100644 index 0000000..e28fccd --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/QuestionStatusView.java @@ -0,0 +1,10 @@ +package dev.caskeleton.application.techlog.studio.model; + +/** + * 계약의 축약 상태(ADR-003). Domain의 {@code OPEN}/{@code INVESTIGATING}/{@code PAUSED}가 모두 {@link #OPEN}으로 + * 보이며, 그 역방향 변환은 Domain 상태를 덮어쓰지 않는다. + */ +public enum QuestionStatusView { + OPEN, + RESOLVED +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/RecordKind.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/RecordKind.java new file mode 100644 index 0000000..4659176 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/RecordKind.java @@ -0,0 +1,12 @@ +package dev.caskeleton.application.techlog.studio.model; + +/** + * Studio 편집 대상 유형. 계약({@code studio-v1.yaml} {@code RecordKind})의 discriminator이며 Domain Aggregate가 + * 아니다 — ADR-003대로 네 유형은 각자 자기 Aggregate와 테이블을 그대로 소유한다. + */ +public enum RecordKind { + CASE, + REFERENCE, + QUESTION, + PROJECT_DECISION +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/ReferenceRuleView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/ReferenceRuleView.java new file mode 100644 index 0000000..3d93f39 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/ReferenceRuleView.java @@ -0,0 +1,6 @@ +package dev.caskeleton.application.techlog.studio.model; + +import java.util.UUID; + +/** 계약 {@code ReferenceRule}. */ +public record ReferenceRuleView(UUID id, String title, String body, int order) {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/RelationView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/RelationView.java new file mode 100644 index 0000000..f739532 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/RelationView.java @@ -0,0 +1,9 @@ +package dev.caskeleton.application.techlog.studio.model; + +import java.util.UUID; + +/** + * 계약 {@code Relation} / {@code RelationInput}. {@code targetId}가 null일 수 있는 것은 아직 대상을 고르지 않은 관계 줄도 + * 저장할 수 있어야 하기 때문이다 — 게시 가능 여부는 검증이 판단한다. + */ +public record RelationView(UUID id, UUID targetId, String reason, int order) {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/RenderInput.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/RenderInput.java new file mode 100644 index 0000000..2ea07d4 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/RenderInput.java @@ -0,0 +1,29 @@ +package dev.caskeleton.application.techlog.studio.model; + +import java.time.Instant; +import java.util.List; +import java.util.Map; + +/** + * 렌더러가 {@code PublicRenderModel}을 만드는 데 필요한 모든 것. 렌더러는 이 값 밖의 어떤 상태도 읽지 않는다 — 그래야 + * Preview·공개·Snapshot 세 화면이 같은 입력에 같은 출력을 낸다(ADR-005). + * + * @param assetsByKey {@code asset_key} → 해석된 Asset. Snapshot은 게시 시점에 고정된 manifest를 넣고 나머지 화면은 현재 + * 상태를 넣는다 — 그것이 ADR-002가 요구하는 유일하게 허용된 차이다. + */ +public record RenderInput( + WorkingCopyView document, + String publicPath, + DisplayTargetView topic, + DisplayTargetView project, + List relations, + DisplayTargetView resolutionEvidenceTarget, + Map assetsByKey, + String dependencyRevision, + Instant generatedAt) { + + public RenderInput { + relations = relations == null ? List.of() : List.copyOf(relations); + assetsByKey = assetsByKey == null ? Map.of() : Map.copyOf(assetsByKey); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/ResolvedAssetView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/ResolvedAssetView.java new file mode 100644 index 0000000..a6fb5fd --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/ResolvedAssetView.java @@ -0,0 +1,18 @@ +package dev.caskeleton.application.techlog.studio.model; + +import java.util.UUID; + +/** + * 계약 {@code ResolvedAsset}. 네 화면(즉시 미리보기 / Public Preview / 공개 / Snapshot)이 같은 resolver를 거쳐 같은 결과를 + * 얻어야 한다(ADR-005). + * + * @param publicPath {@code asset_key}로 찾은 현재 승인된 전송 경로. 본문에는 이 값을 저장하지 않는다. + */ +public record ResolvedAssetView( + UUID assetId, + String assetKey, + String mediaType, + String publicPath, + Integer width, + Integer height, + boolean decorative) {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/ResolvedRelationView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/ResolvedRelationView.java new file mode 100644 index 0000000..e64c2d4 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/ResolvedRelationView.java @@ -0,0 +1,18 @@ +package dev.caskeleton.application.techlog.studio.model; + +import java.util.UUID; + +/** + * 계약 {@code ResolvedRelation}. 편집본의 관계에 대상의 제목·공개 경로를 붙인 것이다. + * + * @param targetKind 대상이 무엇인지. {@code PROJECT}는 {@link RecordKind}에 없는 값이라 문자열로 둔다 — 계약({@code + * ResolvedRelation.targetKind})의 enum이 RecordKind보다 하나 넓다. + */ +public record ResolvedRelationView( + UUID id, + UUID targetId, + String targetKind, + String title, + String publicPath, + String reason, + int order) {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/ValidationIssueView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/ValidationIssueView.java new file mode 100644 index 0000000..e4e2016 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/ValidationIssueView.java @@ -0,0 +1,9 @@ +package dev.caskeleton.application.techlog.studio.model; + +/** + * 계약 {@code ValidationIssue}. + * + * @param path 영향을 받은 필드의 JSON Pointer + */ +public record ValidationIssueView( + String code, ValidationSeverity severity, String path, String message) {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/ValidationReportView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/ValidationReportView.java new file mode 100644 index 0000000..6fc16f9 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/ValidationReportView.java @@ -0,0 +1,24 @@ +package dev.caskeleton.application.techlog.studio.model; + +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +/** + * 계약 {@code ValidationReport}. 일급 artifact이며 {@code studio_validation}에 영속한다 — 실행 결과를 그때그때 반환하고 버리지 + * 않는다. + */ +public record ValidationReportView( + UUID validationId, + UUID documentId, + long validatedVersion, + ValidationStatus status, + List issues, + Instant validatedAt, + Instant validUntil, + String dependencyRevision) { + + public ValidationReportView { + issues = issues == null ? List.of() : List.copyOf(issues); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/ValidationSeverity.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/ValidationSeverity.java new file mode 100644 index 0000000..8671912 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/ValidationSeverity.java @@ -0,0 +1,7 @@ +package dev.caskeleton.application.techlog.studio.model; + +/** 계약 {@code ValidationIssue.severity}. */ +public enum ValidationSeverity { + ERROR, + WARNING +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/ValidationStatus.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/ValidationStatus.java new file mode 100644 index 0000000..439edb8 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/ValidationStatus.java @@ -0,0 +1,8 @@ +package dev.caskeleton.application.techlog.studio.model; + +/** 계약 {@code ValidationReport.status}. */ +public enum ValidationStatus { + INVALID, + WARNINGS, + VALID +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/WorkingCopyBaseInput.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/WorkingCopyBaseInput.java new file mode 100644 index 0000000..9d7e4b2 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/WorkingCopyBaseInput.java @@ -0,0 +1,26 @@ +package dev.caskeleton.application.techlog.studio.model; + +import java.util.List; +import java.util.UUID; + +/** + * 계약 {@code WorkingCopyInputBase}. 네 유형이 공유하는 편집 필드다. + * + *

    불완전한 초안도 저장할 수 있어야 하므로 빈 문자열과 null을 허용한다 — 게시 가능 여부는 {@code validateStudioDocument}가 판단한다(계약 + * 주석). + * + * @param slug 빈 문자열은 "아직 정하지 않음"이다. null이 아니다. + */ +public record WorkingCopyBaseInput( + RecordKind kind, + String title, + String slug, + String summary, + UUID topicId, + UUID projectId, + List relations) { + + public WorkingCopyBaseInput { + relations = relations == null ? List.of() : List.copyOf(relations); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/WorkingCopyDetailView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/WorkingCopyDetailView.java new file mode 100644 index 0000000..43407f1 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/WorkingCopyDetailView.java @@ -0,0 +1,15 @@ +package dev.caskeleton.application.techlog.studio.model; + +/** + * 계약 {@code WorkingCopyDetail}. 편집본과 그 주변 상태를 한 번에 준다. + * + *

    {@code currentValidation} / {@code latestPreview} / {@code currentPublication}은 계약상 nullable이며 + * 각각 아직 검증·미리보기·게시하지 않은 상태를 뜻한다. {@code nextAction}은 저장하지 않고 조회 시점에 계산한다(spec §6.3). + */ +public record WorkingCopyDetailView( + WorkingCopyView document, + ValidationReportView currentValidation, + PublicPreviewView latestPreview, + PublicationAggregateView currentPublication, + String dependencyRevision, + NextAction nextAction) {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/WorkingCopyInputView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/WorkingCopyInputView.java new file mode 100644 index 0000000..7aebd04 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/WorkingCopyInputView.java @@ -0,0 +1,86 @@ +package dev.caskeleton.application.techlog.studio.model; + +import java.time.LocalDate; +import java.util.List; + +/** + * 계약 {@code WorkingCopyInput} union. 저장 요청으로 들어오는 편집 내용이다. + * + *

    sealed로 두는 이유는 use case의 dispatch가 네 유형을 빠짐없이 다루도록 컴파일러가 강제하게 하기 위해서다 — 유형이 늘면 switch가 컴파일 오류로 + * 알려준다. + */ +public sealed interface WorkingCopyInputView { + + WorkingCopyBaseInput base(); + + default RecordKind kind() { + return base().kind(); + } + + /** 계약 {@code CaseInput}. */ + record CaseInputView( + WorkingCopyBaseInput base, + String problem, + String conclusion, + String environment, + String reproduction, + LocalDate lastVerifiedOn, + String bodyMarkdown) + implements WorkingCopyInputView {} + + /** 계약 {@code ReferenceInput}. */ + record ReferenceInputView( + WorkingCopyBaseInput base, + String purpose, + List rules, + List applyWhen, + List exceptions, + List examples, + LocalDate verifiedOn) + implements WorkingCopyInputView { + + public ReferenceInputView { + rules = rules == null ? List.of() : List.copyOf(rules); + applyWhen = applyWhen == null ? List.of() : List.copyOf(applyWhen); + exceptions = exceptions == null ? List.of() : List.copyOf(exceptions); + examples = examples == null ? List.of() : List.copyOf(examples); + } + } + + /** 계약 {@code QuestionInput}. */ + record QuestionInputView( + WorkingCopyBaseInput base, + QuestionStatusView questionStatus, + List facts, + List assumptions, + List unknowns, + List constraints, + List options, + String nextValidation, + QuestionResolutionView resolution) + implements WorkingCopyInputView { + + public QuestionInputView { + facts = facts == null ? List.of() : List.copyOf(facts); + assumptions = assumptions == null ? List.of() : List.copyOf(assumptions); + unknowns = unknowns == null ? List.of() : List.copyOf(unknowns); + constraints = constraints == null ? List.of() : List.copyOf(constraints); + options = options == null ? List.of() : List.copyOf(options); + } + } + + /** 계약 {@code ProjectDecisionInput}. */ + record ProjectDecisionInputView( + WorkingCopyBaseInput base, + DecisionStatusView decisionStatus, + LocalDate decidedOn, + String statement, + String rationale, + List consequences) + implements WorkingCopyInputView { + + public ProjectDecisionInputView { + consequences = consequences == null ? List.of() : List.copyOf(consequences); + } + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/WorkingCopyView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/WorkingCopyView.java new file mode 100644 index 0000000..d0097d7 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/WorkingCopyView.java @@ -0,0 +1,83 @@ +package dev.caskeleton.application.techlog.studio.model; + +import java.time.Instant; +import java.time.LocalDate; +import java.util.List; +import java.util.UUID; + +/** + * 계약 {@code WorkingCopy} union. 저장된 편집본을 읽어 돌려줄 때의 모양이다. + * + *

    {@code id}는 계약대로 source aggregate id를 그대로 쓴다 — 별도 Studio surrogate id를 만들지 않는다 (ADR-003). + */ +public sealed interface WorkingCopyView { + + UUID id(); + + long version(); + + Instant updatedAt(); + + WorkingCopyBaseInput base(); + + default RecordKind kind() { + return base().kind(); + } + + /** 계약 {@code CaseWorkingCopy}. */ + record CaseWorkingCopyView( + UUID id, + long version, + Instant updatedAt, + WorkingCopyBaseInput base, + String problem, + String conclusion, + String environment, + String reproduction, + LocalDate lastVerifiedOn, + String bodyMarkdown) + implements WorkingCopyView {} + + /** 계약 {@code ReferenceWorkingCopy}. */ + record ReferenceWorkingCopyView( + UUID id, + long version, + Instant updatedAt, + WorkingCopyBaseInput base, + String purpose, + List rules, + List applyWhen, + List exceptions, + List examples, + LocalDate verifiedOn) + implements WorkingCopyView {} + + /** 계약 {@code QuestionWorkingCopy}. */ + record QuestionWorkingCopyView( + UUID id, + long version, + Instant updatedAt, + WorkingCopyBaseInput base, + QuestionStatusView questionStatus, + List facts, + List assumptions, + List unknowns, + List constraints, + List options, + String nextValidation, + QuestionResolutionView resolution) + implements WorkingCopyView {} + + /** 계약 {@code ProjectDecisionWorkingCopy}. */ + record ProjectDecisionWorkingCopyView( + UUID id, + long version, + Instant updatedAt, + WorkingCopyBaseInput base, + DecisionStatusView decisionStatus, + LocalDate decidedOn, + String statement, + String rationale, + List consequences) + implements WorkingCopyView {} +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/port/out/AssetBinaryStoragePort.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/port/out/AssetBinaryStoragePort.java new file mode 100644 index 0000000..c58b255 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/port/out/AssetBinaryStoragePort.java @@ -0,0 +1,20 @@ +package dev.caskeleton.application.techlog.studio.port.out; + +/** + * Asset 바이너리 저장. spec §9 — Studio 는 새 저장 계층을 만들지 않고 기존 object storage 어댑터에 위임한다. + * + *

    {@code assetKey}(안정적인 참조)와 {@code objectKey}(저장 위치)를 분리한다 — 본문에 저장소 경로가 새어 나가면 저장소를 바꿀 때 과거 + * 문서가 전부 깨진다(설계 05장 §3.1). + */ +public interface AssetBinaryStoragePort { + + /** + * 바이너리를 저장하고 저장 위치를 돌려준다. + * + * @throws dev.caskeleton.application.techlog.error.StudioException 저장 계층이 구성되지 않았으면 {@code + * STUDIO_UNAVAILABLE} + */ + String store(String objectKey, byte[] content, String mediaType); + + void delete(String objectKey); +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/port/out/AssetRepositoryPort.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/port/out/AssetRepositoryPort.java new file mode 100644 index 0000000..7bebb7d --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/port/out/AssetRepositoryPort.java @@ -0,0 +1,63 @@ +package dev.caskeleton.application.techlog.studio.port.out; + +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.AssetView; +import dev.caskeleton.application.techlog.studio.query.ListAssetsQuery; +import java.util.Optional; +import java.util.UUID; + +/** {@code asset} 메타데이터. 바이너리는 {@link AssetBinaryStoragePort} 가 소유한다(spec §9). */ +public interface AssetRepositoryPort { + + AssetPageView list(ListAssetsQuery query); + + Optional find(UUID assetId); + + Optional findDetail(UUID assetId); + + AssetView create(NewAsset asset, String principal); + + /** + * 낙관적 잠금 갱신. + * + * @return {@code expectedVersion} 이 현재 버전과 다르면 {@link Optional#empty()} + */ + Optional update( + UUID assetId, + long expectedVersion, + AssetKindView kind, + String altText, + boolean altTextProvided, + Boolean decorative, + AssetManagementStatusView managementStatus, + String principal); + + /** + * 바이너리를 실제로 저장한 위치. 계약이 노출하지 않는 값이라 {@code AssetView} 에는 없다. + * + *

    삭제 경로가 이 값을 규칙으로 다시 계산하지 않고 저장된 것을 읽는 이유: 규칙이 두 곳에 있으면 업로드 규칙이 바뀐 순간 옛 Asset 의 바이너리를 못 찾아 + * 조용히 남는다. + */ + Optional findObjectKey(UUID assetId); + + void delete(UUID assetId); + + /** 새 Asset 의 메타데이터. {@code objectKey} 는 바이너리를 실제로 저장한 위치다. */ + record NewAsset( + UUID id, + String assetKey, + AssetKindView kind, + String mediaType, + String objectKey, + String originalFilename, + long byteSize, + Integer width, + Integer height, + String checksumSha256, + String altText, + boolean decorative, + AssetManagementStatusView managementStatus) {} +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/port/out/ContentAnalyzerPort.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/port/out/ContentAnalyzerPort.java new file mode 100644 index 0000000..1c98ded --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/port/out/ContentAnalyzerPort.java @@ -0,0 +1,35 @@ +package dev.caskeleton.application.techlog.studio.port.out; + +import java.util.List; + +/** + * 본문 Markdown을 게시 검증에 필요한 만큼 분석한다(spec §7.5 7~8단계). + * + *

    렌더러와 같은 파서를 쓴다 — 검증이 "이 문서가 참조하는 Asset"을 렌더러와 다르게 세면, 검증을 통과한 문서가 렌더 단계에서 없는 Asset을 참조하게 된다. + */ +@FunctionalInterface +public interface ContentAnalyzerPort { + + ContentAnalysis analyze(String bodyMarkdown); + + /** + * 본문 분석 결과. + * + * @param assetUsages 본문의 각 사용 위치. 같은 key가 여러 번 쓰이면 여러 항목이 된다 — alt는 사용 위치마다 다를 수 있어 Asset 한 행으로 + * 판단할 수 없다(V7 asset 테이블 주석). + * @param unsupportedDirectives 지원하지 않는 directive 이름. 조용히 잘못 해석하지 않고 경고로 드러낸다 (설계 05장 §4). + */ + record ContentAnalysis( + List assetUsages, List unsupportedDirectives, String plainText) { + + public ContentAnalysis { + assetUsages = assetUsages == null ? List.of() : List.copyOf(assetUsages); + unsupportedDirectives = + unsupportedDirectives == null ? List.of() : List.copyOf(unsupportedDirectives); + plainText = plainText == null ? "" : plainText; + } + } + + /** 본문 안의 Asset 사용 한 곳. */ + record AssetUsage(String assetKey, String alt) {} +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/port/out/DependencyRevisionPort.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/port/out/DependencyRevisionPort.java new file mode 100644 index 0000000..7c4cdd4 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/port/out/DependencyRevisionPort.java @@ -0,0 +1,17 @@ +package dev.caskeleton.application.techlog.studio.port.out; + +import dev.caskeleton.application.techlog.studio.model.RecordKind; +import java.util.UUID; + +/** + * 검증 결과에 영향을 주는 외부 의존 상태를 정규화한 값을 계산한다(계약 {@code DependencyRevision}, spec §7.3). + * + *

    전역 카운터가 아니다 — 이 문서가 실제로 의존하는 것들(topic/project, relation target, asset, slug/route, renderer + * contract version)의 identity+version만 모아 해시한다. Publish 시 다시 계산해 값이 다르면 {@code VALIDATION_STALE}로 + * 거절한다. + */ +@FunctionalInterface +public interface DependencyRevisionPort { + + String revisionFor(RecordKind kind, UUID documentId); +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/port/out/PreviewArtifactPort.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/port/out/PreviewArtifactPort.java new file mode 100644 index 0000000..430f350 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/port/out/PreviewArtifactPort.java @@ -0,0 +1,16 @@ +package dev.caskeleton.application.techlog.studio.port.out; + +import dev.caskeleton.application.techlog.studio.model.PublicPreviewView; +import dev.caskeleton.application.techlog.studio.model.RecordKind; +import java.util.Optional; +import java.util.UUID; + +/** {@code studio_preview} 영속 artifact 접근(spec §7.3). */ +public interface PreviewArtifactPort { + + Optional latestFor(RecordKind kind, UUID documentId); + + Optional findById(UUID previewId); + + PublicPreviewView save(RecordKind kind, PublicPreviewView preview, String principal); +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/port/out/PublicationHistoryQueryPort.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/port/out/PublicationHistoryQueryPort.java new file mode 100644 index 0000000..5bd2686 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/port/out/PublicationHistoryQueryPort.java @@ -0,0 +1,18 @@ +package dev.caskeleton.application.techlog.studio.port.out; + +import dev.caskeleton.application.techlog.studio.model.PublicationAggregateView; +import dev.caskeleton.application.techlog.studio.model.PublicationPageView; +import dev.caskeleton.application.techlog.studio.model.PublicationSnapshotView; +import dev.caskeleton.application.techlog.studio.query.ListPublicationsQuery; +import java.util.Optional; +import java.util.UUID; + +/** 게시 이력·Snapshot 조회. */ +public interface PublicationHistoryQueryPort { + + PublicationPageView list(ListPublicationsQuery query); + + Optional findSnapshot(UUID publicationEventId); + + Optional findById(UUID publicationId); +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/port/out/PublicationQueryPort.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/port/out/PublicationQueryPort.java new file mode 100644 index 0000000..5c444dd --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/port/out/PublicationQueryPort.java @@ -0,0 +1,12 @@ +package dev.caskeleton.application.techlog.studio.port.out; + +import dev.caskeleton.application.techlog.studio.model.PublicationAggregateView; +import java.util.Optional; +import java.util.UUID; + +/** 현재 게시 상태 조회. 게시 이력(Event) 조회와 쓰기는 슬라이스 4의 별도 포트가 담당한다. */ +@FunctionalInterface +public interface PublicationQueryPort { + + Optional currentFor(UUID documentId); +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/port/out/PublicationWriterPort.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/port/out/PublicationWriterPort.java new file mode 100644 index 0000000..29c0f6f --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/port/out/PublicationWriterPort.java @@ -0,0 +1,64 @@ +package dev.caskeleton.application.techlog.studio.port.out; + +import dev.caskeleton.application.techlog.studio.model.AssetManifestEntry; +import dev.caskeleton.application.techlog.studio.model.PublicationAggregateView; +import dev.caskeleton.application.techlog.studio.model.PublishResultView; +import dev.caskeleton.application.techlog.studio.model.RecordKind; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +/** + * spec §7.5 의 게시 트랜잭션 10~19단계. 한 트랜잭션 안에서 전부 수행한다. + * + *

    결정(무엇을 게시해도 되는가)은 use case 가 하고, 여기서는 결정된 것을 쓰기만 한다 — 그래야 "어떤 경로로 게시했느냐"에 따라 검사 항목이 달라지는 일이 + * 생기지 않는다. + */ +public interface PublicationWriterPort { + + /** spec §7.5 1단계. 같은 문서에 대한 동시 게시를 직렬화한다. */ + Optional lockCurrentPublication(RecordKind kind, UUID documentId); + + PublishResultView publish(PublishRequest request); + + PublishResultView unpublish(UnpublishRequest request); + + /** + * 게시할 내용 전부. 값 하나하나가 이미 검증을 통과한 상태다. + * + * @param renderModelJson 사용자가 확인한 미리보기의 렌더 모델. 게시 시점에 다시 렌더링하지 않는다 — 다시 렌더링하면 승인한 화면과 공개된 + * 화면이 달라질 수 있다(spec §7.5). + * @param stateCode 유형별 공개 상태({@code QUESTION} 은 OPEN/RESOLVED, {@code PROJECT_DECISION} 은 + * PROPOSED/ADOPTED). 나머지는 null. + */ + record PublishRequest( + RecordKind kind, + UUID documentId, + long version, + String title, + String summary, + UUID topicId, + UUID projectId, + String publicPath, + String stateCode, + String renderModelJson, + String bodyPlainText, + List assetManifest, + String contentFormatVersion, + String rendererContractVersion, + String idempotencyKey, + String principal) { + + public PublishRequest { + assetManifest = assetManifest == null ? List.of() : List.copyOf(assetManifest); + } + } + + /** 게시 취소. Snapshot 은 삭제하지 않는다 — 이력은 지우지 않는다(spec §7.5). */ + record UnpublishRequest( + UUID publicationId, + RecordKind kind, + UUID documentId, + long expectedRevision, + String principal) {} +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/port/out/RenderModelPort.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/port/out/RenderModelPort.java new file mode 100644 index 0000000..6fa9c7c --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/port/out/RenderModelPort.java @@ -0,0 +1,19 @@ +package dev.caskeleton.application.techlog.studio.port.out; + +import dev.caskeleton.application.techlog.studio.model.RenderInput; + +/** + * 편집본을 계약의 {@code PublicRenderModel} JSON으로 만든다. + * + *

    결과를 구조화된 application 타입이 아니라 JSON 문자열로 주고받는 이유: 이 값은 {@code studio_preview.render_model}에 그대로 + * 들어가고 게시 시 그대로 snapshot으로 옮겨진 뒤 그대로 응답으로 나간다. 중간에서 한 번 더 접었다 펴면 사용자가 확인한 화면과 공개된 화면이 달라질 수 있고, 그 + * 차이는 아무도 검증하지 않는다(ADR-005가 막으려는 바로 그 사건이다). + * + *

    구현이 inbound web 모듈에 있는 것은 의도적이다 — 렌더 모델은 계약 DTO이고 그 타입을 소유한 모듈이 거기다. application에 같은 모양을 한 벌 더 + * 두면 두 정의가 갈라진다. + */ +@FunctionalInterface +public interface RenderModelPort { + + String renderToJson(RenderInput input); +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/port/out/StudioDashboardQueryPort.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/port/out/StudioDashboardQueryPort.java new file mode 100644 index 0000000..1245201 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/port/out/StudioDashboardQueryPort.java @@ -0,0 +1,14 @@ +package dev.caskeleton.application.techlog.studio.port.out; + +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 java.util.List; + +/** 대시보드 union query. 목록과 같은 {@code studio_document} 정의를 쓴다. */ +public interface StudioDashboardQueryPort { + + List topByNextAction(List actions, int limit); + + DashboardTotalsView totals(); +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/port/out/StudioDependencyResolverPort.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/port/out/StudioDependencyResolverPort.java new file mode 100644 index 0000000..9e10e2e --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/port/out/StudioDependencyResolverPort.java @@ -0,0 +1,51 @@ +package dev.caskeleton.application.techlog.studio.port.out; + +import dev.caskeleton.application.techlog.studio.model.DisplayTargetView; +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 java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +/** + * 편집본이 의존하는 바깥 상태를 한 번에 해석한다 — topic/project, 관계 대상, Asset, slug 소유권. + * + *

    검증과 렌더링이 같은 해석 결과를 쓴다. 두 곳이 따로 조회하면 그 사이에 상태가 바뀌어 "검증은 통과했는데 렌더는 없는 Asset을 가리킨다" 같은 사건이 + * 생긴다. + */ +@FunctionalInterface +public interface StudioDependencyResolverPort { + + Resolved resolve(WorkingCopyView document, Set referencedAssetKeys); + + /** + * 해석 결과. + * + * @param topic 없으면 null. {@code topicMissing}이 참이면 topicId가 있는데 그 행이 없다는 뜻이다. + * @param assetStatusByKey key → {@code management_status}. 없는 key는 아예 담기지 않는다. + * @param slugOwnerId 같은 유형에서 이 slug를 이미 쓰는 다른 문서. 없으면 null. + */ + record Resolved( + DisplayTargetView topic, + boolean topicMissing, + DisplayTargetView project, + boolean projectMissing, + List relations, + List missingRelationTargets, + Map assetsByKey, + Map assetStatusByKey, + DisplayTargetView resolutionEvidenceTarget, + String publicPath, + UUID slugOwnerId) { + + public Resolved { + relations = relations == null ? List.of() : List.copyOf(relations); + missingRelationTargets = + missingRelationTargets == null ? List.of() : List.copyOf(missingRelationTargets); + assetsByKey = assetsByKey == null ? Map.of() : Map.copyOf(assetsByKey); + assetStatusByKey = assetStatusByKey == null ? Map.of() : Map.copyOf(assetStatusByKey); + } + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/port/out/StudioDocumentQueryPort.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/port/out/StudioDocumentQueryPort.java new file mode 100644 index 0000000..bc8b879 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/port/out/StudioDocumentQueryPort.java @@ -0,0 +1,14 @@ +package dev.caskeleton.application.techlog.studio.port.out; + +import dev.caskeleton.application.techlog.studio.model.DocumentPageView; +import dev.caskeleton.application.techlog.studio.query.ListDocumentsQuery; + +/** + * Studio 문서 목록. 네 유형이 서로 다른 테이블에 있으므로 공통 repository를 만들지 않고 query side에서 union projection을 구성한다(계약 + * {@code listStudioDocuments} 설명, spec §8.3). + */ +@FunctionalInterface +public interface StudioDocumentQueryPort { + + DocumentPageView list(ListDocumentsQuery query); +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/port/out/ValidationArtifactPort.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/port/out/ValidationArtifactPort.java new file mode 100644 index 0000000..d424bc2 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/port/out/ValidationArtifactPort.java @@ -0,0 +1,17 @@ +package dev.caskeleton.application.techlog.studio.port.out; + +import dev.caskeleton.application.techlog.studio.model.RecordKind; +import dev.caskeleton.application.techlog.studio.model.ValidationReportView; +import java.util.Optional; +import java.util.UUID; + +/** {@code studio_validation} 영속 artifact 접근(spec §7.3). */ +public interface ValidationArtifactPort { + + /** 이 문서에 대한 가장 최근 검증 결과. 검증한 적이 없으면 비어 있다. */ + Optional latestFor(RecordKind kind, UUID documentId); + + Optional findById(UUID validationId); + + ValidationReportView save(RecordKind kind, ValidationReportView report, String principal); +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/port/out/WorkingCopyRepositoryPort.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/port/out/WorkingCopyRepositoryPort.java new file mode 100644 index 0000000..c41f841 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/port/out/WorkingCopyRepositoryPort.java @@ -0,0 +1,31 @@ +package dev.caskeleton.application.techlog.studio.port.out; + +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 java.util.Optional; +import java.util.UUID; + +/** + * 네 source aggregate에 흩어진 편집본을 하나의 API projection으로 읽고 쓴다. + * + *

    범용 CRUD repository가 아니다 — 구현이 {@code kind}에 따라 각자의 테이블로 dispatch한다(ADR-003). + */ +public interface WorkingCopyRepositoryPort { + + /** {@code documentId}가 어느 유형인지 해소한다(spec §7.2 StudioDocumentLocator). */ + Optional findKind(UUID documentId); + + Optional find(UUID documentId); + + WorkingCopyView create(WorkingCopyInputView input, String principal); + + /** + * 낙관적 잠금 저장. + * + * @return 저장된 편집본. {@code expectedVersion}이 현재 버전과 다르면 {@link Optional#empty()} — "없음"과 "충돌"을 + * 호출자가 구분할 수 있도록 존재 확인은 {@link #find}가 따로 한다. + */ + Optional save( + UUID documentId, long expectedVersion, WorkingCopyInputView input, String principal); +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/query/DocumentCursorPosition.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/query/DocumentCursorPosition.java new file mode 100644 index 0000000..9de5b7f --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/query/DocumentCursorPosition.java @@ -0,0 +1,14 @@ +package dev.caskeleton.application.techlog.studio.query; + +import java.time.Instant; +import java.util.UUID; + +/** + * 커서 페이지 위치. 정렬 키와 tie-breaker(id)를 함께 들고 다닌다 — {@code updatedAt}만으로는 같은 시각의 행들이 페이지 경계에서 중복되거나 + * 누락된다. + * + * @param updatedAt {@code UPDATED_*} 정렬의 마지막 행 값 + * @param title {@code TITLE_ASC} 정렬의 마지막 행 값 + * @param id 마지막 행의 id + */ +public record DocumentCursorPosition(Instant updatedAt, String title, UUID id) {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/query/GetAssetQuery.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/query/GetAssetQuery.java new file mode 100644 index 0000000..f854a5c --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/query/GetAssetQuery.java @@ -0,0 +1,7 @@ +package dev.caskeleton.application.techlog.studio.query; + +import dev.caskeleton.application.query.Query; +import java.util.UUID; + +/** {@code getStudioAsset}의 입력. */ +public record GetAssetQuery(UUID assetId) implements Query {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/query/GetDashboardQuery.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/query/GetDashboardQuery.java new file mode 100644 index 0000000..1e3a934 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/query/GetDashboardQuery.java @@ -0,0 +1,6 @@ +package dev.caskeleton.application.techlog.studio.query; + +import dev.caskeleton.application.query.Query; + +/** {@code getStudioDashboard}의 입력. 파라미터가 없다. */ +public record GetDashboardQuery() implements Query {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/query/GetDocumentQuery.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/query/GetDocumentQuery.java new file mode 100644 index 0000000..dcaaced --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/query/GetDocumentQuery.java @@ -0,0 +1,7 @@ +package dev.caskeleton.application.techlog.studio.query; + +import dev.caskeleton.application.query.Query; +import java.util.UUID; + +/** {@code getStudioDocument}의 입력. */ +public record GetDocumentQuery(UUID documentId) implements Query {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/query/GetPreviewQuery.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/query/GetPreviewQuery.java new file mode 100644 index 0000000..ede62da --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/query/GetPreviewQuery.java @@ -0,0 +1,7 @@ +package dev.caskeleton.application.techlog.studio.query; + +import dev.caskeleton.application.query.Query; +import java.util.UUID; + +/** {@code getCurrentStudioPreview}의 입력. */ +public record GetPreviewQuery(UUID documentId) implements Query {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/query/GetSnapshotQuery.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/query/GetSnapshotQuery.java new file mode 100644 index 0000000..b545585 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/query/GetSnapshotQuery.java @@ -0,0 +1,7 @@ +package dev.caskeleton.application.techlog.studio.query; + +import dev.caskeleton.application.query.Query; +import java.util.UUID; + +/** {@code getStudioPublicationSnapshot}의 입력. */ +public record GetSnapshotQuery(UUID publicationEventId) implements Query {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/query/ListAssetsQuery.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/query/ListAssetsQuery.java new file mode 100644 index 0000000..d10b747 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/query/ListAssetsQuery.java @@ -0,0 +1,17 @@ +package dev.caskeleton.application.techlog.studio.query; + +import dev.caskeleton.application.query.Query; +import dev.caskeleton.application.techlog.studio.model.AssetKindView; +import dev.caskeleton.application.techlog.studio.model.AssetManagementStatusView; +import java.time.Instant; +import java.util.UUID; + +/** {@code listStudioAssets}의 입력. 정렬은 최신 등록순 고정이라 선택지가 없다. */ +public record ListAssetsQuery( + String query, + AssetKindView kind, + AssetManagementStatusView managementStatus, + Instant beforeCreatedAt, + UUID beforeId, + int limit) + implements Query {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/query/ListDocumentsQuery.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/query/ListDocumentsQuery.java new file mode 100644 index 0000000..bc9d781 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/query/ListDocumentsQuery.java @@ -0,0 +1,25 @@ +package dev.caskeleton.application.techlog.studio.query; + +import dev.caskeleton.application.query.Query; +import dev.caskeleton.application.techlog.studio.model.DocumentSort; +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.util.UUID; + +/** + * {@code listStudioDocuments}의 입력. + * + *

    {@code position}은 web 계층이 opaque cursor를 풀어 넘긴 페이지 위치다 — 서명·만료·필터 결합 검사는 전송 계층(CursorCodec)의 + * 책임이고, 여기서는 이미 검증된 위치만 받는다. + */ +public record ListDocumentsQuery( + String query, + RecordKind kind, + PublicationStatusView publicationStatus, + NextAction nextAction, + UUID projectId, + DocumentSort sort, + DocumentCursorPosition position, + int limit) + implements Query {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/query/ListPublicationsQuery.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/query/ListPublicationsQuery.java new file mode 100644 index 0000000..d76cedc --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/query/ListPublicationsQuery.java @@ -0,0 +1,15 @@ +package dev.caskeleton.application.techlog.studio.query; + +import dev.caskeleton.application.query.Query; +import dev.caskeleton.application.techlog.studio.model.PublicationEventTypeView; +import java.time.Instant; +import java.util.UUID; + +/** + * {@code listStudioPublications}의 입력. + * + * @param beforeOccurredAt 커서 위치. 게시 이력은 최신순 고정이라 정렬 선택지가 없다. + */ +public record ListPublicationsQuery( + PublicationEventTypeView type, Instant beforeOccurredAt, UUID beforeEventId, int limit) + implements Query {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/AssetMediaTypes.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/AssetMediaTypes.java new file mode 100644 index 0000000..4baa0f3 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/AssetMediaTypes.java @@ -0,0 +1,71 @@ +package dev.caskeleton.application.techlog.studio.service; + +import java.util.Map; + +/** + * 업로드 내용으로 media type 을 판정한다. 확장자와 클라이언트가 보낸 {@code Content-Type} 은 신뢰하지 않는다(계약 {@code + * uploadStudioAsset} 설명) — 둘 다 보내는 쪽이 마음대로 정할 수 있는 값이다. + * + *

    파일 시작 바이트(magic number)로만 판정하고, 아는 형식이 아니면 거절한다. 모르는 것을 통과시키면 그 파일이 무엇인지 아무도 모르는 채로 공개된다. + */ +public final class AssetMediaTypes { + + /** 계약 {@code uploadStudioAsset} 의 {@code encoding.file.contentType} 목록. */ + private static final Map SIGNATURES = + Map.of( + "image/png", new byte[] {(byte) 0x89, 'P', 'N', 'G'}, + "image/jpeg", new byte[] {(byte) 0xFF, (byte) 0xD8, (byte) 0xFF}, + "image/gif", new byte[] {'G', 'I', 'F', '8'}, + "application/pdf", new byte[] {'%', 'P', 'D', 'F'}); + + private AssetMediaTypes() {} + + /** + * 내용으로 판정한 media type. + * + * @return 아는 형식이 아니면 null + */ + public static String detect(byte[] content) { + for (Map.Entry signature : SIGNATURES.entrySet()) { + if (startsWith(content, signature.getValue())) { + return signature.getKey(); + } + } + if (isWebp(content)) { + return "image/webp"; + } + if (isSvg(content)) { + return "image/svg+xml"; + } + return null; + } + + private static boolean startsWith(byte[] content, byte[] prefix) { + if (content.length < prefix.length) { + return false; + } + for (int i = 0; i < prefix.length; i++) { + if (content[i] != prefix[i]) { + return false; + } + } + return true; + } + + /** RIFF 컨테이너의 8~11 바이트가 {@code WEBP} 다. */ + private static boolean isWebp(byte[] content) { + return content.length >= 12 + && startsWith(content, new byte[] {'R', 'I', 'F', 'F'}) + && content[8] == 'W' + && content[9] == 'E' + && content[10] == 'B' + && content[11] == 'P'; + } + + /** SVG 는 텍스트라 서명이 없다. 앞부분에서 {@code {@code Idempotency.KEYED} — 계약이 {@code Idempotency-Key}를 필수로 요구하는 mutation이다 (spec §8.2). 실제 + * 재생은 웹 경계에서 {@code IdempotencyExecutor}가 수행한다. + */ +/* + * 이 클래스가 final 이 아닌 이유: @RequiresPermission 은 Spring AOP 로 강제되고, Boot 는 기본적으로 + * CGLIB 프록시(proxy-target-class=true)를 쓴다 — final 클래스는 subclass 할 수 없어 빈 생성이 + * 실패한다("Cannot subclass final class"). 실제 부팅 검증에서 그렇게 실패했다. + * 템플릿의 NotificationDispatchUseCase 는 final 이면서도 문제가 없는데, 그건 그 능력이 꺼진 배포에서 + * 빈으로 등록되지 않아 프록시가 만들어지지 않기 때문이다. Studio 의 use case 는 항상 등록된다. + */ +@RequiresPermission(StudioPermissions.WRITE) +@UseCaseCapability( + transactionMode = TransactionMode.WRITE, + idempotency = Idempotency.KEYED, + repositoryAccess = RepositoryAccess.WRITE_REPOSITORY) +public class CreateStudioDocumentUseCase + implements CommandUseCase { + + private final WorkingCopyRepositoryPort workingCopies; + private final TransactionPort transactions; + + public CreateStudioDocumentUseCase( + WorkingCopyRepositoryPort workingCopies, TransactionPort transactions) { + this.workingCopies = Objects.requireNonNull(workingCopies, "workingCopies"); + this.transactions = Objects.requireNonNull(transactions, "transactions"); + } + + @Override + public WorkingCopyView handle(CreateDocumentCommand input) { + WorkingCopyInputValidator.validate(input.document()); + if (input.principal() == null || input.principal().isBlank()) { + throw StudioException.of( + StudioError.AUTHENTICATION_REQUIRED, "a principal is required to create a working copy"); + } + return transactions.inWrite(() -> workingCopies.create(input.document(), input.principal())); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/CreateStudioPreviewUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/CreateStudioPreviewUseCase.java new file mode 100644 index 0000000..52cd8fe --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/CreateStudioPreviewUseCase.java @@ -0,0 +1,171 @@ +package dev.caskeleton.application.techlog.studio.service; + +import dev.caskeleton.application.capability.Idempotency; +import dev.caskeleton.application.capability.RepositoryAccess; +import dev.caskeleton.application.capability.UseCaseCapability; +import dev.caskeleton.application.security.RequiresPermission; +import dev.caskeleton.application.techlog.error.StudioError; +import dev.caskeleton.application.techlog.error.StudioException; +import dev.caskeleton.application.techlog.studio.command.CreatePreviewCommand; +import dev.caskeleton.application.techlog.studio.model.PublicPreviewView; +import dev.caskeleton.application.techlog.studio.model.RenderInput; +import dev.caskeleton.application.techlog.studio.model.ValidationReportView; +import dev.caskeleton.application.techlog.studio.model.ValidationStatus; +import dev.caskeleton.application.techlog.studio.model.WorkingCopyView; +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.RenderModelPort; +import dev.caskeleton.application.techlog.studio.port.out.StudioDependencyResolverPort; +import dev.caskeleton.application.techlog.studio.port.out.ValidationArtifactPort; +import dev.caskeleton.application.transaction.TransactionMode; +import dev.caskeleton.application.transaction.TransactionPort; +import dev.caskeleton.application.usecase.CommandUseCase; +import java.time.Clock; +import java.time.Duration; +import java.util.LinkedHashSet; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; +import java.util.function.Supplier; + +/** + * {@code createStudioPreview}. 저장된 working version + validationId + dependency revision 을 묶어 {@code + * PublicRenderModel} snapshot 을 만든다(계약 설명). + * + *

    Preview 는 Public 과 같은 renderer 와 Asset resolver 를 쓴다(ADR-005) — 그래야 작성자가 확인한 화면이 공개될 + * 화면과 같다. + */ +/* + * 이 클래스가 final 이 아닌 이유: @RequiresPermission 은 Spring AOP 로 강제되고, Boot 는 기본적으로 + * CGLIB 프록시(proxy-target-class=true)를 쓴다 — final 클래스는 subclass 할 수 없어 빈 생성이 + * 실패한다("Cannot subclass final class"). 실제 부팅 검증에서 그렇게 실패했다. + * 템플릿의 NotificationDispatchUseCase 는 final 이면서도 문제가 없는데, 그건 그 능력이 꺼진 배포에서 + * 빈으로 등록되지 않아 프록시가 만들어지지 않기 때문이다. Studio 의 use case 는 항상 등록된다. + */ +@RequiresPermission(StudioPermissions.WRITE) +@UseCaseCapability( + transactionMode = TransactionMode.WRITE, + idempotency = Idempotency.KEYED, + repositoryAccess = RepositoryAccess.WRITE_REPOSITORY) +public class CreateStudioPreviewUseCase + implements CommandUseCase { + + private final StudioDocumentLoader documents; + private final StudioDependencyResolverPort dependencies; + private final ContentAnalyzerPort contentAnalyzer; + private final DependencyRevisionPort dependencyRevisions; + private final ValidationArtifactPort validations; + private final PreviewArtifactPort previews; + private final RenderModelPort renderer; + private final TransactionPort transactions; + private final Supplier idGenerator; + private final Clock clock; + private final Duration previewTtl; + + public CreateStudioPreviewUseCase( + StudioDocumentLoader documents, + StudioDependencyResolverPort dependencies, + ContentAnalyzerPort contentAnalyzer, + DependencyRevisionPort dependencyRevisions, + ValidationArtifactPort validations, + PreviewArtifactPort previews, + RenderModelPort renderer, + TransactionPort transactions, + Supplier idGenerator, + Clock clock, + Duration previewTtl) { + this.documents = Objects.requireNonNull(documents, "documents"); + this.dependencies = Objects.requireNonNull(dependencies, "dependencies"); + this.contentAnalyzer = Objects.requireNonNull(contentAnalyzer, "contentAnalyzer"); + this.dependencyRevisions = Objects.requireNonNull(dependencyRevisions, "dependencyRevisions"); + this.validations = Objects.requireNonNull(validations, "validations"); + this.previews = Objects.requireNonNull(previews, "previews"); + this.renderer = Objects.requireNonNull(renderer, "renderer"); + this.transactions = Objects.requireNonNull(transactions, "transactions"); + this.idGenerator = Objects.requireNonNull(idGenerator, "idGenerator"); + this.clock = Objects.requireNonNull(clock, "clock"); + this.previewTtl = Objects.requireNonNull(previewTtl, "previewTtl"); + } + + @Override + public PublicPreviewView handle(CreatePreviewCommand input) { + return transactions.inWrite( + () -> { + WorkingCopyView document = + documents.requireAtVersion(input.documentId(), input.expectedVersion()); + String currentRevision = dependencyRevisions.revisionFor(document.kind(), document.id()); + ValidationReportView validation = + requireUsableValidation(input, document, currentRevision); + + ContentAnalyzerPort.ContentAnalysis content = + contentAnalyzer.analyze(ValidateStudioDocumentUseCase.bodyMarkdownOf(document)); + Set assetKeys = new LinkedHashSet<>(); + content.assetUsages().forEach(usage -> assetKeys.add(usage.assetKey())); + StudioDependencyResolverPort.Resolved resolved = + dependencies.resolve(document, assetKeys); + + var now = clock.instant(); + String renderModelJson = + renderer.renderToJson( + new RenderInput( + document, + resolved.publicPath(), + resolved.topic(), + resolved.project(), + resolved.relations(), + resolved.resolutionEvidenceTarget(), + resolved.assetsByKey(), + currentRevision, + now)); + + PublicPreviewView preview = + new PublicPreviewView( + idGenerator.get(), + document.id(), + document.version(), + validation.validationId(), + currentRevision, + now, + now.plus(previewTtl), + renderModelJson); + return previews.save(document.kind(), preview, input.principal()); + }); + } + + /** + * spec §7.3 의 "Validation 유효" 조건. {@code VALIDATION_FAILED}(지금 검증하면 실패)와 {@code + * VALIDATION_STALE}(통과했으나 전제가 바뀜)은 다른 사건이며 코드도 다르다. + */ + private ValidationReportView requireUsableValidation( + CreatePreviewCommand input, WorkingCopyView document, String currentRevision) { + + ValidationReportView validation = + validations + .findById(input.validationId()) + .orElseThrow( + () -> + StudioException.of( + StudioError.VALIDATION_STALE, + "no validation " + input.validationId() + " to build a preview from")); + + if (!validation.documentId().equals(document.id())) { + throw StudioException.of( + StudioError.VALIDATION_STALE, + "validation " + input.validationId() + " belongs to a different document"); + } + if (validation.validatedVersion() != document.version() + || !currentRevision.equals(validation.dependencyRevision()) + || !clock.instant().isBefore(validation.validUntil())) { + throw StudioException.of( + StudioError.VALIDATION_STALE, + "the validation no longer describes the current document or its dependencies"); + } + if (validation.status() == ValidationStatus.INVALID) { + throw StudioException.of( + StudioError.DOCUMENT_VALIDATION_FAILED, + "a preview cannot be built from a document that failed validation"); + } + return validation; + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/DeleteStudioAssetUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/DeleteStudioAssetUseCase.java new file mode 100644 index 0000000..fbd9e81 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/DeleteStudioAssetUseCase.java @@ -0,0 +1,78 @@ +package dev.caskeleton.application.techlog.studio.service; + +import dev.caskeleton.application.capability.Idempotency; +import dev.caskeleton.application.capability.RepositoryAccess; +import dev.caskeleton.application.capability.UseCaseCapability; +import dev.caskeleton.application.security.RequiresPermission; +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.model.AssetDetailView; +import dev.caskeleton.application.techlog.studio.port.out.AssetBinaryStoragePort; +import dev.caskeleton.application.techlog.studio.port.out.AssetRepositoryPort; +import dev.caskeleton.application.transaction.TransactionMode; +import dev.caskeleton.application.transaction.TransactionPort; +import dev.caskeleton.application.usecase.CommandUseCase; +import java.util.Objects; + +/** + * {@code deleteStudioAsset}. 공개 이력이 있거나 사용 중인 Asset 은 hard delete 하지 않는다 — 지우면 이미 공개된 문서의 그림이 사라진다. + * 두 경우 모두 {@code ASSET_IN_USE} 로 거절하고 {@code ARCHIVED} 전환을 쓰게 한다(계약 설명). + */ +/* + * 이 클래스가 final 이 아닌 이유: @RequiresPermission 은 Spring AOP 로 강제되고, Boot 는 기본적으로 + * CGLIB 프록시(proxy-target-class=true)를 쓴다 — final 클래스는 subclass 할 수 없어 빈 생성이 + * 실패한다("Cannot subclass final class"). 실제 부팅 검증에서 그렇게 실패했다. + * 템플릿의 NotificationDispatchUseCase 는 final 이면서도 문제가 없는데, 그건 그 능력이 꺼진 배포에서 + * 빈으로 등록되지 않아 프록시가 만들어지지 않기 때문이다. Studio 의 use case 는 항상 등록된다. + */ +@RequiresPermission(StudioPermissions.WRITE) +@UseCaseCapability( + transactionMode = TransactionMode.WRITE, + idempotency = Idempotency.KEYED, + repositoryAccess = RepositoryAccess.WRITE_REPOSITORY) +public class DeleteStudioAssetUseCase implements CommandUseCase { + + private final AssetRepositoryPort assets; + private final AssetBinaryStoragePort binaries; + private final TransactionPort transactions; + + public DeleteStudioAssetUseCase( + AssetRepositoryPort assets, AssetBinaryStoragePort binaries, TransactionPort transactions) { + this.assets = Objects.requireNonNull(assets, "assets"); + this.binaries = Objects.requireNonNull(binaries, "binaries"); + this.transactions = Objects.requireNonNull(transactions, "transactions"); + } + + @Override + public Void handle(DeleteAssetCommand input) { + return transactions.inWrite( + () -> { + AssetDetailView detail = + assets + .findDetail(input.assetId()) + .orElseThrow( + () -> + StudioException.of( + StudioError.ASSET_NOT_FOUND, "no asset " + input.assetId())); + if (detail.hasPublicationHistory()) { + throw StudioException.of( + StudioError.ASSET_IN_USE, + "this asset has been published; archive it instead of deleting it"); + } + if (!detail.usages().isEmpty()) { + throw StudioException.of( + StudioError.ASSET_IN_USE, + "this asset is used by " + detail.usages().size() + " record(s)"); + } + String objectKey = assets.findObjectKey(input.assetId()).orElse(null); + assets.delete(input.assetId()); + // 메타데이터를 지운 뒤에 바이너리를 지운다 — 순서를 뒤집으면 롤백 시 메타데이터는 남고 + // 바이너리만 사라져 그림이 깨진 Asset 행이 생긴다. + if (objectKey != null) { + binaries.delete(objectKey); + } + return null; + }); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/GetCurrentStudioPreviewUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/GetCurrentStudioPreviewUseCase.java new file mode 100644 index 0000000..e93120f --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/GetCurrentStudioPreviewUseCase.java @@ -0,0 +1,93 @@ +package dev.caskeleton.application.techlog.studio.service; + +import dev.caskeleton.application.capability.Idempotency; +import dev.caskeleton.application.capability.RepositoryAccess; +import dev.caskeleton.application.capability.UseCaseCapability; +import dev.caskeleton.application.techlog.error.StudioError; +import dev.caskeleton.application.techlog.error.StudioException; +import dev.caskeleton.application.techlog.studio.model.PreviewDetailView; +import dev.caskeleton.application.techlog.studio.model.PreviewState; +import dev.caskeleton.application.techlog.studio.model.PublicPreviewView; +import dev.caskeleton.application.techlog.studio.model.WorkingCopyView; +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.ValidationArtifactPort; +import dev.caskeleton.application.techlog.studio.query.GetPreviewQuery; +import dev.caskeleton.application.transaction.TransactionMode; +import dev.caskeleton.application.transaction.TransactionPort; +import dev.caskeleton.application.usecase.QueryUseCase; +import java.time.Clock; +import java.util.Objects; + +/** + * {@code getCurrentStudioPreview}. Preview 상태({@code CURRENT}/{@code STALE}/{@code EXPIRED})는 서버가 + * 계산한다(계약 설명, spec §7.3) — 프론트가 여러 값을 조합해 재추론하지 않는다. + */ +@UseCaseCapability( + transactionMode = TransactionMode.READ_ONLY, + idempotency = Idempotency.IDEMPOTENT, + repositoryAccess = RepositoryAccess.READ_REPOSITORY) +public final class GetCurrentStudioPreviewUseCase + implements QueryUseCase { + + private final StudioDocumentLoader documents; + private final PreviewArtifactPort previews; + private final ValidationArtifactPort validations; + private final DependencyRevisionPort dependencyRevisions; + private final TransactionPort transactions; + private final Clock clock; + + public GetCurrentStudioPreviewUseCase( + StudioDocumentLoader documents, + PreviewArtifactPort previews, + ValidationArtifactPort validations, + DependencyRevisionPort dependencyRevisions, + TransactionPort transactions, + Clock clock) { + this.documents = Objects.requireNonNull(documents, "documents"); + this.previews = Objects.requireNonNull(previews, "previews"); + this.validations = Objects.requireNonNull(validations, "validations"); + this.dependencyRevisions = Objects.requireNonNull(dependencyRevisions, "dependencyRevisions"); + this.transactions = Objects.requireNonNull(transactions, "transactions"); + this.clock = Objects.requireNonNull(clock, "clock"); + } + + @Override + public PreviewDetailView handle(GetPreviewQuery input) { + return transactions.inRead( + () -> { + WorkingCopyView document = documents.require(input.documentId()); + PublicPreviewView preview = + previews + .latestFor(document.kind(), document.id()) + .orElseThrow( + () -> + StudioException.of( + StudioError.PREVIEW_NOT_FOUND, + "no preview has been created for " + document.id())); + + String currentRevision = dependencyRevisions.revisionFor(document.kind(), document.id()); + return new PreviewDetailView( + preview, + stateOf(preview, document, currentRevision), + document.version(), + validations + .latestFor(document.kind(), document.id()) + .map(report -> report.validationId()) + .orElse(null)); + }); + } + + /** 만료가 먼저다 — 만료된 미리보기는 내용이 최신이어도 다시 만들어야 하므로 {@code STALE} 보다 강하다. */ + private PreviewState stateOf( + PublicPreviewView preview, WorkingCopyView document, String currentRevision) { + if (!clock.instant().isBefore(preview.expiresAt())) { + return PreviewState.EXPIRED; + } + if (preview.previewVersion() != document.version() + || !Objects.equals(preview.dependencyRevision(), currentRevision)) { + return PreviewState.STALE; + } + return PreviewState.CURRENT; + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/GetStudioAssetUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/GetStudioAssetUseCase.java new file mode 100644 index 0000000..76e1d73 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/GetStudioAssetUseCase.java @@ -0,0 +1,42 @@ +package dev.caskeleton.application.techlog.studio.service; + +import dev.caskeleton.application.capability.Idempotency; +import dev.caskeleton.application.capability.RepositoryAccess; +import dev.caskeleton.application.capability.UseCaseCapability; +import dev.caskeleton.application.techlog.error.StudioError; +import dev.caskeleton.application.techlog.error.StudioException; +import dev.caskeleton.application.techlog.studio.model.AssetDetailView; +import dev.caskeleton.application.techlog.studio.port.out.AssetRepositoryPort; +import dev.caskeleton.application.techlog.studio.query.GetAssetQuery; +import dev.caskeleton.application.transaction.TransactionMode; +import dev.caskeleton.application.transaction.TransactionPort; +import dev.caskeleton.application.usecase.QueryUseCase; +import java.util.Objects; + +/** {@code getStudioAsset}. */ +@UseCaseCapability( + transactionMode = TransactionMode.READ_ONLY, + idempotency = Idempotency.IDEMPOTENT, + repositoryAccess = RepositoryAccess.READ_REPOSITORY) +public final class GetStudioAssetUseCase implements QueryUseCase { + + private final AssetRepositoryPort assets; + private final TransactionPort transactions; + + public GetStudioAssetUseCase(AssetRepositoryPort assets, TransactionPort transactions) { + this.assets = Objects.requireNonNull(assets, "assets"); + this.transactions = Objects.requireNonNull(transactions, "transactions"); + } + + @Override + public AssetDetailView handle(GetAssetQuery input) { + return transactions.inRead( + () -> + assets + .findDetail(input.assetId()) + .orElseThrow( + () -> + StudioException.of( + StudioError.ASSET_NOT_FOUND, "no asset " + input.assetId()))); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/GetStudioDashboardUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/GetStudioDashboardUseCase.java new file mode 100644 index 0000000..93eb9de --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/GetStudioDashboardUseCase.java @@ -0,0 +1,61 @@ +package dev.caskeleton.application.techlog.studio.service; + +import dev.caskeleton.application.capability.Idempotency; +import dev.caskeleton.application.capability.RepositoryAccess; +import dev.caskeleton.application.capability.UseCaseCapability; +import dev.caskeleton.application.techlog.studio.model.DashboardView; +import dev.caskeleton.application.techlog.studio.model.NextAction; +import dev.caskeleton.application.techlog.studio.port.out.PublicationHistoryQueryPort; +import dev.caskeleton.application.techlog.studio.port.out.StudioDashboardQueryPort; +import dev.caskeleton.application.techlog.studio.query.GetDashboardQuery; +import dev.caskeleton.application.techlog.studio.query.ListPublicationsQuery; +import dev.caskeleton.application.transaction.TransactionMode; +import dev.caskeleton.application.transaction.TransactionPort; +import dev.caskeleton.application.usecase.QueryUseCase; +import java.util.List; +import java.util.Objects; + +/** + * {@code getStudioDashboard}. {@code nextAction} 을 포함한 모든 workflow 상태는 서버가 계산한다 — 프론트가 여러 endpoint + * 를 조합해 재추론하지 않는다(계약 설명). + */ +@UseCaseCapability( + transactionMode = TransactionMode.READ_ONLY, + idempotency = Idempotency.IDEMPOTENT, + repositoryAccess = RepositoryAccess.READ_REPOSITORY) +public final class GetStudioDashboardUseCase + implements QueryUseCase { + + /** 계약 {@code StudioDashboard} 의 각 목록 maxItems. */ + private static final int SECTION_SIZE = 5; + + /** "이어서 쓰기"에 해당하는 상태들. 게시까지 갈 수 없는 것부터 보여준다. */ + private static final List CONTINUE_WRITING = + List.of(NextAction.CONTINUE_EDITING, NextAction.FIX_VALIDATION, NextAction.VALIDATE); + + private static final List READY_TO_PUBLISH = List.of(NextAction.PUBLISH); + + private final StudioDashboardQueryPort dashboard; + private final PublicationHistoryQueryPort history; + private final TransactionPort transactions; + + public GetStudioDashboardUseCase( + StudioDashboardQueryPort dashboard, + PublicationHistoryQueryPort history, + TransactionPort transactions) { + this.dashboard = Objects.requireNonNull(dashboard, "dashboard"); + this.history = Objects.requireNonNull(history, "history"); + this.transactions = Objects.requireNonNull(transactions, "transactions"); + } + + @Override + public DashboardView handle(GetDashboardQuery input) { + return transactions.inRead( + () -> + new DashboardView( + dashboard.topByNextAction(CONTINUE_WRITING, SECTION_SIZE), + dashboard.topByNextAction(READY_TO_PUBLISH, SECTION_SIZE), + history.list(new ListPublicationsQuery(null, null, null, SECTION_SIZE)).items(), + dashboard.totals())); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/GetStudioDocumentUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/GetStudioDocumentUseCase.java new file mode 100644 index 0000000..cfcea80 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/GetStudioDocumentUseCase.java @@ -0,0 +1,55 @@ +package dev.caskeleton.application.techlog.studio.service; + +import dev.caskeleton.application.capability.Idempotency; +import dev.caskeleton.application.capability.RepositoryAccess; +import dev.caskeleton.application.capability.UseCaseCapability; +import dev.caskeleton.application.techlog.error.StudioError; +import dev.caskeleton.application.techlog.error.StudioException; +import dev.caskeleton.application.techlog.studio.model.WorkingCopyDetailView; +import dev.caskeleton.application.techlog.studio.port.out.WorkingCopyRepositoryPort; +import dev.caskeleton.application.techlog.studio.query.GetDocumentQuery; +import dev.caskeleton.application.transaction.TransactionMode; +import dev.caskeleton.application.transaction.TransactionPort; +import dev.caskeleton.application.usecase.QueryUseCase; +import java.util.Objects; + +/** {@code getStudioDocument}. */ +@UseCaseCapability( + transactionMode = TransactionMode.READ_ONLY, + idempotency = Idempotency.IDEMPOTENT, + repositoryAccess = RepositoryAccess.READ_REPOSITORY) +public final class GetStudioDocumentUseCase + implements QueryUseCase { + + private final WorkingCopyRepositoryPort workingCopies; + private final WorkingCopyDetailAssembler assembler; + private final TransactionPort transactions; + + public GetStudioDocumentUseCase( + WorkingCopyRepositoryPort workingCopies, + WorkingCopyDetailAssembler assembler, + TransactionPort transactions) { + this.workingCopies = Objects.requireNonNull(workingCopies, "workingCopies"); + this.assembler = Objects.requireNonNull(assembler, "assembler"); + this.transactions = Objects.requireNonNull(transactions, "transactions"); + } + + @Override + public WorkingCopyDetailView handle(GetDocumentQuery input) { + if (input.documentId() == null) { + throw StudioException.of(StudioError.REQUEST_VALIDATION_FAILED, "documentId is required"); + } + // 조회 전체를 한 트랜잭션으로 묶는다 — 편집본과 그 validation/preview/publication을 따로 읽으면 + // 그 사이에 저장이 끼어들어 서로 다른 버전을 가리키는 detail이 나갈 수 있다. + return transactions.inRead( + () -> + workingCopies + .find(input.documentId()) + .map(assembler::assemble) + .orElseThrow( + () -> + StudioException.of( + StudioError.DOCUMENT_NOT_FOUND, + "no working copy for documentId " + input.documentId()))); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/GetStudioPublicationSnapshotUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/GetStudioPublicationSnapshotUseCase.java new file mode 100644 index 0000000..8e26722 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/GetStudioPublicationSnapshotUseCase.java @@ -0,0 +1,45 @@ +package dev.caskeleton.application.techlog.studio.service; + +import dev.caskeleton.application.capability.Idempotency; +import dev.caskeleton.application.capability.RepositoryAccess; +import dev.caskeleton.application.capability.UseCaseCapability; +import dev.caskeleton.application.techlog.error.StudioError; +import dev.caskeleton.application.techlog.error.StudioException; +import dev.caskeleton.application.techlog.studio.model.PublicationSnapshotView; +import dev.caskeleton.application.techlog.studio.port.out.PublicationHistoryQueryPort; +import dev.caskeleton.application.techlog.studio.query.GetSnapshotQuery; +import dev.caskeleton.application.transaction.TransactionMode; +import dev.caskeleton.application.transaction.TransactionPort; +import dev.caskeleton.application.usecase.QueryUseCase; +import java.util.Objects; + +/** {@code getStudioPublicationSnapshot}. 현재 source 에서 다시 만들지 않고 게시 시점에 고정된 것을 그대로 돌려준다(ADR-002). */ +@UseCaseCapability( + transactionMode = TransactionMode.READ_ONLY, + idempotency = Idempotency.IDEMPOTENT, + repositoryAccess = RepositoryAccess.READ_REPOSITORY) +public final class GetStudioPublicationSnapshotUseCase + implements QueryUseCase { + + private final PublicationHistoryQueryPort history; + private final TransactionPort transactions; + + public GetStudioPublicationSnapshotUseCase( + PublicationHistoryQueryPort history, TransactionPort transactions) { + this.history = Objects.requireNonNull(history, "history"); + this.transactions = Objects.requireNonNull(transactions, "transactions"); + } + + @Override + public PublicationSnapshotView handle(GetSnapshotQuery input) { + return transactions.inRead( + () -> + history + .findSnapshot(input.publicationEventId()) + .orElseThrow( + () -> + StudioException.of( + StudioError.PUBLICATION_SNAPSHOT_NOT_FOUND, + "no snapshot for publication event " + input.publicationEventId()))); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/ListStudioAssetsUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/ListStudioAssetsUseCase.java new file mode 100644 index 0000000..a864f66 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/ListStudioAssetsUseCase.java @@ -0,0 +1,47 @@ +package dev.caskeleton.application.techlog.studio.service; + +import dev.caskeleton.application.capability.Idempotency; +import dev.caskeleton.application.capability.RepositoryAccess; +import dev.caskeleton.application.capability.UseCaseCapability; +import dev.caskeleton.application.techlog.error.StudioError; +import dev.caskeleton.application.techlog.error.StudioException; +import dev.caskeleton.application.techlog.studio.model.AssetPageView; +import dev.caskeleton.application.techlog.studio.port.out.AssetRepositoryPort; +import dev.caskeleton.application.techlog.studio.query.ListAssetsQuery; +import dev.caskeleton.application.transaction.TransactionMode; +import dev.caskeleton.application.transaction.TransactionPort; +import dev.caskeleton.application.usecase.QueryUseCase; +import java.util.Objects; + +/** {@code listStudioAssets}. */ +@UseCaseCapability( + transactionMode = TransactionMode.READ_ONLY, + idempotency = Idempotency.IDEMPOTENT, + repositoryAccess = RepositoryAccess.READ_REPOSITORY) +public final class ListStudioAssetsUseCase implements QueryUseCase { + + private static final int MAX_LIMIT = 100; + private static final int MAX_QUERY_LENGTH = 100; + + private final AssetRepositoryPort assets; + private final TransactionPort transactions; + + public ListStudioAssetsUseCase(AssetRepositoryPort assets, TransactionPort transactions) { + this.assets = Objects.requireNonNull(assets, "assets"); + this.transactions = Objects.requireNonNull(transactions, "transactions"); + } + + @Override + public AssetPageView handle(ListAssetsQuery input) { + if (input.limit() < 1 || input.limit() > MAX_LIMIT) { + throw StudioException.of( + StudioError.REQUEST_VALIDATION_FAILED, "limit must be between 1 and " + MAX_LIMIT); + } + if (input.query() != null && input.query().length() > MAX_QUERY_LENGTH) { + throw StudioException.of( + StudioError.REQUEST_VALIDATION_FAILED, + "q must be at most " + MAX_QUERY_LENGTH + " characters"); + } + return transactions.inRead(() -> assets.list(input)); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/ListStudioDocumentsUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/ListStudioDocumentsUseCase.java new file mode 100644 index 0000000..66b2e25 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/ListStudioDocumentsUseCase.java @@ -0,0 +1,55 @@ +package dev.caskeleton.application.techlog.studio.service; + +import dev.caskeleton.application.capability.Idempotency; +import dev.caskeleton.application.capability.RepositoryAccess; +import dev.caskeleton.application.capability.UseCaseCapability; +import dev.caskeleton.application.techlog.error.StudioError; +import dev.caskeleton.application.techlog.error.StudioException; +import dev.caskeleton.application.techlog.studio.model.DocumentPageView; +import dev.caskeleton.application.techlog.studio.port.out.StudioDocumentQueryPort; +import dev.caskeleton.application.techlog.studio.query.ListDocumentsQuery; +import dev.caskeleton.application.transaction.TransactionMode; +import dev.caskeleton.application.transaction.TransactionPort; +import dev.caskeleton.application.usecase.QueryUseCase; +import java.util.Objects; + +/** {@code listStudioDocuments}. */ +@UseCaseCapability( + transactionMode = TransactionMode.READ_ONLY, + idempotency = Idempotency.IDEMPOTENT, + repositoryAccess = RepositoryAccess.READ_REPOSITORY) +public final class ListStudioDocumentsUseCase + implements QueryUseCase { + + /** 계약 {@code components.parameters.Limit}. */ + private static final int MAX_LIMIT = 100; + + /** 계약 {@code components.parameters.Query.schema.maxLength}. */ + private static final int MAX_QUERY_LENGTH = 100; + + private final StudioDocumentQueryPort documents; + private final TransactionPort transactions; + + public ListStudioDocumentsUseCase( + StudioDocumentQueryPort documents, TransactionPort transactions) { + this.documents = Objects.requireNonNull(documents, "documents"); + this.transactions = Objects.requireNonNull(transactions, "transactions"); + } + + @Override + public DocumentPageView handle(ListDocumentsQuery input) { + if (input.limit() < 1 || input.limit() > MAX_LIMIT) { + throw StudioException.of( + StudioError.REQUEST_VALIDATION_FAILED, "limit must be between 1 and " + MAX_LIMIT); + } + if (input.query() != null && input.query().length() > MAX_QUERY_LENGTH) { + throw StudioException.of( + StudioError.REQUEST_VALIDATION_FAILED, + "q must be at most " + MAX_QUERY_LENGTH + " characters"); + } + if (input.sort() == null) { + throw StudioException.of(StudioError.REQUEST_VALIDATION_FAILED, "sort is required"); + } + return transactions.inRead(() -> documents.list(input)); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/ListStudioPublicationsUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/ListStudioPublicationsUseCase.java new file mode 100644 index 0000000..eb7fbbb --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/ListStudioPublicationsUseCase.java @@ -0,0 +1,43 @@ +package dev.caskeleton.application.techlog.studio.service; + +import dev.caskeleton.application.capability.Idempotency; +import dev.caskeleton.application.capability.RepositoryAccess; +import dev.caskeleton.application.capability.UseCaseCapability; +import dev.caskeleton.application.techlog.error.StudioError; +import dev.caskeleton.application.techlog.error.StudioException; +import dev.caskeleton.application.techlog.studio.model.PublicationPageView; +import dev.caskeleton.application.techlog.studio.port.out.PublicationHistoryQueryPort; +import dev.caskeleton.application.techlog.studio.query.ListPublicationsQuery; +import dev.caskeleton.application.transaction.TransactionMode; +import dev.caskeleton.application.transaction.TransactionPort; +import dev.caskeleton.application.usecase.QueryUseCase; +import java.util.Objects; + +/** {@code listStudioPublications}. */ +@UseCaseCapability( + transactionMode = TransactionMode.READ_ONLY, + idempotency = Idempotency.IDEMPOTENT, + repositoryAccess = RepositoryAccess.READ_REPOSITORY) +public final class ListStudioPublicationsUseCase + implements QueryUseCase { + + private static final int MAX_LIMIT = 100; + + private final PublicationHistoryQueryPort history; + private final TransactionPort transactions; + + public ListStudioPublicationsUseCase( + PublicationHistoryQueryPort history, TransactionPort transactions) { + this.history = Objects.requireNonNull(history, "history"); + this.transactions = Objects.requireNonNull(transactions, "transactions"); + } + + @Override + public PublicationPageView handle(ListPublicationsQuery input) { + if (input.limit() < 1 || input.limit() > MAX_LIMIT) { + throw StudioException.of( + StudioError.REQUEST_VALIDATION_FAILED, "limit must be between 1 and " + MAX_LIMIT); + } + return transactions.inRead(() -> history.list(input)); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/NextActionCalculator.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/NextActionCalculator.java new file mode 100644 index 0000000..881931f --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/NextActionCalculator.java @@ -0,0 +1,88 @@ +package dev.caskeleton.application.techlog.studio.service; + +import dev.caskeleton.application.techlog.studio.model.NextAction; +import dev.caskeleton.application.techlog.studio.model.PublicPreviewView; +import dev.caskeleton.application.techlog.studio.model.PublicationAggregateStatus; +import dev.caskeleton.application.techlog.studio.model.PublicationAggregateView; +import dev.caskeleton.application.techlog.studio.model.ValidationReportView; +import dev.caskeleton.application.techlog.studio.model.ValidationStatus; +import dev.caskeleton.application.techlog.studio.model.WorkingCopyView; +import java.time.Instant; +import java.util.Objects; + +/** + * spec §7.4. 서버가 조회 시점에 계산하는 Studio projection이며 어떤 domain 컬럼에도 저장하지 않는다. + * + *

    프론트가 여러 endpoint를 조합해 workflow 상태를 재추론하지 않도록 서버가 단일 값으로 답한다(계약 {@code getStudioDashboard} 설명). + */ +public final class NextActionCalculator { + + private NextActionCalculator() {} + + /** + * spec §7.4의 판정 순서를 그대로 따른다. 앞선 조건이 참이면 뒤는 보지 않는다. + * + * @param currentDependencyRevision 지금 계산한 값. artifact에 박제된 값과 다르면 그 artifact는 전제가 바뀐 것이라 더는 유효하지 + * 않다. + */ + public static NextAction calculate( + WorkingCopyView document, + ValidationReportView validation, + PublicPreviewView preview, + PublicationAggregateView publication, + String currentDependencyRevision, + Instant now) { + + if (!isWorthValidating(document)) { + return NextAction.CONTINUE_EDITING; + } + if (!isValidationCurrent(validation, document, currentDependencyRevision, now)) { + return NextAction.VALIDATE; + } + if (validation.status() == ValidationStatus.INVALID) { + return NextAction.FIX_VALIDATION; + } + if (!isPreviewCurrent(preview, document, currentDependencyRevision, now)) { + return NextAction.CREATE_PREVIEW; + } + if (publication == null + || publication.status() != PublicationAggregateStatus.PUBLISHED + || publication.publishedVersion() != document.version()) { + return NextAction.PUBLISH; + } + return NextAction.NONE; + } + + /** + * spec의 "저장 가능한 형태조차 미달". 제목이 비어 있으면 어떤 유형이든 검증을 돌릴 의미가 없다 — 나머지 필수값 판정은 검증 자신의 일이고, 여기서 흉내 내면 두 + * 곳이 서로 다른 답을 낼 수 있다. + */ + private static boolean isWorthValidating(WorkingCopyView document) { + String title = document.base().title(); + return title != null && !title.isBlank(); + } + + /** spec §7.3의 "Validation 유효" 조건 세 가지를 모두 만족해야 한다. */ + private static boolean isValidationCurrent( + ValidationReportView validation, + WorkingCopyView document, + String currentDependencyRevision, + Instant now) { + return validation != null + && validation.validatedVersion() == document.version() + && Objects.equals(validation.dependencyRevision(), currentDependencyRevision) + && now.isBefore(validation.validUntil()); + } + + /** spec §7.3의 Preview {@code CURRENT} 조건. {@code STALE}/{@code EXPIRED}는 모두 재생성 대상이다. */ + private static boolean isPreviewCurrent( + PublicPreviewView preview, + WorkingCopyView document, + String currentDependencyRevision, + Instant now) { + return preview != null + && preview.previewVersion() == document.version() + && Objects.equals(preview.dependencyRevision(), currentDependencyRevision) + && now.isBefore(preview.expiresAt()); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/PublishStudioDocumentUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/PublishStudioDocumentUseCase.java new file mode 100644 index 0000000..5ac0891 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/PublishStudioDocumentUseCase.java @@ -0,0 +1,249 @@ +package dev.caskeleton.application.techlog.studio.service; + +import dev.caskeleton.application.capability.Idempotency; +import dev.caskeleton.application.capability.RepositoryAccess; +import dev.caskeleton.application.capability.UseCaseCapability; +import dev.caskeleton.application.security.RequiresPermission; +import dev.caskeleton.application.techlog.error.StudioError; +import dev.caskeleton.application.techlog.error.StudioException; +import dev.caskeleton.application.techlog.studio.command.PublishDocumentCommand; +import dev.caskeleton.application.techlog.studio.model.AssetManifestEntry; +import dev.caskeleton.application.techlog.studio.model.DecisionStatusView; +import dev.caskeleton.application.techlog.studio.model.PublicPreviewView; +import dev.caskeleton.application.techlog.studio.model.PublishResultView; +import dev.caskeleton.application.techlog.studio.model.QuestionStatusView; +import dev.caskeleton.application.techlog.studio.model.ResolvedAssetView; +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.model.WorkingCopyView; +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.PublicationWriterPort; +import dev.caskeleton.application.techlog.studio.port.out.StudioDependencyResolverPort; +import dev.caskeleton.application.techlog.studio.port.out.ValidationArtifactPort; +import dev.caskeleton.application.techlog.studio.validation.StudioDocumentValidator; +import dev.caskeleton.application.transaction.TransactionMode; +import dev.caskeleton.application.transaction.TransactionPort; +import dev.caskeleton.application.usecase.CommandUseCase; +import java.time.Clock; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Objects; +import java.util.Set; + +/** + * {@code publishStudioDocument} — spec §7.5 의 게시 트랜잭션. + * + *

    단계별 실패가 서로 다른 계약 코드로 나가는 것이 이 use case 의 핵심이다. {@code DOCUMENT_VALIDATION_FAILED}(지금 검증하면 실패)와 + * {@code VALIDATION_STALE}(통과했으나 전제가 바뀜)은 다른 사건이고, 작성자가 해야 할 일도 다르다 — 전자는 고치는 것이고 후자는 다시 검증하는 것이다. + * + *

    Snapshot 의 렌더 모델은 게시 시점에 다시 렌더링하지 않고 사용자가 확인한 미리보기의 것을 그대로 쓴다(spec §7.5). 다시 렌더링하면 승인한 + * 화면과 공개된 화면이 달라질 수 있다. + */ +/* + * 이 클래스가 final 이 아닌 이유: @RequiresPermission 은 Spring AOP 로 강제되고, Boot 는 기본적으로 + * CGLIB 프록시(proxy-target-class=true)를 쓴다 — final 클래스는 subclass 할 수 없어 빈 생성이 + * 실패한다("Cannot subclass final class"). 실제 부팅 검증에서 그렇게 실패했다. + * 템플릿의 NotificationDispatchUseCase 는 final 이면서도 문제가 없는데, 그건 그 능력이 꺼진 배포에서 + * 빈으로 등록되지 않아 프록시가 만들어지지 않기 때문이다. Studio 의 use case 는 항상 등록된다. + */ +@RequiresPermission(StudioPermissions.WRITE) +@UseCaseCapability( + transactionMode = TransactionMode.WRITE, + idempotency = Idempotency.KEYED, + repositoryAccess = RepositoryAccess.WRITE_REPOSITORY) +public class PublishStudioDocumentUseCase + implements CommandUseCase { + + /** 설계 05장 §14 의 content format 버전. */ + private static final String CONTENT_FORMAT_VERSION = "1"; + + /** 렌더 계약 버전. 렌더 결과의 의미가 바뀌면 올린다 — 기존 snapshot 이 어떤 규칙으로 만들어졌는지 남는다. */ + private static final String RENDERER_CONTRACT_VERSION = "1"; + + private final StudioDocumentLoader documents; + private final StudioDependencyResolverPort dependencies; + private final ContentAnalyzerPort contentAnalyzer; + private final DependencyRevisionPort dependencyRevisions; + private final ValidationArtifactPort validations; + private final PreviewArtifactPort previews; + private final PublicationWriterPort publications; + private final TransactionPort transactions; + private final Clock clock; + + public PublishStudioDocumentUseCase( + StudioDocumentLoader documents, + StudioDependencyResolverPort dependencies, + ContentAnalyzerPort contentAnalyzer, + DependencyRevisionPort dependencyRevisions, + ValidationArtifactPort validations, + PreviewArtifactPort previews, + PublicationWriterPort publications, + TransactionPort transactions, + Clock clock) { + this.documents = Objects.requireNonNull(documents, "documents"); + this.dependencies = Objects.requireNonNull(dependencies, "dependencies"); + this.contentAnalyzer = Objects.requireNonNull(contentAnalyzer, "contentAnalyzer"); + this.dependencyRevisions = Objects.requireNonNull(dependencyRevisions, "dependencyRevisions"); + this.validations = Objects.requireNonNull(validations, "validations"); + this.previews = Objects.requireNonNull(previews, "previews"); + this.publications = Objects.requireNonNull(publications, "publications"); + this.transactions = Objects.requireNonNull(transactions, "transactions"); + this.clock = Objects.requireNonNull(clock, "clock"); + } + + @Override + public PublishResultView handle(PublishDocumentCommand input) { + return transactions.inWrite( + () -> { + // 1~2. 잠금 + 버전 확인 + WorkingCopyView document = + documents.requireAtVersion(input.documentId(), input.expectedVersion()); + publications.lockCurrentPublication(document.kind(), document.id()); + + String currentRevision = dependencyRevisions.revisionFor(document.kind(), document.id()); + + // 3. validation 신선도 + ValidationReportView validation = + requireFreshValidation(input, document, currentRevision); + + // 4. preview 신선도 + PublicPreviewView preview = requireFreshPreview(input, document, currentRevision); + + // 5. 경고 승인 + requireAcknowledgedWarnings(validation, input.acknowledgedWarningCodes()); + + // 6~8. 지금 다시 검증한다 — 저장된 결과를 믿고 건너뛰면 그 사이의 변화가 그대로 공개된다. + ContentAnalyzerPort.ContentAnalysis content = + contentAnalyzer.analyze(ValidateStudioDocumentUseCase.bodyMarkdownOf(document)); + Set assetKeys = new LinkedHashSet<>(); + content.assetUsages().forEach(usage -> assetKeys.add(usage.assetKey())); + StudioDependencyResolverPort.Resolved resolved = + dependencies.resolve(document, assetKeys); + + StudioDocumentValidator.Outcome outcome = + StudioDocumentValidator.validate(document, resolved, content); + if (outcome.status() == ValidationStatus.INVALID) { + throw StudioException.of( + StudioError.DOCUMENT_VALIDATION_FAILED, + "the document does not pass validation at publish time"); + } + + // 9~19. 쓰기 + return publications.publish( + new PublicationWriterPort.PublishRequest( + document.kind(), + document.id(), + document.version(), + document.base().title(), + document.base().summary(), + document.base().topicId(), + document.base().projectId(), + resolved.publicPath(), + stateCodeOf(document), + preview.renderModelJson(), + content.plainText(), + manifestOf(resolved), + CONTENT_FORMAT_VERSION, + RENDERER_CONTRACT_VERSION, + input.idempotencyKey(), + input.principal())); + }); + } + + private ValidationReportView requireFreshValidation( + PublishDocumentCommand input, WorkingCopyView document, String currentRevision) { + ValidationReportView validation = + validations + .findById(input.validationId()) + .orElseThrow( + () -> + StudioException.of( + StudioError.VALIDATION_STALE, + "no validation " + input.validationId() + " to publish against")); + if (!validation.documentId().equals(document.id()) + || validation.validatedVersion() != document.version() + || !currentRevision.equals(validation.dependencyRevision()) + || !clock.instant().isBefore(validation.validUntil())) { + throw StudioException.of( + StudioError.VALIDATION_STALE, + "the validation no longer describes the current document or its dependencies"); + } + return validation; + } + + private PublicPreviewView requireFreshPreview( + PublishDocumentCommand input, WorkingCopyView document, String currentRevision) { + PublicPreviewView preview = + previews + .findById(input.previewId()) + .orElseThrow( + () -> + StudioException.of( + StudioError.PREVIEW_STALE, + "no preview " + input.previewId() + " to publish")); + if (!clock.instant().isBefore(preview.expiresAt())) { + throw StudioException.of( + StudioError.PREVIEW_EXPIRED, "the preview expired; create a new one before publishing"); + } + if (!preview.documentId().equals(document.id()) + || preview.previewVersion() != document.version() + || !currentRevision.equals(preview.dependencyRevision())) { + throw StudioException.of( + StudioError.PREVIEW_STALE, "the preview does not describe the version being published"); + } + return preview; + } + + /** + * 계약: {@code acknowledgedWarningCodes} 가 현재 Validation 의 WARNING 집합을 모두 덮지 못하면 거절한다. 작성자가 보지 못한 + * 경고를 안고 공개되는 일을 막는 장치다. + */ + private static void requireAcknowledgedWarnings( + ValidationReportView validation, List acknowledged) { + Set warnings = + validation.issues().stream() + .filter(issue -> issue.severity() == ValidationSeverity.WARNING) + .map(ValidationIssueView::code) + .collect(java.util.stream.Collectors.toCollection(LinkedHashSet::new)); + warnings.removeAll(Set.copyOf(acknowledged)); + if (!warnings.isEmpty()) { + throw StudioException.of( + StudioError.WARNING_ACKNOWLEDGEMENT_REQUIRED, + "these validation warnings were not acknowledged: " + warnings); + } + } + + private static List manifestOf( + StudioDependencyResolverPort.Resolved resolved) { + return resolved.assetsByKey().values().stream() + .map(PublishStudioDocumentUseCase::manifestEntry) + .toList(); + } + + private static AssetManifestEntry manifestEntry(ResolvedAssetView asset) { + return new AssetManifestEntry( + asset.assetId(), + asset.assetKey(), + asset.mediaType(), + asset.publicPath(), + asset.width(), + asset.height(), + asset.decorative()); + } + + /** 공개 projection 의 {@code state_code}. 목록 화면이 상태별로 걸러 볼 수 있게 하는 값이다. */ + private static String stateCodeOf(WorkingCopyView document) { + return switch (document) { + case WorkingCopyView.QuestionWorkingCopyView value -> + value.questionStatus() == QuestionStatusView.RESOLVED ? "RESOLVED" : "OPEN"; + case WorkingCopyView.ProjectDecisionWorkingCopyView value -> + value.decisionStatus() == DecisionStatusView.ADOPTED ? "ADOPTED" : "PROPOSED"; + case WorkingCopyView.CaseWorkingCopyView ignored -> null; + case WorkingCopyView.ReferenceWorkingCopyView ignored -> null; + }; + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/SaveOutcome.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/SaveOutcome.java new file mode 100644 index 0000000..c57c579 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/SaveOutcome.java @@ -0,0 +1,18 @@ +package dev.caskeleton.application.techlog.studio.service; + +import dev.caskeleton.application.techlog.studio.model.WorkingCopyDetailView; + +/** + * 저장 결과. 낙관적 잠금 충돌을 예외가 아니라 값으로 돌려준다. + * + *

    계약의 {@code VersionConflictDetails.latestDocument}가 전체 {@code WorkingCopyDetail}이라, 충돌 응답을 만들려면 + * 계약 DTO 조립이 필요하다. 그 조립은 웹 계층의 일이므로 application이 예외에 계약 모양을 실어 던지는 대신 현재 상태를 값으로 넘긴다. + */ +public sealed interface SaveOutcome { + + /** 저장됨. */ + record Saved(WorkingCopyDetailView detail) implements SaveOutcome {} + + /** {@code expectedVersion}이 현재 버전과 다름. {@code latest}는 지금의 실제 상태다. */ + record VersionConflict(WorkingCopyDetailView latest) implements SaveOutcome {} +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/SaveStudioDocumentUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/SaveStudioDocumentUseCase.java new file mode 100644 index 0000000..82b050a --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/SaveStudioDocumentUseCase.java @@ -0,0 +1,102 @@ +package dev.caskeleton.application.techlog.studio.service; + +import dev.caskeleton.application.capability.Idempotency; +import dev.caskeleton.application.capability.RepositoryAccess; +import dev.caskeleton.application.capability.UseCaseCapability; +import dev.caskeleton.application.security.RequiresPermission; +import dev.caskeleton.application.techlog.error.StudioError; +import dev.caskeleton.application.techlog.error.StudioException; +import dev.caskeleton.application.techlog.studio.command.SaveDocumentCommand; +import dev.caskeleton.application.techlog.studio.model.RecordKind; +import dev.caskeleton.application.techlog.studio.model.WorkingCopyView; +import dev.caskeleton.application.techlog.studio.port.out.WorkingCopyRepositoryPort; +import dev.caskeleton.application.transaction.TransactionMode; +import dev.caskeleton.application.transaction.TransactionPort; +import dev.caskeleton.application.usecase.CommandUseCase; +import java.util.Objects; +import java.util.Optional; + +/** + * {@code saveStudioDocument}. 편집 가능한 content field만 저장하고 lifecycle 전이를 유발하지 않는다 (spec §6.2). 저장은 + * Public Projection을 바꾸지 않는다. + */ +/* + * 이 클래스가 final 이 아닌 이유: @RequiresPermission 은 Spring AOP 로 강제되고, Boot 는 기본적으로 + * CGLIB 프록시(proxy-target-class=true)를 쓴다 — final 클래스는 subclass 할 수 없어 빈 생성이 + * 실패한다("Cannot subclass final class"). 실제 부팅 검증에서 그렇게 실패했다. + * 템플릿의 NotificationDispatchUseCase 는 final 이면서도 문제가 없는데, 그건 그 능력이 꺼진 배포에서 + * 빈으로 등록되지 않아 프록시가 만들어지지 않기 때문이다. Studio 의 use case 는 항상 등록된다. + */ +@RequiresPermission(StudioPermissions.WRITE) +@UseCaseCapability( + transactionMode = TransactionMode.WRITE, + idempotency = Idempotency.KEYED, + repositoryAccess = RepositoryAccess.WRITE_REPOSITORY) +public class SaveStudioDocumentUseCase implements CommandUseCase { + + private final WorkingCopyRepositoryPort workingCopies; + private final WorkingCopyDetailAssembler assembler; + private final TransactionPort transactions; + + public SaveStudioDocumentUseCase( + WorkingCopyRepositoryPort workingCopies, + WorkingCopyDetailAssembler assembler, + TransactionPort transactions) { + this.workingCopies = Objects.requireNonNull(workingCopies, "workingCopies"); + this.assembler = Objects.requireNonNull(assembler, "assembler"); + this.transactions = Objects.requireNonNull(transactions, "transactions"); + } + + @Override + public SaveOutcome handle(SaveDocumentCommand input) { + WorkingCopyInputValidator.validate(input.document()); + if (input.expectedVersion() < 1) { + throw StudioException.of( + StudioError.REQUEST_VALIDATION_FAILED, "expectedVersion must be at least 1"); + } + if (input.principal() == null || input.principal().isBlank()) { + throw StudioException.of( + StudioError.AUTHENTICATION_REQUIRED, "a principal is required to save a working copy"); + } + + return transactions.inWrite( + () -> { + RecordKind existing = + workingCopies + .findKind(input.documentId()) + .orElseThrow( + () -> + StudioException.of( + StudioError.DOCUMENT_NOT_FOUND, + "no working copy for documentId " + input.documentId())); + // 유형을 바꾸는 저장은 다른 aggregate로의 이동이지 편집이 아니다. 허용하면 원본 행이 남은 채 + // 새 유형의 행이 생겨 같은 documentId가 두 테이블에 존재하게 된다. + if (existing != input.document().kind()) { + throw StudioException.of( + StudioError.REQUEST_VALIDATION_FAILED, + "document.kind cannot change on save: stored " + + existing + + ", requested " + + input.document().kind()); + } + + Optional saved = + workingCopies.save( + input.documentId(), input.expectedVersion(), input.document(), input.principal()); + if (saved.isPresent()) { + return new SaveOutcome.Saved(assembler.assemble(saved.get())); + } + // 충돌 시점의 실제 상태를 다시 읽는다 — 저장 전에 읽어 둔 스냅숏을 돌려주면 계약이 약속한 + // "현재 상태"가 아니라 이미 지난 상태를 주게 된다. + WorkingCopyView latest = + workingCopies + .find(input.documentId()) + .orElseThrow( + () -> + StudioException.of( + StudioError.DOCUMENT_NOT_FOUND, + "working copy " + input.documentId() + " disappeared during save")); + return new SaveOutcome.VersionConflict(assembler.assemble(latest)); + }); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/StudioDocumentLoader.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/StudioDocumentLoader.java new file mode 100644 index 0000000..ba974e3 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/StudioDocumentLoader.java @@ -0,0 +1,46 @@ +package dev.caskeleton.application.techlog.studio.service; + +import dev.caskeleton.application.techlog.error.StudioError; +import dev.caskeleton.application.techlog.error.StudioException; +import dev.caskeleton.application.techlog.studio.model.WorkingCopyView; +import dev.caskeleton.application.techlog.studio.port.out.WorkingCopyRepositoryPort; +import java.util.Objects; +import java.util.UUID; + +/** + * "존재하고, 클라이언트가 생각하는 그 버전인가"를 한 곳에서 확인한다. + * + *

    validate·preview·publish 가 모두 같은 확인을 하는데 각자 구현하면 어떤 경로는 버전 검사를 빠뜨린 채로 지나간다 — 그러면 사용자가 화면에서 본 + * 것과 다른 버전이 검증되거나 게시된다. + */ +public final class StudioDocumentLoader { + + private final WorkingCopyRepositoryPort workingCopies; + + public StudioDocumentLoader(WorkingCopyRepositoryPort workingCopies) { + this.workingCopies = Objects.requireNonNull(workingCopies, "workingCopies"); + } + + public WorkingCopyView requireAtVersion(UUID documentId, long expectedVersion) { + WorkingCopyView document = require(documentId); + if (document.version() != expectedVersion) { + throw StudioException.of( + StudioError.VERSION_CONFLICT, + "expectedVersion " + expectedVersion + " but stored version is " + document.version()); + } + return document; + } + + public WorkingCopyView require(UUID documentId) { + if (documentId == null) { + throw StudioException.of(StudioError.REQUEST_VALIDATION_FAILED, "documentId is required"); + } + return workingCopies + .find(documentId) + .orElseThrow( + () -> + StudioException.of( + StudioError.DOCUMENT_NOT_FOUND, + "no working copy for documentId " + documentId)); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/StudioPermissions.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/StudioPermissions.java new file mode 100644 index 0000000..3718b96 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/StudioPermissions.java @@ -0,0 +1,16 @@ +package dev.caskeleton.application.techlog.studio.service; + +/** + * Studio가 요구하는 권한 토큰. + * + *

    배포는 {@code ca-skeleton.authz.role-permissions. = [studio:write]}로 자기 IdP의 역할 이름을 여기에 + * 잇는다. 역할 이름을 코드에 박지 않는 이유는 그것이 배포마다 다른 값이기 때문이다 — 계약({@code StudioSession.roles})도 역할 이름을 고정하지 + * 않는다. + */ +public final class StudioPermissions { + + /** 편집본 생성·저장·검증·미리보기·게시가 요구하는 권한. */ + public static final String WRITE = "studio:write"; + + private StudioPermissions() {} +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/UnpublishStudioPublicationUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/UnpublishStudioPublicationUseCase.java new file mode 100644 index 0000000..b9cc180 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/UnpublishStudioPublicationUseCase.java @@ -0,0 +1,90 @@ +package dev.caskeleton.application.techlog.studio.service; + +import dev.caskeleton.application.capability.Idempotency; +import dev.caskeleton.application.capability.RepositoryAccess; +import dev.caskeleton.application.capability.UseCaseCapability; +import dev.caskeleton.application.security.RequiresPermission; +import dev.caskeleton.application.techlog.error.StudioError; +import dev.caskeleton.application.techlog.error.StudioException; +import dev.caskeleton.application.techlog.studio.command.UnpublishPublicationCommand; +import dev.caskeleton.application.techlog.studio.model.PublicationAggregateStatus; +import dev.caskeleton.application.techlog.studio.model.PublicationAggregateView; +import dev.caskeleton.application.techlog.studio.model.PublishResultView; +import dev.caskeleton.application.techlog.studio.model.WorkingCopyView; +import dev.caskeleton.application.techlog.studio.port.out.PublicationHistoryQueryPort; +import dev.caskeleton.application.techlog.studio.port.out.PublicationWriterPort; +import dev.caskeleton.application.transaction.TransactionMode; +import dev.caskeleton.application.transaction.TransactionPort; +import dev.caskeleton.application.usecase.CommandUseCase; +import java.util.Objects; + +/** {@code unpublishStudioPublication}. Snapshot 은 삭제하지 않는다 — 과거에 무엇이 공개됐는지는 지우지 않는다(spec §7.5). */ +/* + * 이 클래스가 final 이 아닌 이유: @RequiresPermission 은 Spring AOP 로 강제되고, Boot 는 기본적으로 + * CGLIB 프록시(proxy-target-class=true)를 쓴다 — final 클래스는 subclass 할 수 없어 빈 생성이 + * 실패한다("Cannot subclass final class"). 실제 부팅 검증에서 그렇게 실패했다. + * 템플릿의 NotificationDispatchUseCase 는 final 이면서도 문제가 없는데, 그건 그 능력이 꺼진 배포에서 + * 빈으로 등록되지 않아 프록시가 만들어지지 않기 때문이다. Studio 의 use case 는 항상 등록된다. + */ +@RequiresPermission(StudioPermissions.WRITE) +@UseCaseCapability( + transactionMode = TransactionMode.WRITE, + idempotency = Idempotency.KEYED, + repositoryAccess = RepositoryAccess.WRITE_REPOSITORY) +public class UnpublishStudioPublicationUseCase + implements CommandUseCase { + + private final PublicationHistoryQueryPort history; + private final PublicationWriterPort publications; + private final StudioDocumentLoader documents; + private final TransactionPort transactions; + + public UnpublishStudioPublicationUseCase( + PublicationHistoryQueryPort history, + PublicationWriterPort publications, + StudioDocumentLoader documents, + TransactionPort transactions) { + this.history = Objects.requireNonNull(history, "history"); + this.publications = Objects.requireNonNull(publications, "publications"); + this.documents = Objects.requireNonNull(documents, "documents"); + this.transactions = Objects.requireNonNull(transactions, "transactions"); + } + + @Override + public PublishResultView handle(UnpublishPublicationCommand input) { + return transactions.inWrite( + () -> { + PublicationAggregateView publication = + history + .findById(input.publicationId()) + .orElseThrow( + () -> + StudioException.of( + StudioError.PUBLICATION_NOT_FOUND, + "no publication " + input.publicationId())); + + if (publication.status() != PublicationAggregateStatus.PUBLISHED) { + throw StudioException.of( + StudioError.PUBLICATION_CONFLICT, "this publication is already withdrawn"); + } + if (publication.publicationRevision() != input.expectedPublicationRevision()) { + throw StudioException.of( + StudioError.PUBLICATION_CONFLICT, + "expectedPublicationRevision " + + input.expectedPublicationRevision() + + " but stored revision is " + + publication.publicationRevision()); + } + + WorkingCopyView document = documents.require(publication.documentId()); + publications.lockCurrentPublication(document.kind(), document.id()); + return publications.unpublish( + new PublicationWriterPort.UnpublishRequest( + publication.publicationId(), + document.kind(), + document.id(), + input.expectedPublicationRevision(), + input.principal())); + }); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/UpdateStudioAssetUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/UpdateStudioAssetUseCase.java new file mode 100644 index 0000000..1248f22 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/UpdateStudioAssetUseCase.java @@ -0,0 +1,97 @@ +package dev.caskeleton.application.techlog.studio.service; + +import dev.caskeleton.application.capability.Idempotency; +import dev.caskeleton.application.capability.RepositoryAccess; +import dev.caskeleton.application.capability.UseCaseCapability; +import dev.caskeleton.application.security.RequiresPermission; +import dev.caskeleton.application.techlog.error.StudioError; +import dev.caskeleton.application.techlog.error.StudioException; +import dev.caskeleton.application.techlog.studio.command.UpdateAssetCommand; +import dev.caskeleton.application.techlog.studio.model.AssetManagementStatusView; +import dev.caskeleton.application.techlog.studio.model.AssetView; +import dev.caskeleton.application.techlog.studio.port.out.AssetRepositoryPort; +import dev.caskeleton.application.transaction.TransactionMode; +import dev.caskeleton.application.transaction.TransactionPort; +import dev.caskeleton.application.usecase.CommandUseCase; +import java.util.Objects; + +/** + * {@code updateStudioAsset}. 계약이 허용하는 것은 {@code altText}, {@code decorative}, {@code kind}, 그리고 + * {@code READY ↔ ARCHIVED} 전환뿐이다. + */ +/* + * 이 클래스가 final 이 아닌 이유: @RequiresPermission 은 Spring AOP 로 강제되고, Boot 는 기본적으로 + * CGLIB 프록시(proxy-target-class=true)를 쓴다 — final 클래스는 subclass 할 수 없어 빈 생성이 + * 실패한다("Cannot subclass final class"). 실제 부팅 검증에서 그렇게 실패했다. + * 템플릿의 NotificationDispatchUseCase 는 final 이면서도 문제가 없는데, 그건 그 능력이 꺼진 배포에서 + * 빈으로 등록되지 않아 프록시가 만들어지지 않기 때문이다. Studio 의 use case 는 항상 등록된다. + */ +@RequiresPermission(StudioPermissions.WRITE) +@UseCaseCapability( + transactionMode = TransactionMode.WRITE, + idempotency = Idempotency.KEYED, + repositoryAccess = RepositoryAccess.WRITE_REPOSITORY) +public class UpdateStudioAssetUseCase implements CommandUseCase { + + private final AssetRepositoryPort assets; + private final TransactionPort transactions; + + public UpdateStudioAssetUseCase(AssetRepositoryPort assets, TransactionPort transactions) { + this.assets = Objects.requireNonNull(assets, "assets"); + this.transactions = Objects.requireNonNull(transactions, "transactions"); + } + + @Override + public AssetView handle(UpdateAssetCommand input) { + if (input.expectedVersion() < 1) { + throw StudioException.of( + StudioError.REQUEST_VALIDATION_FAILED, "expectedVersion must be at least 1"); + } + if (input.managementStatus() != null + && input.managementStatus() != AssetManagementStatusView.READY + && input.managementStatus() != AssetManagementStatusView.ARCHIVED) { + // REJECTED/QUARANTINED 는 서버 검증 결과다. 클라이언트가 지정하게 두면 격리된 Asset 을 + // 스스로 풀어 줄 수 있게 된다. + throw StudioException.of( + StudioError.REQUEST_VALIDATION_FAILED, + "managementStatus can only be set to READY or ARCHIVED"); + } + + return transactions.inWrite( + () -> { + AssetView current = + assets + .find(input.assetId()) + .orElseThrow( + () -> + StudioException.of( + StudioError.ASSET_NOT_FOUND, "no asset " + input.assetId())); + if (current.managementStatus() == AssetManagementStatusView.QUARANTINED) { + throw StudioException.of( + StudioError.ASSET_QUARANTINED, "a quarantined asset cannot be edited"); + } + if (current.managementStatus() == AssetManagementStatusView.REJECTED) { + throw StudioException.of( + StudioError.ASSET_NOT_READY, "a rejected asset cannot be edited"); + } + return assets + .update( + input.assetId(), + input.expectedVersion(), + input.kind(), + input.altText(), + input.altTextProvided(), + input.decorative(), + input.managementStatus(), + input.principal()) + .orElseThrow( + () -> + StudioException.of( + StudioError.VERSION_CONFLICT, + "expectedVersion " + + input.expectedVersion() + + " but stored version is " + + current.version())); + }); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/UploadStudioAssetUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/UploadStudioAssetUseCase.java new file mode 100644 index 0000000..8fdad45 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/UploadStudioAssetUseCase.java @@ -0,0 +1,140 @@ +package dev.caskeleton.application.techlog.studio.service; + +import dev.caskeleton.application.capability.Idempotency; +import dev.caskeleton.application.capability.RepositoryAccess; +import dev.caskeleton.application.capability.UseCaseCapability; +import dev.caskeleton.application.security.RequiresPermission; +import dev.caskeleton.application.techlog.error.StudioError; +import dev.caskeleton.application.techlog.error.StudioException; +import dev.caskeleton.application.techlog.studio.command.UploadAssetCommand; +import dev.caskeleton.application.techlog.studio.model.AssetManagementStatusView; +import dev.caskeleton.application.techlog.studio.model.AssetView; +import dev.caskeleton.application.techlog.studio.port.out.AssetBinaryStoragePort; +import dev.caskeleton.application.techlog.studio.port.out.AssetRepositoryPort; +import dev.caskeleton.application.transaction.TransactionMode; +import dev.caskeleton.application.transaction.TransactionPort; +import dev.caskeleton.application.usecase.CommandUseCase; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; +import java.util.Locale; +import java.util.Objects; +import java.util.UUID; +import java.util.function.Supplier; + +/** + * {@code uploadStudioAsset}. 확장자를 신뢰하지 않고 내용으로 media type 을 판정하며, 판정에 실패하면 {@code READY} 로 만들지 + * 않는다(계약 설명). + */ +/* + * 이 클래스가 final 이 아닌 이유: @RequiresPermission 은 Spring AOP 로 강제되고, Boot 는 기본적으로 + * CGLIB 프록시(proxy-target-class=true)를 쓴다 — final 클래스는 subclass 할 수 없어 빈 생성이 + * 실패한다("Cannot subclass final class"). 실제 부팅 검증에서 그렇게 실패했다. + * 템플릿의 NotificationDispatchUseCase 는 final 이면서도 문제가 없는데, 그건 그 능력이 꺼진 배포에서 + * 빈으로 등록되지 않아 프록시가 만들어지지 않기 때문이다. Studio 의 use case 는 항상 등록된다. + */ +@RequiresPermission(StudioPermissions.WRITE) +@UseCaseCapability( + transactionMode = TransactionMode.WRITE, + idempotency = Idempotency.KEYED, + repositoryAccess = RepositoryAccess.WRITE_REPOSITORY) +public class UploadStudioAssetUseCase implements CommandUseCase { + + /** 업로드 상한. 넘으면 계약의 413 {@code PAYLOAD_TOO_LARGE} 로 거절한다. */ + private static final long MAX_BYTES = 20L * 1024 * 1024; + + private final AssetRepositoryPort assets; + private final AssetBinaryStoragePort binaries; + private final TransactionPort transactions; + private final Supplier idGenerator; + + public UploadStudioAssetUseCase( + AssetRepositoryPort assets, + AssetBinaryStoragePort binaries, + TransactionPort transactions, + Supplier idGenerator) { + this.assets = Objects.requireNonNull(assets, "assets"); + this.binaries = Objects.requireNonNull(binaries, "binaries"); + this.transactions = Objects.requireNonNull(transactions, "transactions"); + this.idGenerator = Objects.requireNonNull(idGenerator, "idGenerator"); + } + + @Override + public AssetView handle(UploadAssetCommand input) { + if (input.kind() == null) { + throw StudioException.of(StudioError.REQUEST_VALIDATION_FAILED, "kind is required"); + } + if (input.byteSize() == 0) { + throw StudioException.of(StudioError.REQUEST_VALIDATION_FAILED, "the uploaded file is empty"); + } + if (input.byteSize() > MAX_BYTES) { + throw StudioException.of( + StudioError.PAYLOAD_TOO_LARGE, "the upload exceeds " + MAX_BYTES + " bytes"); + } + + byte[] content = input.content(); + String mediaType = AssetMediaTypes.detect(content); + if (mediaType == null) { + // 무엇인지 모르는 파일을 저장은 하되 사용할 수 없게 두는 것보다, 받지 않는 편이 낫다 — + // 저장하면 그 바이트의 소유·수명·정리 책임이 생긴다. + throw StudioException.of( + StudioError.UNSUPPORTED_MEDIA_TYPE, + "the uploaded content is not one of the media types this Studio accepts"); + } + + UUID assetId = idGenerator.get(); + String assetKey = assetKeyFor(input.originalFilename(), assetId); + String objectKey = "techlog/assets/" + assetId; + + return transactions.inWrite( + () -> { + String storedKey = binaries.store(objectKey, content, mediaType); + return assets.create( + new AssetRepositoryPort.NewAsset( + assetId, + assetKey, + input.kind(), + mediaType, + storedKey, + input.originalFilename(), + input.byteSize(), + null, + null, + sha256(content), + input.altText(), + input.decorative(), + // 내용 판정을 통과했으므로 READY 다. 판정 실패는 위에서 이미 거절했다. + AssetManagementStatusView.READY), + input.principal()); + }); + } + + /** + * 안정적이고 사람이 읽을 수 있는 key. 파일명에서 만들되 뒤에 id 조각을 붙여 유일성을 보장한다 — 같은 이름의 파일을 두 번 올렸다고 key 가 충돌하면 두 번째 + * 업로드가 실패한다. + */ + private static String assetKeyFor(String originalFilename, UUID assetId) { + String base = originalFilename == null ? "" : originalFilename; + int dot = base.lastIndexOf('.'); + if (dot > 0) { + base = base.substring(0, dot); + } + String slug = + base.toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9]+", "-").replaceAll("(^-|-$)", ""); + if (slug.isBlank()) { + slug = "asset"; + } + if (slug.length() > 150) { + slug = slug.substring(0, 150); + } + return slug + "-" + assetId.toString().substring(0, 8); + } + + private static String sha256(byte[] content) { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(content)); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 must be available on every supported JVM", e); + } + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/ValidateStudioDocumentUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/ValidateStudioDocumentUseCase.java new file mode 100644 index 0000000..9246d58 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/ValidateStudioDocumentUseCase.java @@ -0,0 +1,116 @@ +package dev.caskeleton.application.techlog.studio.service; + +import dev.caskeleton.application.capability.Idempotency; +import dev.caskeleton.application.capability.RepositoryAccess; +import dev.caskeleton.application.capability.UseCaseCapability; +import dev.caskeleton.application.security.RequiresPermission; +import dev.caskeleton.application.techlog.studio.command.ValidateDocumentCommand; +import dev.caskeleton.application.techlog.studio.model.ValidationReportView; +import dev.caskeleton.application.techlog.studio.model.WorkingCopyView; +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.StudioDependencyResolverPort; +import dev.caskeleton.application.techlog.studio.port.out.ValidationArtifactPort; +import dev.caskeleton.application.techlog.studio.validation.StudioDocumentValidator; +import dev.caskeleton.application.transaction.TransactionMode; +import dev.caskeleton.application.transaction.TransactionPort; +import dev.caskeleton.application.usecase.CommandUseCase; +import java.time.Clock; +import java.time.Duration; +import java.util.LinkedHashSet; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; +import java.util.function.Supplier; + +/** + * {@code validateStudioDocument}. 단순 request validation 이 아니라 계약이 정한 체인 전체를 수행하고, 결과를 일급 artifact 로 + * 영속한다(계약 설명, spec §7.3). + * + *

    결과를 버리지 않는 이유는 {@code createStudioPreview} 와 {@code publishStudioDocument} 가 {@code + * validationId} 로 "무엇을 근거로 통과했는지"를 참조하기 때문이다. + */ +/* + * 이 클래스가 final 이 아닌 이유: @RequiresPermission 은 Spring AOP 로 강제되고, Boot 는 기본적으로 + * CGLIB 프록시(proxy-target-class=true)를 쓴다 — final 클래스는 subclass 할 수 없어 빈 생성이 + * 실패한다("Cannot subclass final class"). 실제 부팅 검증에서 그렇게 실패했다. + * 템플릿의 NotificationDispatchUseCase 는 final 이면서도 문제가 없는데, 그건 그 능력이 꺼진 배포에서 + * 빈으로 등록되지 않아 프록시가 만들어지지 않기 때문이다. Studio 의 use case 는 항상 등록된다. + */ +@RequiresPermission(StudioPermissions.WRITE) +@UseCaseCapability( + transactionMode = TransactionMode.WRITE, + idempotency = Idempotency.KEYED, + repositoryAccess = RepositoryAccess.WRITE_REPOSITORY) +public class ValidateStudioDocumentUseCase + implements CommandUseCase { + + private final StudioDocumentLoader documents; + private final StudioDependencyResolverPort dependencies; + private final ContentAnalyzerPort contentAnalyzer; + private final DependencyRevisionPort dependencyRevisions; + private final ValidationArtifactPort validations; + private final TransactionPort transactions; + private final Supplier idGenerator; + private final Clock clock; + private final Duration validationTtl; + + public ValidateStudioDocumentUseCase( + StudioDocumentLoader documents, + StudioDependencyResolverPort dependencies, + ContentAnalyzerPort contentAnalyzer, + DependencyRevisionPort dependencyRevisions, + ValidationArtifactPort validations, + TransactionPort transactions, + Supplier idGenerator, + Clock clock, + Duration validationTtl) { + this.documents = Objects.requireNonNull(documents, "documents"); + this.dependencies = Objects.requireNonNull(dependencies, "dependencies"); + this.contentAnalyzer = Objects.requireNonNull(contentAnalyzer, "contentAnalyzer"); + this.dependencyRevisions = Objects.requireNonNull(dependencyRevisions, "dependencyRevisions"); + this.validations = Objects.requireNonNull(validations, "validations"); + this.transactions = Objects.requireNonNull(transactions, "transactions"); + this.idGenerator = Objects.requireNonNull(idGenerator, "idGenerator"); + this.clock = Objects.requireNonNull(clock, "clock"); + this.validationTtl = Objects.requireNonNull(validationTtl, "validationTtl"); + } + + @Override + public ValidationReportView handle(ValidateDocumentCommand input) { + return transactions.inWrite( + () -> { + WorkingCopyView document = + documents.requireAtVersion(input.documentId(), input.expectedVersion()); + ContentAnalyzerPort.ContentAnalysis content = + contentAnalyzer.analyze(bodyMarkdownOf(document)); + Set assetKeys = new LinkedHashSet<>(); + content.assetUsages().forEach(usage -> assetKeys.add(usage.assetKey())); + + StudioDependencyResolverPort.Resolved resolved = + dependencies.resolve(document, assetKeys); + StudioDocumentValidator.Outcome outcome = + StudioDocumentValidator.validate(document, resolved, content); + + var now = clock.instant(); + ValidationReportView report = + new ValidationReportView( + idGenerator.get(), + document.id(), + document.version(), + outcome.status(), + outcome.issues(), + now, + now.plus(validationTtl), + dependencyRevisions.revisionFor(document.kind(), document.id())); + return validations.save(document.kind(), report, input.principal()); + }); + } + + /** {@code CASE} 만 본문을 갖는다 — 나머지 세 유형의 공개 모델은 구조화된 필드로만 이루어진다. */ + static String bodyMarkdownOf(WorkingCopyView document) { + return document instanceof WorkingCopyView.CaseWorkingCopyView value + ? value.bodyMarkdown() + : ""; + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/WorkingCopyDetailAssembler.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/WorkingCopyDetailAssembler.java new file mode 100644 index 0000000..63c7dcd --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/WorkingCopyDetailAssembler.java @@ -0,0 +1,59 @@ +package dev.caskeleton.application.techlog.studio.service; + +import dev.caskeleton.application.techlog.studio.model.PublicPreviewView; +import dev.caskeleton.application.techlog.studio.model.PublicationAggregateView; +import dev.caskeleton.application.techlog.studio.model.ValidationReportView; +import dev.caskeleton.application.techlog.studio.model.WorkingCopyDetailView; +import dev.caskeleton.application.techlog.studio.model.WorkingCopyView; +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.PublicationQueryPort; +import dev.caskeleton.application.techlog.studio.port.out.ValidationArtifactPort; +import java.time.Clock; +import java.util.Objects; + +/** + * 편집본 하나를 계약의 {@code WorkingCopyDetail} 모양으로 모은다. + * + *

    {@code getStudioDocument}와 {@code saveStudioDocument}가 같은 응답 스키마를 쓰고, 낙관적 잠금 충돌의 {@code + * VersionConflictDetails.latestDocument}도 같은 모양이라 세 곳이 같은 조립기를 공유한다 — 세 곳이 제각기 조립하면 {@code + * nextAction} 계산이 갈라진다. + */ +public final class WorkingCopyDetailAssembler { + + private final ValidationArtifactPort validations; + private final PreviewArtifactPort previews; + private final PublicationQueryPort publications; + private final DependencyRevisionPort dependencyRevisions; + private final Clock clock; + + public WorkingCopyDetailAssembler( + ValidationArtifactPort validations, + PreviewArtifactPort previews, + PublicationQueryPort publications, + DependencyRevisionPort dependencyRevisions, + Clock clock) { + this.validations = Objects.requireNonNull(validations, "validations"); + this.previews = Objects.requireNonNull(previews, "previews"); + this.publications = Objects.requireNonNull(publications, "publications"); + this.dependencyRevisions = Objects.requireNonNull(dependencyRevisions, "dependencyRevisions"); + this.clock = Objects.requireNonNull(clock, "clock"); + } + + public WorkingCopyDetailView assemble(WorkingCopyView document) { + String dependencyRevision = dependencyRevisions.revisionFor(document.kind(), document.id()); + ValidationReportView validation = + validations.latestFor(document.kind(), document.id()).orElse(null); + PublicPreviewView preview = previews.latestFor(document.kind(), document.id()).orElse(null); + PublicationAggregateView publication = publications.currentFor(document.id()).orElse(null); + + return new WorkingCopyDetailView( + document, + validation, + preview, + publication, + dependencyRevision, + NextActionCalculator.calculate( + document, validation, preview, publication, dependencyRevision, clock.instant())); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/WorkingCopyInputValidator.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/WorkingCopyInputValidator.java new file mode 100644 index 0000000..07c007e --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/WorkingCopyInputValidator.java @@ -0,0 +1,76 @@ +package dev.caskeleton.application.techlog.studio.service; + +import dev.caskeleton.application.techlog.error.StudioError; +import dev.caskeleton.application.techlog.error.StudioException; +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.HashSet; +import java.util.List; +import java.util.Set; +import java.util.regex.Pattern; + +/** + * 저장 요청이 형태로서 성립하는지만 본다. 게시 가능 여부는 여기서 판단하지 않는다 — 그건 {@code validateStudioDocument}의 일이고, + * 계약이 불완전한 초안의 저장을 명시적으로 허용한다. + * + *

    필드 길이·필수 여부 같은 계약 제약은 생성 DTO의 jakarta 애너테이션이 웹 경계에서 이미 잡는다. 여기서 다시 확인하는 것은 그 애너테이션으로 표현할 수 없는 + * 것들뿐이다 — slug 패턴의 "빈 문자열 또는 패턴" 양자택일, 관계 순서의 중복, 자기 자신 참조. + */ +public final class WorkingCopyInputValidator { + + /** 계약 {@code WorkingCopyInputBase.slug}의 두 번째 분기. */ + private static final Pattern SLUG = Pattern.compile("^[a-z0-9]+(?:-[a-z0-9]+)*$"); + + private static final int MAX_RELATIONS = 20; + + private WorkingCopyInputValidator() {} + + public static void validate(WorkingCopyInputView input) { + if (input == null) { + throw StudioException.of(StudioError.REQUEST_VALIDATION_FAILED, "document is required"); + } + WorkingCopyBaseInput base = input.base(); + if (base == null || base.kind() == null) { + throw StudioException.of(StudioError.REQUEST_VALIDATION_FAILED, "document.kind is required"); + } + validateSlug(base.slug()); + validateRelations(base.relations()); + } + + private static void validateSlug(String slug) { + if (slug == null) { + throw StudioException.of( + StudioError.REQUEST_VALIDATION_FAILED, + "document.slug must be present; use \"\" when it is not decided yet"); + } + if (!slug.isEmpty() && !SLUG.matcher(slug).matches()) { + throw StudioException.of( + StudioError.REQUEST_VALIDATION_FAILED, + "document.slug must be empty or match " + SLUG.pattern()); + } + } + + private static void validateRelations(List relations) { + if (relations.size() > MAX_RELATIONS) { + throw StudioException.of( + StudioError.REQUEST_VALIDATION_FAILED, + "document.relations must hold at most " + MAX_RELATIONS + " items"); + } + Set orders = new HashSet<>(); + for (RelationView relation : relations) { + if (relation.order() < 0) { + throw StudioException.of( + StudioError.REQUEST_VALIDATION_FAILED, + "document.relations[].order must not be negative"); + } + if (!orders.add(relation.order())) { + // 순서가 겹치면 저장은 되지만 다시 읽을 때 줄 순서가 비결정적이 된다 — 사용자가 쓴 순서와 + // 다른 순서로 돌아오는 편집기는 데이터가 조용히 뒤바뀐 것과 같다. + throw StudioException.of( + StudioError.REQUEST_VALIDATION_FAILED, + "document.relations[].order must be unique; " + relation.order() + " is repeated"); + } + } + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/validation/StudioDocumentValidator.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/validation/StudioDocumentValidator.java new file mode 100644 index 0000000..d6cb3e7 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/validation/StudioDocumentValidator.java @@ -0,0 +1,262 @@ +package dev.caskeleton.application.techlog.studio.validation; + +import dev.caskeleton.application.techlog.studio.model.OrderedTextView; +import dev.caskeleton.application.techlog.studio.model.QuestionStatusView; +import dev.caskeleton.application.techlog.studio.model.RelationView; +import dev.caskeleton.application.techlog.studio.model.ResolvedAssetView; +import dev.caskeleton.application.techlog.studio.model.ValidationStatus; +import dev.caskeleton.application.techlog.studio.model.WorkingCopyBaseInput; +import dev.caskeleton.application.techlog.studio.model.WorkingCopyView; +import dev.caskeleton.application.techlog.studio.port.out.ContentAnalyzerPort; +import dev.caskeleton.application.techlog.studio.port.out.StudioDependencyResolverPort; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +/** + * 계약 {@code validateStudioDocument}의 검증 체인(계약 설명, spec §7.5 6~8단계). + * + *

    {@code
    + * Schema/Input -> 유형별 Domain -> 관계/Project/Topic 존재 -> Asset READY
    + *   -> Slug/Route 충돌 -> Publication
    + * }
    + * + *

    판정 기준은 하나다 — 이 편집본으로 계약이 요구하는 {@code PublicRenderModel}을 만들 수 있는가. 그래서 각 규칙은 렌더 모델의 + * required/minLength/minItems에서 나온다. 검증이 그것과 다른 기준을 쓰면 검증을 통과한 문서가 렌더 단계에서 계약을 위반한다. + */ +public final class StudioDocumentValidator { + + private StudioDocumentValidator() {} + + /** + * 검증 결과. + * + * @param status ERROR가 하나라도 있으면 {@code INVALID}, WARNING만 있으면 {@code WARNINGS} + */ + public record Outcome( + ValidationStatus status, + List issues) {} + + public static Outcome validate( + WorkingCopyView document, + StudioDependencyResolverPort.Resolved resolved, + ContentAnalyzerPort.ContentAnalysis content) { + + ValidationIssues issues = new ValidationIssues(); + validateBase(document.base(), issues); + validateDependencies(document, resolved, issues); + validateKindSpecific(document, issues); + validateContent(document, resolved, content, issues); + + ValidationStatus status = + issues.hasError() + ? ValidationStatus.INVALID + : (issues.hasWarning() ? ValidationStatus.WARNINGS : ValidationStatus.VALID); + return new Outcome(status, issues.toList()); + } + + private static void validateBase(WorkingCopyBaseInput base, ValidationIssues issues) { + requireText(issues, base.title(), "TITLE_REQUIRED", "/title", "a title is required to publish"); + // 렌더 모델의 slug 는 minLength 3 + 패턴이다. 저장은 빈 slug 를 허용하지만 게시는 못 한다. + if (base.slug() == null || base.slug().isBlank()) { + issues.error("SLUG_REQUIRED", "/slug", "a slug is required to publish"); + } else if (base.slug().length() < 3) { + issues.error("SLUG_TOO_SHORT", "/slug", "a slug must be at least 3 characters"); + } + requireText( + issues, base.summary(), "SUMMARY_REQUIRED", "/summary", "a summary is required to publish"); + if (base.topicId() == null) { + issues.error("TOPIC_REQUIRED", "/topicId", "a topic is required to publish"); + } + } + + private static void validateDependencies( + WorkingCopyView document, + StudioDependencyResolverPort.Resolved resolved, + ValidationIssues issues) { + + if (resolved.topicMissing()) { + issues.error("TOPIC_NOT_FOUND", "/topicId", "the referenced topic no longer exists"); + } + if (resolved.projectMissing()) { + issues.error("PROJECT_NOT_FOUND", "/projectId", "the referenced project no longer exists"); + } + if (document.kind() + == dev.caskeleton.application.techlog.studio.model.RecordKind.PROJECT_DECISION + && document.base().projectId() == null) { + // 계약: "kind=PROJECT_DECISION 은 게시 시점에 non-null 이어야 한다." + issues.error("PROJECT_REQUIRED", "/projectId", "a project decision must belong to a project"); + } + + List relations = document.base().relations(); + for (int i = 0; i < relations.size(); i++) { + RelationView relation = relations.get(i); + String path = "/relations/" + i + "/targetId"; + if (relation.targetId() == null) { + issues.error("RELATION_TARGET_REQUIRED", path, "a relation must point at something"); + } else if (resolved.missingRelationTargets().contains(relation.targetId())) { + issues.error("RELATION_TARGET_NOT_FOUND", path, "the related record no longer exists"); + } + } + + UUID slugOwner = resolved.slugOwnerId(); + if (slugOwner != null && !slugOwner.equals(document.id())) { + issues.error("SLUG_CONFLICT", "/slug", "another record already publishes this slug"); + } + if (resolved.publicPath() == null) { + issues.error( + "PUBLIC_PATH_UNRESOLVED", "/slug", "a public path cannot be derived for this record"); + } + } + + private static void validateKindSpecific(WorkingCopyView document, ValidationIssues issues) { + switch (document) { + case WorkingCopyView.CaseWorkingCopyView value -> { + requireText( + issues, value.problem(), "PROBLEM_REQUIRED", "/problem", "a problem is required"); + requireText( + issues, + value.conclusion(), + "CONCLUSION_REQUIRED", + "/conclusion", + "a conclusion is required"); + if (value.lastVerifiedOn() == null) { + issues.error( + "LAST_VERIFIED_ON_REQUIRED", "/lastVerifiedOn", "a verification date is required"); + } + } + case WorkingCopyView.ReferenceWorkingCopyView value -> { + requireText( + issues, value.purpose(), "PURPOSE_REQUIRED", "/purpose", "a purpose is required"); + if (value.rules().isEmpty()) { + issues.error("RULES_REQUIRED", "/rules", "at least one rule is required"); + } + if (value.applyWhen().isEmpty()) { + issues.error( + "APPLY_WHEN_REQUIRED", + "/applyWhen", + "at least one application condition is required"); + } + if (value.verifiedOn() == null) { + issues.error("VERIFIED_ON_REQUIRED", "/verifiedOn", "a verification date is required"); + } + requireOrderedText(issues, value.applyWhen(), "/applyWhen"); + requireOrderedText(issues, value.exceptions(), "/exceptions"); + requireOrderedText(issues, value.examples(), "/examples"); + } + case WorkingCopyView.QuestionWorkingCopyView value -> { + if (value.questionStatus() == null) { + issues.error("QUESTION_STATUS_REQUIRED", "/questionStatus", "a status is required"); + } + if (value.facts().isEmpty()) { + issues.error("FACTS_REQUIRED", "/facts", "at least one established fact is required"); + } + requireText( + issues, + value.nextValidation(), + "NEXT_VALIDATION_REQUIRED", + "/nextValidation", + "the next validation step is required"); + if (value.questionStatus() == QuestionStatusView.RESOLVED + && (value.resolution() == null || isBlank(value.resolution().summary()))) { + issues.error( + "RESOLUTION_REQUIRED", "/resolution", "a resolved question needs its resolution"); + } + requireOrderedText(issues, value.facts(), "/facts"); + requireOrderedText(issues, value.assumptions(), "/assumptions"); + requireOrderedText(issues, value.unknowns(), "/unknowns"); + requireOrderedText(issues, value.constraints(), "/constraints"); + } + case WorkingCopyView.ProjectDecisionWorkingCopyView value -> { + if (value.decisionStatus() == null) { + issues.error("DECISION_STATUS_REQUIRED", "/decisionStatus", "a status is required"); + } + if (value.decidedOn() == null) { + issues.error("DECIDED_ON_REQUIRED", "/decidedOn", "a decision date is required"); + } + requireText( + issues, + value.statement(), + "STATEMENT_REQUIRED", + "/statement", + "a statement is required"); + requireText( + issues, + value.rationale(), + "RATIONALE_REQUIRED", + "/rationale", + "a rationale is required"); + requireOrderedText(issues, value.consequences(), "/consequences"); + } + } + } + + private static void validateContent( + WorkingCopyView document, + StudioDependencyResolverPort.Resolved resolved, + ContentAnalyzerPort.ContentAnalysis content, + ValidationIssues issues) { + + Map assets = resolved.assetsByKey(); + Map statuses = resolved.assetStatusByKey(); + + for (int i = 0; i < content.assetUsages().size(); i++) { + ContentAnalyzerPort.AssetUsage usage = content.assetUsages().get(i); + String path = "/bodyMarkdown/" + i; + if (isBlank(usage.assetKey())) { + issues.error("ASSET_KEY_REQUIRED", path, "an evidence figure needs a key"); + continue; + } + ResolvedAssetView asset = assets.get(usage.assetKey()); + if (asset == null) { + issues.error("ASSET_NOT_FOUND", path, "no asset is registered for key " + usage.assetKey()); + continue; + } + String status = statuses.get(usage.assetKey()); + if ("QUARANTINED".equals(status)) { + issues.error("ASSET_QUARANTINED", path, "a quarantined asset must not be published"); + } else if (!"READY".equals(status)) { + issues.error("ASSET_NOT_READY", path, "only a READY asset can be published"); + } + // 판단 대상은 asset 행의 alt 가 아니라 이 사용 위치의 alt 다 — 같은 Asset 이 문서마다 다른 + // alt 로 쓰인다(설계 05장 §3.4). + if (!asset.decorative() && isBlank(usage.alt())) { + issues.error( + "ASSET_ALT_REQUIRED", path, "a non-decorative asset needs alt text at this usage"); + } + } + + for (String directive : content.unsupportedDirectives()) { + issues.warning( + "UNSUPPORTED_DIRECTIVE", + "/bodyMarkdown", + "the ':::" + directive + "' directive is rendered as a plain quote"); + } + if (document instanceof WorkingCopyView.CaseWorkingCopyView value + && isBlank(value.bodyMarkdown())) { + issues.warning("BODY_EMPTY", "/bodyMarkdown", "this case will publish without a body"); + } + } + + private static void requireText( + ValidationIssues issues, String value, String code, String path, String message) { + if (isBlank(value)) { + issues.error(code, path, message); + } + } + + /** 계약의 {@code OrderedText.text} 는 minLength 1 이다 — 빈 항목이 있으면 렌더 모델이 계약을 어긴다. */ + private static void requireOrderedText( + ValidationIssues issues, List items, String path) { + for (int i = 0; i < items.size(); i++) { + if (isBlank(items.get(i).text())) { + issues.error( + "ORDERED_TEXT_EMPTY", path + "/" + i + "/text", "an empty item cannot publish"); + } + } + } + + private static boolean isBlank(String value) { + return value == null || value.isBlank(); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/validation/ValidationIssues.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/validation/ValidationIssues.java new file mode 100644 index 0000000..8c669a7 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/validation/ValidationIssues.java @@ -0,0 +1,46 @@ +package dev.caskeleton.application.techlog.studio.validation; + +import dev.caskeleton.application.techlog.studio.model.ValidationIssueView; +import dev.caskeleton.application.techlog.studio.model.ValidationSeverity; +import java.util.ArrayList; +import java.util.List; + +/** + * 검증 결과를 모으는 수집기. + * + *

    첫 오류에서 멈추지 않고 끝까지 모은다 — 작성자가 고칠 것을 한 번에 보게 하기 위해서다. 하나씩 알려주면 저장·검증을 오류 개수만큼 반복하게 된다. + */ +public final class ValidationIssues { + + /** 계약 {@code ValidationReport.issues} 의 maxItems. 넘치면 뒤는 버린다. */ + private static final int MAX_ISSUES = 200; + + private final List issues = new ArrayList<>(); + + public void error(String code, String path, String message) { + add(code, ValidationSeverity.ERROR, path, message); + } + + public void warning(String code, String path, String message) { + add(code, ValidationSeverity.WARNING, path, message); + } + + private void add(String code, ValidationSeverity severity, String path, String message) { + if (issues.size() >= MAX_ISSUES) { + return; + } + issues.add(new ValidationIssueView(code, severity, path, message)); + } + + public List toList() { + return List.copyOf(issues); + } + + public boolean hasError() { + return issues.stream().anyMatch(issue -> issue.severity() == ValidationSeverity.ERROR); + } + + public boolean hasWarning() { + return issues.stream().anyMatch(issue -> issue.severity() == ValidationSeverity.WARNING); + } +} From 37d56141294cc8b4c97f56be14144618b36cc32e Mon Sep 17 00:00:00 2001 From: DongHyeonka Date: Thu, 20 Aug 2026 00:14:18 +0900 Subject: [PATCH 05/10] fix: make Studio authorization actually work, and stop it failing as a 500 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four linked defects, found by driving the 19 operations against a running backend on PostgreSQL behind a real Keycloak realm. 1. The role→permission mapping never bound. Both profiles wrote it as role-permissions: ${APP_STUDIO_AUTHOR_ROLE:studio-author}: - studio:write and Spring Boot resolves placeholders in @ConfigurationProperties *values*, not in Map *keys* — the key bound as the literal "${APP_STUDIO_AUTHOR_ROLE:studio-author}", matched no real role, and left RolePermissionRegistry empty. Every Studio write answered 403, in local and prod alike. Setting APP_STUDIO_AUTHOR_ROLE explicitly did not help; a literal key returned 201 immediately. StudioAuthzEnvironmentPost Processor now resolves the role name as a scalar (where placeholders do work) and contributes the mapping under a literal key, so the name stays deployment-configurable. Registered the same way the tracing bridge is. 2. Reads were unguarded. Only WRITE carried @RequiresPermission, so any authenticated caller could list every draft and fetch one by id: listStudioDocuments 200, 2 drafts getStudioDocument 200 getStudioDashboard 200 listStudioAssets 200 The nine read use cases now declare studio:read. They lose `final` for the same CGLIB reason the write ones already document. 3. Failures were masked. IdempotencyExecutor's catch called store.discard, whose @Modifying bulk delete needs a transaction and had none, so it threw TransactionRequiredException over the original exception — the 403 above surfaced as 500 INTERNAL_ERROR with no cause in the log, which is why this shipped. discard now runs REQUIRES_NEW (cleanup must survive the failed work's rollback) and a cleanup failure is attached with addSuppressed instead of replacing what actually went wrong. 4. Reservations leaked. With discard throwing every time, failed requests left their idempotency rows behind. After the fix only the successful call's COMPLETED row remains. Verified end to end: studio-author writes with no extra configuration; an unprivileged caller gets 403 on all five read operations and on write; create → save → validate → preview → publish → unpublish all succeed; optimistic lock returns 409 VERSION_CONFLICT; the publication reaches public_resource_projection and flips to WITHDRAWN on unpublish. Co-Authored-By: Claude Opus 5 (1M context) --- .../idempotency/IdempotencyStoreAdapter.java | 9 +++ .../StudioAuthzEnvironmentPostProcessor.java | 56 +++++++++++++++++++ .../main/resources/META-INF/spring.factories | 3 +- .../src/main/resources/application-local.yml | 13 +---- .../src/main/resources/application-prod.yml | 13 +---- .../idempotency/IdempotencyExecutor.java | 6 +- .../GetCurrentStudioPreviewUseCase.java | 9 ++- .../studio/service/GetStudioAssetUseCase.java | 9 ++- .../service/GetStudioDashboardUseCase.java | 10 +++- .../service/GetStudioDocumentUseCase.java | 9 ++- .../GetStudioPublicationSnapshotUseCase.java | 9 ++- .../studio/service/ListCatalogUseCase.java | 9 ++- .../service/ListStudioAssetsUseCase.java | 9 ++- .../service/ListStudioDocumentsUseCase.java | 9 ++- .../ListStudioPublicationsUseCase.java | 9 ++- .../studio/service/StudioPermissions.java | 9 +++ 16 files changed, 159 insertions(+), 32 deletions(-) create mode 100644 src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/techlog/StudioAuthzEnvironmentPostProcessor.java diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/idempotency/IdempotencyStoreAdapter.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/idempotency/IdempotencyStoreAdapter.java index 280bcc6..396391a 100644 --- a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/idempotency/IdempotencyStoreAdapter.java +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/idempotency/IdempotencyStoreAdapter.java @@ -19,6 +19,8 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; /** * DB-backed {@link IdempotencyStorePort}. {@link #tryBegin} uses the {@code uq_idempotency_scope} @@ -162,7 +164,14 @@ public class IdempotencyStoreAdapter implements IdempotencyStorePort { row.getExpiresAt())); } + /** + * {@code deleteByScope} 는 {@code @Modifying} 벌크 delete 이므로 활성 트랜잭션을 요구한다. 이 메서드는 {@code + * IdempotencyExecutor} 의 실패 경로에서 호출되는데 그 지점에는 트랜잭션이 없다 — 예약 레코드를 지우려다 {@code + * TransactionRequiredException} 을 던져 원래 실패를 덮고 있었다(403 이 500 으로 바뀌고 로그에 원인이 남지 않았다). REQUIRES_NEW + * 인 이유: 정리는 실패한 작업의 롤백에 휩쓸리면 안 된다. + */ @Override + @Transactional(propagation = Propagation.REQUIRES_NEW) public void discard(IdempotencyScope scope) { repository.deleteByScope( IdempotencyRecordEntityMapper.tenantColumn(scope), diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/techlog/StudioAuthzEnvironmentPostProcessor.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/techlog/StudioAuthzEnvironmentPostProcessor.java new file mode 100644 index 0000000..04012fd --- /dev/null +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/techlog/StudioAuthzEnvironmentPostProcessor.java @@ -0,0 +1,56 @@ +package dev.caskeleton.bootstrap.techlog; + +import java.util.LinkedHashMap; +import java.util.Map; +import org.springframework.boot.EnvironmentPostProcessor; +import org.springframework.boot.SpringApplication; +import org.springframework.core.env.ConfigurableEnvironment; +import org.springframework.core.env.MapPropertySource; + +/** + * Studio 의 IdP 역할을 {@code studio:read} / {@code studio:write} 에 잇는다. + * + *

    왜 YAML 이 아니라 여기인가: 프로파일 YAML 은 이 매핑을 + * + *

    + * role-permissions:
    + *   ${APP_STUDIO_AUTHOR_ROLE:studio-author}:
    + *     - studio:write
    + * 
    + * + * 로 적고 있었다. Spring Boot 는 {@code @ConfigurationProperties} 의 에서는 플레이스홀더를 풀지만 Map + * 키에서는 풀지 않는다 — 키는 리터럴 {@code "${APP_STUDIO_AUTHOR_ROLE:studio-author}"} 로 바인딩된다. 어떤 실제 역할과도 + * 일치하지 않으므로 {@link dev.caskeleton.adapter.inbound.web.authz.RolePermissionRegistry} 는 빈 레지스트리가 되고, + * Studio 의 모든 쓰기가 403 이 된다. 실측으로 확인했다: {@code APP_STUDIO_AUTHOR_ROLE=studio-author} 를 명시해도 403, 리터럴 + * 키로 바꾸면 즉시 201. + * + *

    역할 이름은 배포마다 다르므로(계약도 {@code StudioSession.roles} 를 고정하지 않는다) 코드에 박을 수 없다. 스칼라 프로퍼티는 플레이스홀더가 정상 + * 동작하므로, 여기서 이름을 해석한 뒤 리터럴 키로 매핑을 심는다. + * + *

    {@code addLast} 로 넣으므로 운영자가 같은 키를 직접 주면 그쪽이 이긴다. {@code META-INF/spring.factories} 에 등록된다. + */ +public class StudioAuthzEnvironmentPostProcessor implements EnvironmentPostProcessor { + + static final String ROLE_KEY = "app.studio.author-role"; + static final String DEFAULT_ROLE = "studio-author"; + private static final String PREFIX = "ca-skeleton.authz.role-permissions."; + + @Override + public void postProcessEnvironment( + ConfigurableEnvironment environment, SpringApplication application) { + String role = resolveRole(environment); + Map mapping = new LinkedHashMap<>(); + mapping.put(PREFIX + role + "[0]", "studio:read"); + mapping.put(PREFIX + role + "[1]", "studio:write"); + environment.getPropertySources().addLast(new MapPropertySource("studioAuthzMapping", mapping)); + } + + /** + * {@code APP_STUDIO_AUTHOR_ROLE} 은 relaxed binding 으로 {@code app.studio.author-role} 에 닿는다. 값이 비어 + * 있으면 기본값을 쓴다 — 빈 문자열을 키로 심으면 역할 없는 호출자에게 권한이 붙는다. + */ + private static String resolveRole(ConfigurableEnvironment environment) { + String configured = environment.getProperty(ROLE_KEY); + return (configured == null || configured.isBlank()) ? DEFAULT_ROLE : configured.trim(); + } +} diff --git a/src/app-bootstrap/src/main/resources/META-INF/spring.factories b/src/app-bootstrap/src/main/resources/META-INF/spring.factories index 8a537d8..275a195 100644 --- a/src/app-bootstrap/src/main/resources/META-INF/spring.factories +++ b/src/app-bootstrap/src/main/resources/META-INF/spring.factories @@ -1,6 +1,7 @@ org.springframework.boot.EnvironmentPostProcessor=\ dev.caskeleton.bootstrap.tracing.TracingSamplingEnvironmentPostProcessor,\ -dev.caskeleton.bootstrap.runtime.RedisReadinessGroupPostProcessor +dev.caskeleton.bootstrap.runtime.RedisReadinessGroupPostProcessor,\ + dev.caskeleton.bootstrap.techlog.StudioAuthzEnvironmentPostProcessor org.springframework.boot.SpringBootExceptionReporter=\ dev.caskeleton.bootstrap.runtime.startup.StartupFailureExceptionReporter diff --git a/src/app-bootstrap/src/main/resources/application-local.yml b/src/app-bootstrap/src/main/resources/application-local.yml index 2e8a117..57af34b 100644 --- a/src/app-bootstrap/src/main/resources/application-local.yml +++ b/src/app-bootstrap/src/main/resources/application-local.yml @@ -73,16 +73,9 @@ spring: # 그대로 동작한다. 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 + # authz.role-permissions 는 StudioAuthzEnvironmentPostProcessor 가 심는다. + # YAML Map 키에는 플레이스홀더가 풀리지 않아 ${APP_STUDIO_AUTHOR_ROLE} 를 키로 쓰면 + # 매핑이 통째로 죽는다(모든 Studio 호출 403). 역할 이름은 APP_STUDIO_AUTHOR_ROLE 로 준다. security: oauth2: diff --git a/src/app-bootstrap/src/main/resources/application-prod.yml b/src/app-bootstrap/src/main/resources/application-prod.yml index 9cde678..4911903 100644 --- a/src/app-bootstrap/src/main/resources/application-prod.yml +++ b/src/app-bootstrap/src/main/resources/application-prod.yml @@ -26,16 +26,9 @@ spring: ca-skeleton: persistence: 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 + # authz.role-permissions 는 StudioAuthzEnvironmentPostProcessor 가 심는다. + # YAML Map 키에는 플레이스홀더가 풀리지 않아 ${APP_STUDIO_AUTHOR_ROLE} 를 키로 쓰면 + # 매핑이 통째로 죽는다(모든 Studio 호출 403). 역할 이름은 APP_STUDIO_AUTHOR_ROLE 로 준다. security: # Studio's contract (studio-v1.yaml StudioSession.csrfHeaderName) fixes this header name as a diff --git a/src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotencyExecutor.java b/src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotencyExecutor.java index 25fe1ae..c1fc915 100644 --- a/src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotencyExecutor.java +++ b/src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotencyExecutor.java @@ -105,7 +105,11 @@ public final class IdempotencyExecutor { store.complete(scope, new StoredResponse(codec.serialize(result))); return result; } catch (RuntimeException e) { - store.discard(scope); + try { + store.discard(scope); + } catch (RuntimeException cleanupFailure) { + e.addSuppressed(cleanupFailure); + } throw e; } } diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/GetCurrentStudioPreviewUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/GetCurrentStudioPreviewUseCase.java index e93120f..3daeb0a 100644 --- a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/GetCurrentStudioPreviewUseCase.java +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/GetCurrentStudioPreviewUseCase.java @@ -3,6 +3,7 @@ package dev.caskeleton.application.techlog.studio.service; import dev.caskeleton.application.capability.Idempotency; import dev.caskeleton.application.capability.RepositoryAccess; import dev.caskeleton.application.capability.UseCaseCapability; +import dev.caskeleton.application.security.RequiresPermission; import dev.caskeleton.application.techlog.error.StudioError; import dev.caskeleton.application.techlog.error.StudioException; import dev.caskeleton.application.techlog.studio.model.PreviewDetailView; @@ -23,11 +24,17 @@ import java.util.Objects; * {@code getCurrentStudioPreview}. Preview 상태({@code CURRENT}/{@code STALE}/{@code EXPIRED})는 서버가 * 계산한다(계약 설명, spec §7.3) — 프론트가 여러 값을 조합해 재추론하지 않는다. */ +/* + * 이 클래스가 final 이 아닌 이유: @RequiresPermission 은 Spring AOP 로 강제되고, Boot 는 기본적으로 + * CGLIB 프록시를 쓴다 — final 클래스는 subclass 할 수 없어 빈 생성이 실패한다. 쓰기 use case 들과 + * 같은 이유다(CreateStudioDocumentUseCase 참조). + */ +@RequiresPermission(StudioPermissions.READ) @UseCaseCapability( transactionMode = TransactionMode.READ_ONLY, idempotency = Idempotency.IDEMPOTENT, repositoryAccess = RepositoryAccess.READ_REPOSITORY) -public final class GetCurrentStudioPreviewUseCase +public class GetCurrentStudioPreviewUseCase implements QueryUseCase { private final StudioDocumentLoader documents; diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/GetStudioAssetUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/GetStudioAssetUseCase.java index 76e1d73..26f59c3 100644 --- a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/GetStudioAssetUseCase.java +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/GetStudioAssetUseCase.java @@ -3,6 +3,7 @@ package dev.caskeleton.application.techlog.studio.service; import dev.caskeleton.application.capability.Idempotency; import dev.caskeleton.application.capability.RepositoryAccess; import dev.caskeleton.application.capability.UseCaseCapability; +import dev.caskeleton.application.security.RequiresPermission; import dev.caskeleton.application.techlog.error.StudioError; import dev.caskeleton.application.techlog.error.StudioException; import dev.caskeleton.application.techlog.studio.model.AssetDetailView; @@ -14,11 +15,17 @@ import dev.caskeleton.application.usecase.QueryUseCase; import java.util.Objects; /** {@code getStudioAsset}. */ +/* + * 이 클래스가 final 이 아닌 이유: @RequiresPermission 은 Spring AOP 로 강제되고, Boot 는 기본적으로 + * CGLIB 프록시를 쓴다 — final 클래스는 subclass 할 수 없어 빈 생성이 실패한다. 쓰기 use case 들과 + * 같은 이유다(CreateStudioDocumentUseCase 참조). + */ +@RequiresPermission(StudioPermissions.READ) @UseCaseCapability( transactionMode = TransactionMode.READ_ONLY, idempotency = Idempotency.IDEMPOTENT, repositoryAccess = RepositoryAccess.READ_REPOSITORY) -public final class GetStudioAssetUseCase implements QueryUseCase { +public class GetStudioAssetUseCase implements QueryUseCase { private final AssetRepositoryPort assets; private final TransactionPort transactions; diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/GetStudioDashboardUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/GetStudioDashboardUseCase.java index 93eb9de..222c956 100644 --- a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/GetStudioDashboardUseCase.java +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/GetStudioDashboardUseCase.java @@ -3,6 +3,7 @@ package dev.caskeleton.application.techlog.studio.service; import dev.caskeleton.application.capability.Idempotency; import dev.caskeleton.application.capability.RepositoryAccess; import dev.caskeleton.application.capability.UseCaseCapability; +import dev.caskeleton.application.security.RequiresPermission; import dev.caskeleton.application.techlog.studio.model.DashboardView; import dev.caskeleton.application.techlog.studio.model.NextAction; import dev.caskeleton.application.techlog.studio.port.out.PublicationHistoryQueryPort; @@ -19,12 +20,17 @@ import java.util.Objects; * {@code getStudioDashboard}. {@code nextAction} 을 포함한 모든 workflow 상태는 서버가 계산한다 — 프론트가 여러 endpoint * 를 조합해 재추론하지 않는다(계약 설명). */ +/* + * 이 클래스가 final 이 아닌 이유: @RequiresPermission 은 Spring AOP 로 강제되고, Boot 는 기본적으로 + * CGLIB 프록시를 쓴다 — final 클래스는 subclass 할 수 없어 빈 생성이 실패한다. 쓰기 use case 들과 + * 같은 이유다(CreateStudioDocumentUseCase 참조). + */ +@RequiresPermission(StudioPermissions.READ) @UseCaseCapability( transactionMode = TransactionMode.READ_ONLY, idempotency = Idempotency.IDEMPOTENT, repositoryAccess = RepositoryAccess.READ_REPOSITORY) -public final class GetStudioDashboardUseCase - implements QueryUseCase { +public class GetStudioDashboardUseCase implements QueryUseCase { /** 계약 {@code StudioDashboard} 의 각 목록 maxItems. */ private static final int SECTION_SIZE = 5; diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/GetStudioDocumentUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/GetStudioDocumentUseCase.java index cfcea80..d75d773 100644 --- a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/GetStudioDocumentUseCase.java +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/GetStudioDocumentUseCase.java @@ -3,6 +3,7 @@ package dev.caskeleton.application.techlog.studio.service; import dev.caskeleton.application.capability.Idempotency; import dev.caskeleton.application.capability.RepositoryAccess; import dev.caskeleton.application.capability.UseCaseCapability; +import dev.caskeleton.application.security.RequiresPermission; import dev.caskeleton.application.techlog.error.StudioError; import dev.caskeleton.application.techlog.error.StudioException; import dev.caskeleton.application.techlog.studio.model.WorkingCopyDetailView; @@ -14,11 +15,17 @@ import dev.caskeleton.application.usecase.QueryUseCase; import java.util.Objects; /** {@code getStudioDocument}. */ +/* + * 이 클래스가 final 이 아닌 이유: @RequiresPermission 은 Spring AOP 로 강제되고, Boot 는 기본적으로 + * CGLIB 프록시를 쓴다 — final 클래스는 subclass 할 수 없어 빈 생성이 실패한다. 쓰기 use case 들과 + * 같은 이유다(CreateStudioDocumentUseCase 참조). + */ +@RequiresPermission(StudioPermissions.READ) @UseCaseCapability( transactionMode = TransactionMode.READ_ONLY, idempotency = Idempotency.IDEMPOTENT, repositoryAccess = RepositoryAccess.READ_REPOSITORY) -public final class GetStudioDocumentUseCase +public class GetStudioDocumentUseCase implements QueryUseCase { private final WorkingCopyRepositoryPort workingCopies; diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/GetStudioPublicationSnapshotUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/GetStudioPublicationSnapshotUseCase.java index 8e26722..56edb5d 100644 --- a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/GetStudioPublicationSnapshotUseCase.java +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/GetStudioPublicationSnapshotUseCase.java @@ -3,6 +3,7 @@ package dev.caskeleton.application.techlog.studio.service; import dev.caskeleton.application.capability.Idempotency; import dev.caskeleton.application.capability.RepositoryAccess; import dev.caskeleton.application.capability.UseCaseCapability; +import dev.caskeleton.application.security.RequiresPermission; import dev.caskeleton.application.techlog.error.StudioError; import dev.caskeleton.application.techlog.error.StudioException; import dev.caskeleton.application.techlog.studio.model.PublicationSnapshotView; @@ -14,11 +15,17 @@ import dev.caskeleton.application.usecase.QueryUseCase; import java.util.Objects; /** {@code getStudioPublicationSnapshot}. 현재 source 에서 다시 만들지 않고 게시 시점에 고정된 것을 그대로 돌려준다(ADR-002). */ +/* + * 이 클래스가 final 이 아닌 이유: @RequiresPermission 은 Spring AOP 로 강제되고, Boot 는 기본적으로 + * CGLIB 프록시를 쓴다 — final 클래스는 subclass 할 수 없어 빈 생성이 실패한다. 쓰기 use case 들과 + * 같은 이유다(CreateStudioDocumentUseCase 참조). + */ +@RequiresPermission(StudioPermissions.READ) @UseCaseCapability( transactionMode = TransactionMode.READ_ONLY, idempotency = Idempotency.IDEMPOTENT, repositoryAccess = RepositoryAccess.READ_REPOSITORY) -public final class GetStudioPublicationSnapshotUseCase +public class GetStudioPublicationSnapshotUseCase implements QueryUseCase { private final PublicationHistoryQueryPort history; diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/ListCatalogUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/ListCatalogUseCase.java index f39c031..5b3c24c 100644 --- a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/ListCatalogUseCase.java +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/ListCatalogUseCase.java @@ -3,6 +3,7 @@ package dev.caskeleton.application.techlog.studio.service; import dev.caskeleton.application.capability.Idempotency; import dev.caskeleton.application.capability.RepositoryAccess; import dev.caskeleton.application.capability.UseCaseCapability; +import dev.caskeleton.application.security.RequiresPermission; import dev.caskeleton.application.techlog.error.StudioError; import dev.caskeleton.application.techlog.error.StudioException; import dev.caskeleton.application.techlog.studio.port.out.CatalogQueryPort; @@ -21,11 +22,17 @@ import dev.caskeleton.application.usecase.QueryUseCase; * TransactionPort.inRead(...)}를 직접 호출하도록 정적으로 강제한다. {@link * dev.caskeleton.application.notification.NotificationOperationsSnapshotUseCase}와 같은 패턴이다. */ +/* + * 이 클래스가 final 이 아닌 이유: @RequiresPermission 은 Spring AOP 로 강제되고, Boot 는 기본적으로 + * CGLIB 프록시를 쓴다 — final 클래스는 subclass 할 수 없어 빈 생성이 실패한다. 쓰기 use case 들과 + * 같은 이유다(CreateStudioDocumentUseCase 참조). + */ +@RequiresPermission(StudioPermissions.READ) @UseCaseCapability( transactionMode = TransactionMode.READ_ONLY, idempotency = Idempotency.IDEMPOTENT, repositoryAccess = RepositoryAccess.READ_REPOSITORY) -public final class ListCatalogUseCase implements QueryUseCase { +public class ListCatalogUseCase implements QueryUseCase { private static final int MAX_LIMIT = 100; diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/ListStudioAssetsUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/ListStudioAssetsUseCase.java index a864f66..141c957 100644 --- a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/ListStudioAssetsUseCase.java +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/ListStudioAssetsUseCase.java @@ -3,6 +3,7 @@ package dev.caskeleton.application.techlog.studio.service; import dev.caskeleton.application.capability.Idempotency; import dev.caskeleton.application.capability.RepositoryAccess; import dev.caskeleton.application.capability.UseCaseCapability; +import dev.caskeleton.application.security.RequiresPermission; import dev.caskeleton.application.techlog.error.StudioError; import dev.caskeleton.application.techlog.error.StudioException; import dev.caskeleton.application.techlog.studio.model.AssetPageView; @@ -14,11 +15,17 @@ import dev.caskeleton.application.usecase.QueryUseCase; import java.util.Objects; /** {@code listStudioAssets}. */ +/* + * 이 클래스가 final 이 아닌 이유: @RequiresPermission 은 Spring AOP 로 강제되고, Boot 는 기본적으로 + * CGLIB 프록시를 쓴다 — final 클래스는 subclass 할 수 없어 빈 생성이 실패한다. 쓰기 use case 들과 + * 같은 이유다(CreateStudioDocumentUseCase 참조). + */ +@RequiresPermission(StudioPermissions.READ) @UseCaseCapability( transactionMode = TransactionMode.READ_ONLY, idempotency = Idempotency.IDEMPOTENT, repositoryAccess = RepositoryAccess.READ_REPOSITORY) -public final class ListStudioAssetsUseCase implements QueryUseCase { +public class ListStudioAssetsUseCase implements QueryUseCase { private static final int MAX_LIMIT = 100; private static final int MAX_QUERY_LENGTH = 100; diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/ListStudioDocumentsUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/ListStudioDocumentsUseCase.java index 66b2e25..516a49e 100644 --- a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/ListStudioDocumentsUseCase.java +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/ListStudioDocumentsUseCase.java @@ -3,6 +3,7 @@ package dev.caskeleton.application.techlog.studio.service; import dev.caskeleton.application.capability.Idempotency; import dev.caskeleton.application.capability.RepositoryAccess; import dev.caskeleton.application.capability.UseCaseCapability; +import dev.caskeleton.application.security.RequiresPermission; import dev.caskeleton.application.techlog.error.StudioError; import dev.caskeleton.application.techlog.error.StudioException; import dev.caskeleton.application.techlog.studio.model.DocumentPageView; @@ -14,11 +15,17 @@ import dev.caskeleton.application.usecase.QueryUseCase; import java.util.Objects; /** {@code listStudioDocuments}. */ +/* + * 이 클래스가 final 이 아닌 이유: @RequiresPermission 은 Spring AOP 로 강제되고, Boot 는 기본적으로 + * CGLIB 프록시를 쓴다 — final 클래스는 subclass 할 수 없어 빈 생성이 실패한다. 쓰기 use case 들과 + * 같은 이유다(CreateStudioDocumentUseCase 참조). + */ +@RequiresPermission(StudioPermissions.READ) @UseCaseCapability( transactionMode = TransactionMode.READ_ONLY, idempotency = Idempotency.IDEMPOTENT, repositoryAccess = RepositoryAccess.READ_REPOSITORY) -public final class ListStudioDocumentsUseCase +public class ListStudioDocumentsUseCase implements QueryUseCase { /** 계약 {@code components.parameters.Limit}. */ diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/ListStudioPublicationsUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/ListStudioPublicationsUseCase.java index eb7fbbb..43dbbf4 100644 --- a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/ListStudioPublicationsUseCase.java +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/ListStudioPublicationsUseCase.java @@ -3,6 +3,7 @@ package dev.caskeleton.application.techlog.studio.service; import dev.caskeleton.application.capability.Idempotency; import dev.caskeleton.application.capability.RepositoryAccess; import dev.caskeleton.application.capability.UseCaseCapability; +import dev.caskeleton.application.security.RequiresPermission; import dev.caskeleton.application.techlog.error.StudioError; import dev.caskeleton.application.techlog.error.StudioException; import dev.caskeleton.application.techlog.studio.model.PublicationPageView; @@ -14,11 +15,17 @@ import dev.caskeleton.application.usecase.QueryUseCase; import java.util.Objects; /** {@code listStudioPublications}. */ +/* + * 이 클래스가 final 이 아닌 이유: @RequiresPermission 은 Spring AOP 로 강제되고, Boot 는 기본적으로 + * CGLIB 프록시를 쓴다 — final 클래스는 subclass 할 수 없어 빈 생성이 실패한다. 쓰기 use case 들과 + * 같은 이유다(CreateStudioDocumentUseCase 참조). + */ +@RequiresPermission(StudioPermissions.READ) @UseCaseCapability( transactionMode = TransactionMode.READ_ONLY, idempotency = Idempotency.IDEMPOTENT, repositoryAccess = RepositoryAccess.READ_REPOSITORY) -public final class ListStudioPublicationsUseCase +public class ListStudioPublicationsUseCase implements QueryUseCase { private static final int MAX_LIMIT = 100; diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/StudioPermissions.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/StudioPermissions.java index 3718b96..e98b0d0 100644 --- a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/StudioPermissions.java +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/StudioPermissions.java @@ -9,6 +9,15 @@ package dev.caskeleton.application.techlog.studio.service; */ public final class StudioPermissions { + /** + * 작업본·게시기록·Asset·대시보드 조회가 요구하는 권한. + * + *

    읽기에도 권한이 필요한 이유: Studio 가 읽는 것은 게시 전 초안이다. 인증만 통과하면 누구나 {@code listStudioDocuments} 로 남의 초안 + * 목록을, {@code getStudioDocument} 로 그 본문을 볼 수 있어서는 안 된다 — 계약도 Studio 표면 전체에 권한을 + * 요구한다(securitySchemes.sessionCookie). + */ + public static final String READ = "studio:read"; + /** 편집본 생성·저장·검증·미리보기·게시가 요구하는 권한. */ public static final String WRITE = "studio:write"; From a828b5d9fedbf6defebf2ac4e3a7019eb4f5a134 Mon Sep 17 00:00:00 2001 From: DongHyeonka Date: Thu, 20 Aug 2026 01:19:28 +0900 Subject: [PATCH 06/10] =?UTF-8?q?feat:=20give=20redis-session=20mode=20a?= =?UTF-8?q?=20way=20to=20authenticate=20=E2=80=94=20the=20BFF=20login=20pa?= =?UTF-8?q?th?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit auth-mode=redis-session was unreachable: getStudioSession answered 503 on every call because the CSRF token is null when CsrfFilter never runs, and CsrfFilter only runs in the session branch, which could not be selected because AuthenticationModeCompositionConfig requires a `redisVersionedSessionRepository` / `springSessionRepositoryFilter` pair and only the second existed. Even with the pair present nothing could create a session — there was no login endpoint and no client registration. This is the surface the contract already describes: securitySchemes declares a session cookie plus X-CSRF-TOKEN on mutations, not a bearer token, and SecurityConfig's session branch (cookie CSRF repository, session-fixation migration) plus RedisSessionWebConfig (servlet filter, host-only cookie) were already written for it. The SPA never holds a token; the backend owns the session. - StudioSessionInfrastructureConfig supplies the missing repository under the name the composition validator looks for. @EnableRedisHttp Session is not used because it pins the bean name to sessionRepository. - StudioOidcLoginSuccessHandler converts the OidcUser into an AuthenticatedPrincipal. PrimitiveSessionSecurityContextRepository rejects anything else on save — deliberately, so credentials and framework object graphs never cross the session boundary — and it restores the same type on load. Roles are unioned from realm_access and resource_access exactly as the JWT converter does, so both modes resolve the same set and the studio:read / studio:write mapping behaves identically. - SecurityConfig wires oauth2Login (only when a success handler bean is present, so JWT mode is untouched) and a /logout that invalidates the session. The envelope 401 stays the entry point: an unauthenticated API call must not answer 302, which an XHR cannot follow. The SPA navigates the browser to /oauth2/authorization/{id} instead. Verified in a browser against a real Keycloak realm: /oauth2/authorization/keycloak → Keycloak → callback TECHLOG_SESSION cookie set, httpOnly GET /api/v1/studio/session 200 {authenticated, displayName, roles, csrfToken, csrfHeaderName} POST /api/v1/studio/documents 403 without the CSRF header 201 with it GET /api/v1/studio/documents 200 Also removes the same broken placeholder-in-map-key role mapping from the dev profile that the previous commit fixed in local and prod. Co-Authored-By: Claude Opus 5 (1M context) --- .../inbound/web/auth/SecurityConfig.java | 27 ++++- .../auth/StudioOidcLoginSuccessHandler.java | 104 ++++++++++++++++++ src/app-bootstrap/build.gradle | 11 ++ src/app-bootstrap/gradle.lockfile | 44 +++++--- .../StudioSessionInfrastructureConfig.java | 55 +++++++++ .../src/main/resources/application-dev.yml | 13 +-- 6 files changed, 226 insertions(+), 28 deletions(-) create mode 100644 src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/auth/StudioOidcLoginSuccessHandler.java create mode 100644 src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/techlog/StudioSessionInfrastructureConfig.java diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/auth/SecurityConfig.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/auth/SecurityConfig.java index c5ec53e..f2b4fd9 100644 --- a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/auth/SecurityConfig.java +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/auth/SecurityConfig.java @@ -70,7 +70,10 @@ public class SecurityConfig { AccessDeniedHandler accessDeniedHandler, org.springframework.beans.factory.ObjectProvider sessionSecurityContextRepository, - org.springframework.beans.factory.ObjectProvider restrictedPaths) + org.springframework.beans.factory.ObjectProvider restrictedPaths, + org.springframework.beans.factory.ObjectProvider< + org.springframework.security.web.authentication.AuthenticationSuccessHandler> + loginSuccessHandler) throws Exception { String[] publicPaths = securitySettings.publicPaths().toArray(new String[0]); java.util.List restricted = restrictedPaths.orderedStream().toList(); @@ -137,6 +140,28 @@ public class SecurityConfig { securityContext .securityContextRepository(sessionSecurityContextRepository.getObject()) .requireExplicitSave(false)); + + // BFF 로그인. 세션을 만들 수 있는 유일한 경로다 — 이것이 없으면 auth-mode=redis-session 은 + // 아무도 인증할 수 없는 모드가 된다. SPA 는 401 을 받으면 브라우저를 /oauth2/authorization/{id} + // 로 이동시키고, 콜백이 세션 쿠키를 심은 뒤 SPA 진입점으로 되돌린다. + // + // 진입점은 바꾸지 않는다: API 요청이 302 로 답하면 XHR 이 따라갈 수 없으므로, 미인증 API 호출은 + // 그대로 봉투 401 이어야 한다. 아래 defaultSuccessUrl 대신 주입된 핸들러를 쓰는 이유는 + // OidcUser 를 세션이 담을 수 있는 AuthenticatedPrincipal 로 바꿔야 하기 때문이다. + org.springframework.security.web.authentication.AuthenticationSuccessHandler onSuccess = + loginSuccessHandler.getIfAvailable(); + if (onSuccess != null) { + http.oauth2Login(login -> login.successHandler(onSuccess)); + } + http.logout( + logout -> + logout + .logoutUrl("/logout") + .invalidateHttpSession(true) + .deleteCookies(securitySettings.session().cookieName()) + .logoutSuccessHandler( + (request, response, authentication) -> + response.setStatus(jakarta.servlet.http.HttpServletResponse.SC_NO_CONTENT))); } return http.build(); } diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/auth/StudioOidcLoginSuccessHandler.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/auth/StudioOidcLoginSuccessHandler.java new file mode 100644 index 0000000..e7c131d --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/auth/StudioOidcLoginSuccessHandler.java @@ -0,0 +1,104 @@ +package dev.caskeleton.adapter.inbound.web.techlog.auth; + +import dev.caskeleton.adapter.inbound.web.auth.AuthenticatedPrincipal; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.core.oidc.user.OidcUser; +import org.springframework.security.web.authentication.AuthenticationSuccessHandler; +import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler; + +/** + * OIDC 로그인 결과를 세션이 담을 수 있는 형태로 바꾼다. + * + *

    {@code oauth2Login} 이 만드는 {@code OAuth2AuthenticationToken} 의 principal 은 {@code OidcUser} 다. + * 그런데 {@code PrimitiveSessionSecurityContextRepository#saveContext} 는 principal 이 {@link + * AuthenticatedPrincipal} 이 아니면 거부한다 — 자격증명·토큰·프레임워크 객체 그래프가 세션 직렬화 경계를 + * 넘지 못하게 하는 의도적인 제약이다. 그래서 로그인 직후 여기서 claim 만 뽑아 {@code AuthenticatedPrincipal} + * 로 갈아끼운다. 세션에 남는 것은 sub·email·role 뿐이고 ID/Access 토큰은 남지 않는다. + * + *

    역할 추출은 {@code JwtToAuthenticatedPrincipalConverter} 와 같은 규칙이다 — Keycloak 의 {@code + * realm_access.roles} 와 {@code resource_access[*].roles} 를 합집합으로 본다. 두 경로(JWT 검증과 세션 로그인)가 + * 같은 역할 집합을 만들어야 {@code studio:read}/{@code studio:write} 매핑이 모드와 무관하게 동일하게 걸린다. + */ +@Component +@ConditionalOnProperty(name = "ca-skeleton.security.auth-mode", havingValue = "redis-session") +public class StudioOidcLoginSuccessHandler implements AuthenticationSuccessHandler { + + private final SimpleUrlAuthenticationSuccessHandler redirect = + new SimpleUrlAuthenticationSuccessHandler(); + + public StudioOidcLoginSuccessHandler( + @Value("${app.studio.post-login-redirect:/}") String defaultTargetUrl) { + redirect.setDefaultTargetUrl(defaultTargetUrl); + // SPA 가 라우팅을 소유한다. 프레임워크의 SavedRequest 는 SecurityConfig 가 이미 꺼두었으므로 + // 로그인 후에는 항상 SPA 진입점으로 보내고, 원래 가려던 화면 복원은 SPA 가 한다. + redirect.setAlwaysUseDefaultTargetUrl(true); + } + + @Override + public void onAuthenticationSuccess( + HttpServletRequest request, HttpServletResponse response, Authentication authentication) + throws IOException, ServletException { + if (authentication.getPrincipal() instanceof OidcUser user) { + Set roles = extractRoles(user); + AuthenticatedPrincipal principal = + new AuthenticatedPrincipal(user.getSubject(), user.getEmail(), roles); + Collection authorities = + roles.stream() + .map(r -> (GrantedAuthority) new SimpleGrantedAuthority("ROLE_" + r.toUpperCase(Locale.ROOT))) + .collect(java.util.stream.Collectors.toCollection(ArrayList::new)); + SecurityContext context = SecurityContextHolder.createEmptyContext(); + context.setAuthentication( + UsernamePasswordAuthenticationToken.authenticated(principal, null, authorities)); + SecurityContextHolder.setContext(context); + // requireExplicitSave(false) 이므로 SecurityContextHolderFilter 가 응답 커밋 시 저장한다. + authentication = context.getAuthentication(); + } + redirect.onAuthenticationSuccess(request, response, authentication); + } + + private static Set extractRoles(OidcUser user) { + Set roles = new HashSet<>(); + addRoles(roles, user.getClaimAsMap("realm_access")); + Map resourceAccess = user.getClaimAsMap("resource_access"); + if (resourceAccess != null) { + for (Object client : resourceAccess.values()) { + if (client instanceof Map map) { + addRoles(roles, map); + } + } + } + List generic = user.getClaimAsStringList("roles"); + if (generic != null) { + roles.addAll(generic); + } + return Set.copyOf(roles); + } + + private static void addRoles(Set sink, Map holder) { + if (holder == null) { + return; + } + if (holder.get("roles") instanceof Collection values) { + values.forEach(value -> sink.add(String.valueOf(value))); + } + } +} diff --git a/src/app-bootstrap/build.gradle b/src/app-bootstrap/build.gradle index 5d45c8b..09d19b3 100644 --- a/src/app-bootstrap/build.gradle +++ b/src/app-bootstrap/build.gradle @@ -76,6 +76,17 @@ dependencies { // Security types for ManagementSecurityConfig (not reachable via adapter-web's implementation dep). See README. implementation 'org.springframework.boot:spring-boot-starter-security' + // Redis-backed HTTP session for auth-mode=redis-session (the BFF surface the Studio contract + // declares: sessionCookie TECHLOG_SESSION + X-CSRF-TOKEN). AuthenticationModeCompositionConfig + // requires the `redisVersionedSessionRepository` / `springSessionRepositoryFilter` pair once + // that mode is active; StudioSessionInfrastructureConfig supplies the first, Spring Session's + // SpringHttpSessionConfiguration the second. + // OIDC Authorization Code 로그인 자동설정(ClientRegistrationRepository 등). 기존의 + // spring-security-oauth2-client 는 라이브러리만 주고 Boot 자동설정은 스타터가 준다. + implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' + implementation 'org.springframework.session:spring-session-data-redis' + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + // test-only: ArchUnit needs actuator types to verify the health-shape guardrail. See README. testImplementation 'org.springframework.boot:spring-boot-starter-actuator' // test-only: @WithMockUser for the actuator security authorization tests. See README. diff --git a/src/app-bootstrap/gradle.lockfile b/src/app-bootstrap/gradle.lockfile index be9cf56..2a06756 100644 --- a/src/app-bootstrap/gradle.lockfile +++ b/src/app-bootstrap/gradle.lockfile @@ -99,7 +99,7 @@ io.grpc:grpc-protobuf:1.68.1=conditionalTransportTestRuntimeClasspath io.grpc:grpc-services:1.68.1=conditionalTransportTestRuntimeClasspath io.grpc:grpc-stub:1.68.1=conditionalTransportTestRuntimeClasspath io.grpc:grpc-util:1.68.1=conditionalTransportTestRuntimeClasspath -io.lettuce:lettuce-core:6.8.1.RELEASE=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +io.lettuce:lettuce-core:6.8.1.RELEASE=compileClasspath,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,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath io.micrometer:micrometer-core:1.16.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath @@ -108,27 +108,27 @@ io.micrometer:micrometer-observation:1.16.0=compileClasspath,conditionalTranspor io.micrometer:micrometer-registry-prometheus:1.16.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath io.micrometer:micrometer-tracing-bridge-otel:1.6.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath io.micrometer:micrometer-tracing:1.6.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -io.netty:netty-buffer:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -io.netty:netty-codec-base:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +io.netty:netty-buffer:4.2.17.Final=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +io.netty:netty-codec-base:4.2.17.Final=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath io.netty:netty-codec-classes-quic:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath io.netty:netty-codec-compression:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -io.netty:netty-codec-dns:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +io.netty:netty-codec-dns:4.2.17.Final=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath io.netty:netty-codec-http2:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath io.netty:netty-codec-http3:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath io.netty:netty-codec-http:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath io.netty:netty-codec-native-quic:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath io.netty:netty-codec-socks:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -io.netty:netty-common:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +io.netty:netty-common:4.2.17.Final=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath io.netty:netty-handler-proxy:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -io.netty:netty-handler:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +io.netty:netty-handler:4.2.17.Final=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath io.netty:netty-resolver-dns-classes-macos:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath io.netty:netty-resolver-dns-native-macos:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -io.netty:netty-resolver-dns:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -io.netty:netty-resolver:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +io.netty:netty-resolver-dns:4.2.17.Final=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +io.netty:netty-resolver:4.2.17.Final=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath io.netty:netty-transport-classes-epoll:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath io.netty:netty-transport-native-epoll:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -io.netty:netty-transport-native-unix-common:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -io.netty:netty-transport:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +io.netty:netty-transport-native-unix-common:4.2.17.Final=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +io.netty:netty-transport:4.2.17.Final=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath io.opentelemetry.semconv:opentelemetry-semconv:1.37.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath io.opentelemetry:opentelemetry-api:1.55.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath io.opentelemetry:opentelemetry-common:1.55.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath @@ -147,7 +147,7 @@ io.opentelemetry:opentelemetry-sdk:1.55.0=compileClasspath,productionRuntimeClas io.perfmark:perfmark-api:0.27.0=conditionalTransportTestRuntimeClasspath io.projectreactor.netty:reactor-netty-core:1.3.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath io.projectreactor.netty:reactor-netty-http:1.3.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -io.projectreactor:reactor-core:3.8.0=conditionalTransportTestRuntimeClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +io.projectreactor:reactor-core:3.8.0=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath io.prometheus:prometheus-metrics-config:1.4.3=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath io.prometheus:prometheus-metrics-core:1.4.3=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath io.prometheus:prometheus-metrics-exposition-formats:1.4.3=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath @@ -268,7 +268,7 @@ org.ow2.asm:asm:9.10.1=spotbugs org.ow2.asm:asm:9.7.1=compileClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.pcollections:pcollections:4.0.1=annotationProcessor,conditionalTransportTestAnnotationProcessor,functionalTestAnnotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor org.postgresql:postgresql:42.7.8=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -org.reactivestreams:reactive-streams:1.0.4=conditionalTransportTestRuntimeClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.reactivestreams:reactive-streams:1.0.4=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.reflections:reflections:0.10.2=checkstyle org.rnorth.duct-tape:duct-tape:1.0.8=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.skyscreamer:jsonassert:1.5.3=functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath @@ -281,9 +281,10 @@ org.springframework.boot:spring-boot-actuator-autoconfigure:4.0.0=compileClasspa 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,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-configuration-processor:4.0.0=annotationProcessor -org.springframework.boot:spring-boot-data-commons:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-data-commons:4.0.0=compileClasspath,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:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-data-redis:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-flyway:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath 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 @@ -297,15 +298,18 @@ org.springframework.boot:spring-boot-jpa-test:4.0.0=sampleOffTestCompileClasspat org.springframework.boot:spring-boot-jpa:4.0.0=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-netty:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,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=functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-resttestclient:4.0.0=functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-security-oauth2-client:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-security-oauth2-resource-server:4.0.0=functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-security:4.0.0=compileClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-servlet:4.0.0=conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-sql:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-actuator:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-data-jpa:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-data-redis:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-flyway:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-graphql:4.0.0=conditionalTransportTestRuntimeClasspath org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath @@ -314,6 +318,7 @@ org.springframework.boot:spring-boot-starter-jdbc:4.0.0=productionRuntimeClasspa 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,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-micrometer-metrics:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-oauth2-client:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-oauth2-resource-server:4.0.0=functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-security:4.0.0=compileClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-test:4.0.0=functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath @@ -336,8 +341,10 @@ org.springframework.boot:spring-boot-webmvc:4.0.0=conditionalTransportTestRuntim org.springframework.boot:spring-boot-websocket:4.0.0=conditionalTransportTestRuntimeClasspath org.springframework.boot:spring-boot:4.0.0=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.cloud:spring-cloud-context:4.1.4=sampleOffTestCompileClasspath,testCompileClasspath -org.springframework.data:spring-data-commons:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.data:spring-data-commons:4.0.0=compileClasspath,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-keyvalue:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.data:spring-data-redis:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.graphql:spring-graphql:2.0.0=conditionalTransportTestRuntimeClasspath org.springframework.integration:spring-integration-core:7.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.integration:spring-integration-jdbc:7.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath @@ -346,20 +353,23 @@ org.springframework.security:spring-security-core:7.0.0=compileClasspath,functio org.springframework.security:spring-security-crypto:7.0.0=compileClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.security:spring-security-oauth2-client:7.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.security:spring-security-oauth2-core:7.0.0=compileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.security:spring-security-oauth2-jose:7.0.0=functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.springframework.security:spring-security-oauth2-jose:7.0.0=compileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.security:spring-security-oauth2-resource-server:7.0.0=functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath org.springframework.security:spring-security-test:7.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.security:spring-security-web:7.0.0=compileClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.session:spring-session-core:4.0.0=functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.springframework.session:spring-session-core:4.0.0=compileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.session:spring-session-data-redis:4.0.0=compileClasspath,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-beans:7.0.1=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-context-support:7.0.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework:spring-context:7.0.1=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework:spring-core:7.0.1=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework:spring-expression:7.0.1=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework:spring-jdbc:7.0.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework:spring-messaging:7.0.1=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework:spring-orm:7.0.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-oxm:7.0.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework:spring-test:7.0.1=functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework:spring-tx:7.0.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework:spring-web:7.0.1=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath @@ -374,7 +384,7 @@ org.testcontainers:testcontainers:2.0.2=sampleOffTestCompileClasspath,sampleOffT org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs org.xmlunit:xmlunit-core:2.10.4=functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.yaml:snakeyaml:2.5=compileClasspath,conditionalTransportTestCompileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -redis.clients.authentication:redis-authx-core:0.1.1-beta2=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +redis.clients.authentication:redis-authx-core:0.1.1-beta2=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath tools.jackson.core:jackson-core:3.0.2=conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath tools.jackson.core:jackson-databind:3.0.2=conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath tools.jackson:jackson-bom:3.0.2=conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/techlog/StudioSessionInfrastructureConfig.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/techlog/StudioSessionInfrastructureConfig.java new file mode 100644 index 0000000..66c1960 --- /dev/null +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/techlog/StudioSessionInfrastructureConfig.java @@ -0,0 +1,55 @@ +package dev.caskeleton.bootstrap.techlog; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.StringRedisSerializer; +import org.springframework.session.data.redis.RedisSessionRepository; + +/** + * {@code auth-mode=redis-session} 의 세션 저장소. + * + *

    이 모드는 Studio 계약이 선언한 표면이다 — {@code securitySchemes.sessionCookie} 는 세션 쿠키를, mutation + * 은 추가로 {@code X-CSRF-TOKEN} 헤더를 요구한다. SPA 가 토큰을 직접 들지 않고 백엔드가 세션을 소유하는 BFF + * 구성이며, {@code SecurityConfig} 의 {@code REDIS_SESSION} 분기(CSRF 쿠키 저장소 + 세션 고정 방지)와 {@code + * RedisSessionWebConfig}(서블릿 세션 필터 + host-only 쿠키)는 이미 그 전제로 쓰여 있었다. + * + *

    빠져 있던 조각은 저장소 하나뿐이다. {@code AuthenticationModeCompositionConfig} 가 이 모드에서 + * {@code redisVersionedSessionRepository} 와 {@code springSessionRepositoryFilter} 를 이름으로 + * 요구하는데, 뒤의 것은 {@code RedisSessionWebConfig} 의 {@code @EnableSpringHttpSession} 이 이미 등록하고 + * 있었고 앞의 것이 어디에도 없었다. 그래서 {@code getStudioSession} 이 항상 503 이었다. + */ +@Configuration(proxyBeanMethods = false) +@ConditionalOnProperty(name = "ca-skeleton.security.auth-mode", havingValue = "redis-session") +public class StudioSessionInfrastructureConfig { + + /** + * 이름이 계약이다 — {@code AuthenticationModeCompositionConfig#validate} 가 이 문자열을 찾는다. 이름을 + * 바꾸면 부팅이 "Redis Session repository/filter is incomplete" 로 실패한다. + * + *

    {@code @EnableRedisHttpSession} 을 쓰지 않는 이유도 같다 — 그 애노테이션은 빈 이름을 + * {@code sessionRepository} 로 고정한다. + */ + @Bean + public RedisSessionRepository redisVersionedSessionRepository( + RedisConnectionFactory connectionFactory) { + return new RedisSessionRepository(sessionRedisTemplate(connectionFactory)); + } + + /** + * 키는 문자열로, 값은 기본 JDK 직렬화로 둔다. 세션에 들어가는 것은 {@code + * PrimitiveSessionSecurityContextRepository} 가 만든 원시 스냅샷뿐이라(자격증명·토큰·프레임워크 객체가 + * 직렬화 경계를 넘지 않는다) 값 직렬화기를 따로 좁힐 필요가 없다. + */ + private static RedisTemplate sessionRedisTemplate( + RedisConnectionFactory connectionFactory) { + RedisTemplate template = new RedisTemplate<>(); + template.setConnectionFactory(connectionFactory); + template.setKeySerializer(new StringRedisSerializer()); + template.setHashKeySerializer(new StringRedisSerializer()); + template.afterPropertiesSet(); + return template; + } +} diff --git a/src/app-bootstrap/src/main/resources/application-dev.yml b/src/app-bootstrap/src/main/resources/application-dev.yml index b5d7378..880c6da 100644 --- a/src/app-bootstrap/src/main/resources/application-dev.yml +++ b/src/app-bootstrap/src/main/resources/application-dev.yml @@ -22,16 +22,9 @@ spring: ca-skeleton: persistence: 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 + # authz.role-permissions 는 StudioAuthzEnvironmentPostProcessor 가 심는다. + # YAML Map 키에는 플레이스홀더가 풀리지 않아 ${APP_STUDIO_AUTHOR_ROLE} 를 키로 쓰면 + # 매핑이 통째로 죽는다(모든 Studio 호출 403). 역할 이름은 APP_STUDIO_AUTHOR_ROLE 로 준다. security: # Studio's contract (studio-v1.yaml StudioSession.csrfHeaderName) fixes this header name as a From c8a891c407330fa1b889909ba823a0b1ecb369b9 Mon Sep 17 00:00:00 2001 From: DongHyeonka Date: Thu, 20 Aug 2026 01:49:05 +0900 Subject: [PATCH 07/10] fix: honour the contract's nullable fields and its error codes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two contract mismatches, both found by driving the API and both invisible from inside the repository because nothing compares the wire to the spec. Nullable-but-required. The contract says required means "the key is present", not "the value is set" — WorkingCopyInputBase spells it out: "불완전한 초안도 저장할 수 있어야 하므로 필드는 required이되 빈 값과 null을 허용한다". The generator moves `required` straight to @NotNull, so topicId, projectId, lastVerifiedOn, verifiedOn, decidedOn, decisionStatus and questionStatus all became non-null, and saving a partial draft failed: {"projectId": null} → 400 NOT_NULL "Required value is missing" prepareStudioCodegenSpec already derives a codegen-only copy of the spec, so the relaxation happens there — 33 properties leave `required` in that copy and the canonical file is untouched, which matters because the frontend reads the same file and its reading is the correct one. Value constraints stay: title still carries @NotNull @Size(max = 120). all-null / omitted / empty slug 201 title 121 chars 422 slug "Bad Slug!" 422 Error codes. Body validation fell through to the template's handler and answered 400 VALIDATION_FAILED, a code the Studio contract does not declare (it knows REQUEST_VALIDATION_FAILED and DOCUMENT_VALIDATION_ FAILED); denials answered AUTHZ_INSUFFICIENT_PERMISSION where the contract assigns STUDIO_ACCESS_DENIED to 403. The frontend validates the envelope's code against an enum, so an undeclared code breaks parsing rather than surfacing as the error it is. Both now map in StudioExceptionHandler, which is already scoped to the techlog package so fileserver and healthcheck keep their existing shapes. body validation 422 REQUEST_VALIDATION_FAILED denial 403 STUDIO_ACCESS_DENIED Co-Authored-By: Claude Opus 5 (1M context) --- src/adapter/inbound/web/build.gradle | 41 ++++++++++++++++++ .../inbound/web/auth/SecurityConfig.java | 3 +- .../web/techlog/StudioExceptionHandler.java | 43 +++++++++++++++++++ .../auth/StudioOidcLoginSuccessHandler.java | 21 +++++---- .../StudioSessionInfrastructureConfig.java | 29 ++++++------- 5 files changed, 112 insertions(+), 25 deletions(-) diff --git a/src/adapter/inbound/web/build.gradle b/src/adapter/inbound/web/build.gradle index 9bdc136..f5e3012 100644 --- a/src/adapter/inbound/web/build.gradle +++ b/src/adapter/inbound/web/build.gradle @@ -243,6 +243,47 @@ tasks.register('prepareStudioCodegenSpec') { } collapseNullableOneOf(doc) + // (4b) `type: [X, "null"]` 인 필드는 required 목록에서 뺀다. + // + // 계약이 이 필드들을 required 로 두는 뜻은 "키가 있어야 한다"이지 "값이 있어야 한다"가 + // 아니다 — WorkingCopyInputBase 의 주석이 그렇게 못박고 있다("불완전한 초안도 저장할 수 + // 있어야 하므로 필드는 required 이되 빈 값과 null 을 허용한다"). 그런데 생성기는 required + // 를 그대로 @NotNull 로 옮긴다. 그래서 topicId/projectId/lastVerifiedOn/verifiedOn/ + // decidedOn/decisionStatus/questionStatus 가 전부 non-null 강제가 되고, 초안 저장이 + // 400 NOT_NULL 로 거부됐다(실측: {"projectId": null} → NOT_NULL "Required value is missing"). + // + // 원본 계약은 건드리지 않는다 — 프론트엔드가 같은 파일을 읽고, 그쪽 해석은 옳다. 코드젠 + // 사본에서만 required 를 벗겨 @NotNull 이 붙지 않게 한다. 값 제약(형식·길이·enum)은 + // 그대로 남는다. + int[] relaxed = [0] + def relaxNullableRequired + relaxNullableRequired = { Object node -> + if (node instanceof Map) { + def props = node.get('properties') + def required = node.get('required') + if (props instanceof Map && required instanceof List) { + def drop = [] + props.each { Object name, Object schema -> + if (!(schema instanceof Map)) return + def type = schema.get('type') + if (type instanceof List && type.contains('null') && required.contains(name)) { + drop << name + } + } + if (!drop.isEmpty()) { + required.removeAll(drop) + relaxed[0] += drop.size() + if (required.isEmpty()) node.remove('required') + } + } + new ArrayList(node.values()).each { relaxNullableRequired(it) } + } else if (node instanceof List) { + node.each { relaxNullableRequired(it) } + } + } + relaxNullableRequired(doc) + logger.lifecycle("prepareStudioCodegenSpec: nullable required 해제 ${relaxed[0]}건") + // (1) x-implements 주입 + union 목록 수집 def unions = [:] schemas.each { String name, Object schema -> diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/auth/SecurityConfig.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/auth/SecurityConfig.java index f2b4fd9..240ef01 100644 --- a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/auth/SecurityConfig.java +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/auth/SecurityConfig.java @@ -161,7 +161,8 @@ public class SecurityConfig { .deleteCookies(securitySettings.session().cookieName()) .logoutSuccessHandler( (request, response, authentication) -> - response.setStatus(jakarta.servlet.http.HttpServletResponse.SC_NO_CONTENT))); + response.setStatus( + jakarta.servlet.http.HttpServletResponse.SC_NO_CONTENT))); } return http.build(); } diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/StudioExceptionHandler.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/StudioExceptionHandler.java index bd05a70..5c10a4f 100644 --- a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/StudioExceptionHandler.java +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/StudioExceptionHandler.java @@ -6,11 +6,14 @@ import dev.caskeleton.application.techlog.error.StudioException; import dev.caskeleton.shared.response.Envelope; import java.util.List; import java.util.Map; +import java.util.stream.Collectors; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.core.Ordered; import org.springframework.core.annotation.Order; import org.springframework.http.ResponseEntity; +import org.springframework.security.authorization.AuthorizationDeniedException; +import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.MissingServletRequestParameterException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; @@ -76,6 +79,46 @@ public class StudioExceptionHandler { return requestValidationFailed(ex.getName(), "Parameter value is invalid"); } + /** + * 요청 본문 bean validation 실패(예: {@code title} 120자 초과). {@code GlobalExceptionHandler}도 이 예외를 처리하지만 + * 400 {@code OperationalError.VALIDATION_FAILED}를 낸다 — Studio 계약에 없는 코드이고 (계약이 아는 것은 {@code + * REQUEST_VALIDATION_FAILED}와 {@code DOCUMENT_VALIDATION_FAILED}뿐이다), 상태도 계약이 본문 검증 실패에 배정한 422가 + * 아니다. 프론트엔드는 봉투의 {@code code}를 enum으로 검증하므로 계약 밖 코드는 응답 파싱 자체를 깨뜨린다. studio 스코프에서 계약 코드로 옮긴다. + */ + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity> handleBodyValidation(MethodArgumentNotValidException ex) { + List> fieldErrors = + ex.getBindingResult().getFieldErrors().stream() + .map( + error -> + Map.of( + "path", + "/" + error.getField(), + "message", + error.getDefaultMessage() == null + ? "Value is invalid" + : error.getDefaultMessage())) + .collect(Collectors.toList()); + return ErrorResponseFactory.envelope( + StudioError.REQUEST_VALIDATION_FAILED, + StudioClientSafeMessages.forError(StudioError.REQUEST_VALIDATION_FAILED), + Map.of("fieldErrors", fieldErrors)); + } + + /** + * 권한 부족. 스켈레톤의 분류기는 {@code AUTHZ_INSUFFICIENT_PERMISSION}을 내지만 계약이 403에 배정한 코드는 {@code + * STUDIO_ACCESS_DENIED}다({@code responses.AccessDenied.x-error-codes}). 상태는 그대로 403이고 코드만 계약 쪽으로 + * 옮긴다. + */ + @ExceptionHandler(AuthorizationDeniedException.class) + public ResponseEntity> handleAccessDenied(AuthorizationDeniedException ex) { + log.warn("studio access denied: {}", ex.getMessage()); + return ErrorResponseFactory.envelope( + StudioError.STUDIO_ACCESS_DENIED, + StudioClientSafeMessages.forError(StudioError.STUDIO_ACCESS_DENIED), + null); + } + /** * {@code details}를 계약의 {@code ValidationErrorDetails}({@code fieldErrors: [{path, message}]}) 모양에 * 맞춰 싣는다 — 자유형 {@code Object}로 아무 모양이나 실으면 계약의 {@code oneOf} 제약을 위반한다. diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/auth/StudioOidcLoginSuccessHandler.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/auth/StudioOidcLoginSuccessHandler.java index e7c131d..0d9c5ad 100644 --- a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/auth/StudioOidcLoginSuccessHandler.java +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/auth/StudioOidcLoginSuccessHandler.java @@ -1,9 +1,6 @@ package dev.caskeleton.adapter.inbound.web.techlog.auth; import dev.caskeleton.adapter.inbound.web.auth.AuthenticatedPrincipal; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.stereotype.Component; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; @@ -15,6 +12,8 @@ import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Set; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.GrantedAuthority; @@ -24,19 +23,20 @@ import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.oauth2.core.oidc.user.OidcUser; import org.springframework.security.web.authentication.AuthenticationSuccessHandler; import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler; +import org.springframework.stereotype.Component; /** * OIDC 로그인 결과를 세션이 담을 수 있는 형태로 바꾼다. * *

    {@code oauth2Login} 이 만드는 {@code OAuth2AuthenticationToken} 의 principal 은 {@code OidcUser} 다. * 그런데 {@code PrimitiveSessionSecurityContextRepository#saveContext} 는 principal 이 {@link - * AuthenticatedPrincipal} 이 아니면 거부한다 — 자격증명·토큰·프레임워크 객체 그래프가 세션 직렬화 경계를 - * 넘지 못하게 하는 의도적인 제약이다. 그래서 로그인 직후 여기서 claim 만 뽑아 {@code AuthenticatedPrincipal} - * 로 갈아끼운다. 세션에 남는 것은 sub·email·role 뿐이고 ID/Access 토큰은 남지 않는다. + * AuthenticatedPrincipal} 이 아니면 거부한다 — 자격증명·토큰·프레임워크 객체 그래프가 세션 직렬화 경계를 넘지 못하게 하는 의도적인 제약이다. 그래서 + * 로그인 직후 여기서 claim 만 뽑아 {@code AuthenticatedPrincipal} 로 갈아끼운다. 세션에 남는 것은 sub·email·role 뿐이고 + * ID/Access 토큰은 남지 않는다. * *

    역할 추출은 {@code JwtToAuthenticatedPrincipalConverter} 와 같은 규칙이다 — Keycloak 의 {@code - * realm_access.roles} 와 {@code resource_access[*].roles} 를 합집합으로 본다. 두 경로(JWT 검증과 세션 로그인)가 - * 같은 역할 집합을 만들어야 {@code studio:read}/{@code studio:write} 매핑이 모드와 무관하게 동일하게 걸린다. + * realm_access.roles} 와 {@code resource_access[*].roles} 를 합집합으로 본다. 두 경로(JWT 검증과 세션 로그인)가 같은 역할 + * 집합을 만들어야 {@code studio:read}/{@code studio:write} 매핑이 모드와 무관하게 동일하게 걸린다. */ @Component @ConditionalOnProperty(name = "ca-skeleton.security.auth-mode", havingValue = "redis-session") @@ -63,7 +63,10 @@ public class StudioOidcLoginSuccessHandler implements AuthenticationSuccessHandl new AuthenticatedPrincipal(user.getSubject(), user.getEmail(), roles); Collection authorities = roles.stream() - .map(r -> (GrantedAuthority) new SimpleGrantedAuthority("ROLE_" + r.toUpperCase(Locale.ROOT))) + .map( + r -> + (GrantedAuthority) + new SimpleGrantedAuthority("ROLE_" + r.toUpperCase(Locale.ROOT))) .collect(java.util.stream.Collectors.toCollection(ArrayList::new)); SecurityContext context = SecurityContextHolder.createEmptyContext(); context.setAuthentication( diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/techlog/StudioSessionInfrastructureConfig.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/techlog/StudioSessionInfrastructureConfig.java index 66c1960..c57c6c2 100644 --- a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/techlog/StudioSessionInfrastructureConfig.java +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/techlog/StudioSessionInfrastructureConfig.java @@ -11,26 +11,26 @@ import org.springframework.session.data.redis.RedisSessionRepository; /** * {@code auth-mode=redis-session} 의 세션 저장소. * - *

    이 모드는 Studio 계약이 선언한 표면이다 — {@code securitySchemes.sessionCookie} 는 세션 쿠키를, mutation - * 은 추가로 {@code X-CSRF-TOKEN} 헤더를 요구한다. SPA 가 토큰을 직접 들지 않고 백엔드가 세션을 소유하는 BFF - * 구성이며, {@code SecurityConfig} 의 {@code REDIS_SESSION} 분기(CSRF 쿠키 저장소 + 세션 고정 방지)와 {@code - * RedisSessionWebConfig}(서블릿 세션 필터 + host-only 쿠키)는 이미 그 전제로 쓰여 있었다. + *

    이 모드는 Studio 계약이 선언한 표면이다 — {@code securitySchemes.sessionCookie} 는 세션 쿠키를, mutation 은 추가로 + * {@code X-CSRF-TOKEN} 헤더를 요구한다. SPA 가 토큰을 직접 들지 않고 백엔드가 세션을 소유하는 BFF 구성이며, {@code SecurityConfig} + * 의 {@code REDIS_SESSION} 분기(CSRF 쿠키 저장소 + 세션 고정 방지)와 {@code RedisSessionWebConfig}(서블릿 세션 필터 + + * host-only 쿠키)는 이미 그 전제로 쓰여 있었다. * - *

    빠져 있던 조각은 저장소 하나뿐이다. {@code AuthenticationModeCompositionConfig} 가 이 모드에서 - * {@code redisVersionedSessionRepository} 와 {@code springSessionRepositoryFilter} 를 이름으로 - * 요구하는데, 뒤의 것은 {@code RedisSessionWebConfig} 의 {@code @EnableSpringHttpSession} 이 이미 등록하고 - * 있었고 앞의 것이 어디에도 없었다. 그래서 {@code getStudioSession} 이 항상 503 이었다. + *

    빠져 있던 조각은 저장소 하나뿐이다. {@code AuthenticationModeCompositionConfig} 가 이 모드에서 {@code + * redisVersionedSessionRepository} 와 {@code springSessionRepositoryFilter} 를 이름으로 요구하는데, + * 뒤의 것은 {@code RedisSessionWebConfig} 의 {@code @EnableSpringHttpSession} 이 이미 등록하고 있었고 앞의 것이 어디에도 + * 없었다. 그래서 {@code getStudioSession} 이 항상 503 이었다. */ @Configuration(proxyBeanMethods = false) @ConditionalOnProperty(name = "ca-skeleton.security.auth-mode", havingValue = "redis-session") public class StudioSessionInfrastructureConfig { /** - * 이름이 계약이다 — {@code AuthenticationModeCompositionConfig#validate} 가 이 문자열을 찾는다. 이름을 - * 바꾸면 부팅이 "Redis Session repository/filter is incomplete" 로 실패한다. + * 이름이 계약이다 — {@code AuthenticationModeCompositionConfig#validate} 가 이 문자열을 찾는다. 이름을 바꾸면 부팅이 + * "Redis Session repository/filter is incomplete" 로 실패한다. * - *

    {@code @EnableRedisHttpSession} 을 쓰지 않는 이유도 같다 — 그 애노테이션은 빈 이름을 - * {@code sessionRepository} 로 고정한다. + *

    {@code @EnableRedisHttpSession} 을 쓰지 않는 이유도 같다 — 그 애노테이션은 빈 이름을 {@code sessionRepository} 로 + * 고정한다. */ @Bean public RedisSessionRepository redisVersionedSessionRepository( @@ -39,9 +39,8 @@ public class StudioSessionInfrastructureConfig { } /** - * 키는 문자열로, 값은 기본 JDK 직렬화로 둔다. 세션에 들어가는 것은 {@code - * PrimitiveSessionSecurityContextRepository} 가 만든 원시 스냅샷뿐이라(자격증명·토큰·프레임워크 객체가 - * 직렬화 경계를 넘지 않는다) 값 직렬화기를 따로 좁힐 필요가 없다. + * 키는 문자열로, 값은 기본 JDK 직렬화로 둔다. 세션에 들어가는 것은 {@code PrimitiveSessionSecurityContextRepository} 가 만든 + * 원시 스냅샷뿐이라(자격증명·토큰·프레임워크 객체가 직렬화 경계를 넘지 않는다) 값 직렬화기를 따로 좁힐 필요가 없다. */ private static RedisTemplate sessionRedisTemplate( RedisConnectionFactory connectionFactory) { From 743fee3907dd030996bab0372064abbee57c6444 Mon Sep 17 00:00:00 2001 From: DongHyeonka Date: Thu, 20 Aug 2026 10:36:47 +0900 Subject: [PATCH 08/10] fix: close the last three local checklist items MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Duplicate relations. Connecting the same target twice saved without a word: the contract carries no uniqueItems on relations (only maxItems 20) and the validator checked order uniqueness but not target. The document then renders the same row twice publicly, and removing one leaves the other behind — "삭제했는데 그대로". Rejected now, alongside the existing order check. two distinct targets 201 same target twice 422 REQUEST_VALIDATION_FAILED The prod DDL guard ran too late. JpaSchemaSafetyValidator was a SmartInitializingSingleton, which fires after every singleton exists — including entityManagerFactory, which Hibernate builds by applying ddl-auto. Booting prod with ddl-auto=update logged "Initialized JPA EntityManagerFactory" first and the PROFILE_MISMATCH second, with the tables Hibernate created in between still in the schema. The guard stopped traffic but not schema mutation, so a misconfigured deploy had already changed the production database by the time it refused to start. It is a BeanFactoryPostProcessor now, before any bean is instantiated. fs_* tables dropped, prod booted with ddl-auto=update exit 71, no EntityManagerFactory line, 0 tables created Object storage inside a database transaction. UploadStudioAssetUseCase called binaries.store from inside inWrite, holding a connection and its locks for the length of a network round-trip — a slow storage backend becomes connection-pool exhaustion. It bought nothing: storage does not join the transaction, so a failed commit leaves the bytes written either way. Storage now happens first and the database write is a short transaction; a failed write deletes the object it just uploaded, and a failed delete is attached with addSuppressed rather than replacing the error the caller needs to see. Full build passes apart from one fileserver flake (LocalPersistentControlPlaneTest.heldOperationReentrancyIsScopedToThe AttestedRoot) that passes in isolation and touches none of these files. Co-Authored-By: Claude Opus 5 (1M context) --- .../runtime/JpaSchemaSafetyValidator.java | 18 +++++- .../runtime/RuntimeSafetyConfig.java | 6 +- .../service/UploadStudioAssetUseCase.java | 59 ++++++++++++------- .../service/WorkingCopyInputValidator.java | 11 ++++ 4 files changed, 69 insertions(+), 25 deletions(-) diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/JpaSchemaSafetyValidator.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/JpaSchemaSafetyValidator.java index 6543460..fa9b5cf 100644 --- a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/JpaSchemaSafetyValidator.java +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/JpaSchemaSafetyValidator.java @@ -3,14 +3,16 @@ package dev.caskeleton.bootstrap.runtime; import dev.caskeleton.bootstrap.runtime.startup.StartupFailures; import java.util.Locale; import java.util.Set; -import org.springframework.beans.factory.SmartInitializingSingleton; +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.config.BeanFactoryPostProcessor; +import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; import org.springframework.core.env.Environment; /** * Prevents Hibernate from becoming a production schema writer. Flyway owns the physical schema; * production may only disable Hibernate DDL or validate the schema. */ -public class JpaSchemaSafetyValidator implements SmartInitializingSingleton { +public class JpaSchemaSafetyValidator implements BeanFactoryPostProcessor { static final String DDL_AUTO_KEY = "spring.jpa.hibernate.ddl-auto"; static final String DDL_AUTO_ENV_KEY = "APP_DATASOURCE_DDL_AUTO"; @@ -24,8 +26,18 @@ public class JpaSchemaSafetyValidator implements SmartInitializingSingleton { this.environment = environment; } + /** + * {@code BeanFactoryPostProcessor} 이지 {@code SmartInitializingSingleton} 이 아닌 이유: 후자는 모든 싱글턴이 + * 만들어진 에 돈다. {@code entityManagerFactory} 도 그 싱글턴 중 하나이고, Hibernate 는 그것을 만들면서 {@code + * ddl-auto} 를 이미 적용한다 — 실측으로 확인했다: {@code ddl-auto=update} 로 prod 를 띄우면 로그에 "Initialized JPA + * EntityManagerFactory" 가 먼저, 그 다음에 이 가드의 PROFILE_MISMATCH 가 찍히고, 스키마에는 그 사이에 만들어진 테이블이 남는다. + * + *

    즉 가드가 트래픽은 막았지만 스키마 변조는 못 막고 있었다. 잘못 설정된 배포가 운영 DB 를 이미 바꿔 놓고 실패하는 셈이라, 검사를 빈 인스턴스화 이전으로 + * 옮긴다. + */ @Override - public void afterSingletonsInstantiated() { + public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) + throws BeansException { if (!isProdActive()) { return; } diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/RuntimeSafetyConfig.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/RuntimeSafetyConfig.java index 426de3c..8e42909 100644 --- a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/RuntimeSafetyConfig.java +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/RuntimeSafetyConfig.java @@ -29,8 +29,12 @@ public class RuntimeSafetyConfig { return new OpenInViewSafetyValidator(environment); } + /** + * {@code static} 이어야 한다 — {@code BeanFactoryPostProcessor} 는 다른 빈보다 먼저 만들어지므로, 인스턴스 메서드로 두면 이 설정 + * 클래스 전체가 too-early 로 초기화되어 경고가 뜬다. + */ @Bean - JpaSchemaSafetyValidator jpaSchemaSafetyValidator(Environment environment) { + static JpaSchemaSafetyValidator jpaSchemaSafetyValidator(Environment environment) { return new JpaSchemaSafetyValidator(environment); } diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/UploadStudioAssetUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/UploadStudioAssetUseCase.java index 8fdad45..89bfcc6 100644 --- a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/UploadStudioAssetUseCase.java +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/UploadStudioAssetUseCase.java @@ -86,27 +86,44 @@ public class UploadStudioAssetUseCase implements CommandUseCase { - String storedKey = binaries.store(objectKey, content, mediaType); - return assets.create( - new AssetRepositoryPort.NewAsset( - assetId, - assetKey, - input.kind(), - mediaType, - storedKey, - input.originalFilename(), - input.byteSize(), - null, - null, - sha256(content), - input.altText(), - input.decorative(), - // 내용 판정을 통과했으므로 READY 다. 판정 실패는 위에서 이미 거절했다. - AssetManagementStatusView.READY), - input.principal()); - }); + // 오브젝트 스토리지 호출은 트랜잭션 밖이다. 원래는 inWrite 안에 있었는데, 그러면 네트워크 + // 왕복이 끝날 때까지 DB 커넥션과 행 잠금을 붙잡고 있게 된다 — 스토리지가 느려지면 그대로 + // 커넥션 풀 고갈로 번진다. 게다가 스토리지는 트랜잭션에 참여하지 않으므로 안에 둔다고 + // 원자성이 생기지도 않는다: 커밋이 실패하면 바이트는 이미 저장돼 있고 롤백되지 않는다. + // + // 그래서 순서를 뒤집는다 — 저장 먼저, DB 쓰기는 짧은 트랜잭션으로. DB 쓰기가 실패하면 + // 방금 올린 오브젝트를 지운다(보상). 그 삭제마저 실패하면 orphan 이 남지만, 그건 원래 + // 실패 경로에 있던 위험이고 지금은 최소한 로그로 드러난다. + String storedKey = binaries.store(objectKey, content, mediaType); + try { + return transactions.inWrite( + () -> + assets.create( + new AssetRepositoryPort.NewAsset( + assetId, + assetKey, + input.kind(), + mediaType, + storedKey, + input.originalFilename(), + input.byteSize(), + null, + null, + sha256(content), + input.altText(), + input.decorative(), + // 내용 판정을 통과했으므로 READY 다. 판정 실패는 위에서 이미 거절했다. + AssetManagementStatusView.READY), + input.principal())); + } catch (RuntimeException databaseFailure) { + try { + binaries.delete(objectKey); + } catch (RuntimeException cleanupFailure) { + // 원래 실패를 덮지 않는다 — 호출자가 알아야 하는 것은 업로드가 왜 실패했는지다. + databaseFailure.addSuppressed(cleanupFailure); + } + throw databaseFailure; + } } /** diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/WorkingCopyInputValidator.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/WorkingCopyInputValidator.java index 07c007e..c4a9dca 100644 --- a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/WorkingCopyInputValidator.java +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/WorkingCopyInputValidator.java @@ -58,7 +58,18 @@ public final class WorkingCopyInputValidator { "document.relations must hold at most " + MAX_RELATIONS + " items"); } Set orders = new HashSet<>(); + Set targets = new HashSet<>(); for (RelationView relation : relations) { + if (relation.targetId() != null && !targets.add(relation.targetId().toString())) { + // 같은 대상을 두 번 연결하면 공개 문서에 같은 줄이 두 번 나오고, 관계를 하나 지웠을 때 + // 나머지 하나가 남아 "지웠는데 그대로"로 보인다. 계약에 uniqueItems 가 없어 스키마가 + // 걸러주지 못하므로 여기서 막는다. + throw StudioException.of( + StudioError.REQUEST_VALIDATION_FAILED, + "document.relations[].targetId must be unique; " + + relation.targetId() + + " is repeated"); + } if (relation.order() < 0) { throw StudioException.of( StudioError.REQUEST_VALIDATION_FAILED, From e3254def5769f4f365d5b954698737082dc0a556 Mon Sep 17 00:00:00 2001 From: DongHyeonka Date: Thu, 20 Aug 2026 18:33:32 +0900 Subject: [PATCH 09/10] =?UTF-8?q?test:=20=EB=AF=B8=EC=B6=94=EC=A0=81?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EB=82=A8=EC=95=84=20=EC=9E=88=EB=8D=98=20?= =?UTF-8?q?Studio=20authz=20=EB=B0=B0=EC=84=A0=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=202=EA=B0=9C=EB=A5=BC=20=EC=B6=94=EC=A0=81=EC=97=90?= =?UTF-8?q?=20=EB=84=A3=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 37d5614("fix: make Studio authorization actually work, and stop it failing as a 500")가 고친 배선을 지키는 테스트인데 커밋에 들어가지 않아 작업 트리에만 있었다. 추적되지 않으면 브랜치를 옮길 때 조용히 사라지고, 다른 사람이 같은 저장소를 받아도 그 회귀 게이트를 갖지 못한다. - StudioAuthzWiringTest: MethodSecurityConfig의 advisor가 AuthorizationPort를 생성자로 받는 인프라 빈이라, auto-proxy보다 먼저 만들어지며 AuthorizationAdapter → RolePermissionRegistry → RolePermissionPolicy를 BeanPostProcessor 등록 전에 끌어 올린다는 사실을 고정한다. 바인딩만 따로 보면 통과하지만 앱에서는 죽는 경우다 - StudioAuthzEnvironmentPostProcessorTest: 같은 수정의 환경 설정 쪽 내용은 손대지 않았다. spotless가 요구한 줄바꿈 두 곳만 정규화됐다(의미 변경 없음). AGENTS.md의 commit 정책은 human-only다. 이 커밋은 사용자가 "지금 변경했던 내용을 전부 반영하고 develop과 main에 반영하도록" 지시해 예외로 수행한다. Co-Authored-By: Claude Opus 5 (1M context) --- ...udioAuthzEnvironmentPostProcessorTest.java | 88 +++++++++++++++++++ .../techlog/StudioAuthzWiringTest.java | 76 ++++++++++++++++ 2 files changed, 164 insertions(+) create mode 100644 src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/techlog/StudioAuthzEnvironmentPostProcessorTest.java create mode 100644 src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/techlog/StudioAuthzWiringTest.java diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/techlog/StudioAuthzEnvironmentPostProcessorTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/techlog/StudioAuthzEnvironmentPostProcessorTest.java new file mode 100644 index 0000000..8fcf301 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/techlog/StudioAuthzEnvironmentPostProcessorTest.java @@ -0,0 +1,88 @@ +package dev.caskeleton.bootstrap.techlog; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.inbound.web.authz.RolePermissionPolicy; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.context.properties.bind.Bindable; +import org.springframework.boot.context.properties.bind.Binder; +import org.springframework.core.ResolvableType; +import org.springframework.core.env.MapPropertySource; +import org.springframework.mock.env.MockEnvironment; + +/** + * 이 후처리기의 산출물은 프로퍼티가 아니라 바인딩 결과다. 프로퍼티가 environment 에 들어갔는지만 보면 통과하면서도 실제 앱에서는 매핑이 죽는 경우가 + * 있다 — application.yml 이 {@code role-permissions: {}} 로 같은 이름을 이미 선언하고 있고, 그 소스가 {@code addLast} 보다 + * 우선순위가 높기 때문이다. 그래서 여기서는 {@code Map>} 로 실제 바인딩해서 확인한다. + */ +class StudioAuthzEnvironmentPostProcessorTest { + + private final StudioAuthzEnvironmentPostProcessor epp = new StudioAuthzEnvironmentPostProcessor(); + + private static final Bindable>> ROLE_PERMISSIONS = + Bindable.of( + ResolvableType.forClassWithGenerics( + Map.class, + ResolvableType.forClass(String.class), + ResolvableType.forClassWithGenerics(List.class, String.class))); + + private static Map> bind(MockEnvironment env) { + return Binder.get(env) + .bind("ca-skeleton.authz.role-permissions", ROLE_PERMISSIONS) + .orElse(Map.of()); + } + + /** + * 앱이 실제로 바인딩하는 대상은 {@code Map} 이 아니라 {@code RolePermissionPolicy} 레코드다(생성자 바인딩). 맵으로만 확인하면 레코드 + * 경로에서만 나타나는 차이를 놓친다. + */ + @Test + void bindsThroughTheRecordTheApplicationActuallyUses() { + MockEnvironment env = new MockEnvironment(); + env.setProperty("app.studio.author-role", "site-admin"); + env.getPropertySources() + .addLast( + new MapPropertySource( + "applicationDefaults", Map.of("ca-skeleton.authz.role-permissions", ""))); + + epp.postProcessEnvironment(env, new SpringApplication()); + + Map> bound = + Binder.get(env) + .bind("ca-skeleton.authz", Bindable.of(RolePermissionPolicy.class)) + .map(RolePermissionPolicy::rolePermissions) + .orElse(Map.of()); + assertThat(bound).containsEntry("site-admin", List.of("studio:read", "studio:write")); + } + + @Test + void grantsBothReadAndWriteToTheConfiguredRole() { + MockEnvironment env = new MockEnvironment(); + env.setProperty("app.studio.author-role", "site-admin"); + + epp.postProcessEnvironment(env, new SpringApplication()); + + assertThat(bind(env)).containsEntry("site-admin", List.of("studio:read", "studio:write")); + } + + /** + * application.yml 이 선언하는 빈 맵을 재현한다. 이것이 매핑을 가리면 Studio 의 모든 쓰기가 403 이 된다 — 읽기는 통과하는데 쓰기만 막히는, + * 진단하기 어려운 모양으로 나타난다. + */ + @Test + void survivesAnEmptyMapDeclaredByTheApplicationDefaults() { + MockEnvironment env = new MockEnvironment(); + env.setProperty("app.studio.author-role", "site-admin"); + env.getPropertySources() + .addLast( + new MapPropertySource( + "applicationDefaults", Map.of("ca-skeleton.authz.role-permissions", ""))); + + epp.postProcessEnvironment(env, new SpringApplication()); + + assertThat(bind(env)).containsEntry("site-admin", List.of("studio:read", "studio:write")); + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/techlog/StudioAuthzWiringTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/techlog/StudioAuthzWiringTest.java new file mode 100644 index 0000000..bf98bc8 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/techlog/StudioAuthzWiringTest.java @@ -0,0 +1,76 @@ +package dev.caskeleton.bootstrap.techlog; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.inbound.web.authz.AuthorizationAdapter; +import dev.caskeleton.adapter.inbound.web.authz.RolePermissionPolicy; +import dev.caskeleton.adapter.inbound.web.authz.RolePermissionRegistry; +import dev.caskeleton.application.security.AuthorizationPort; +import dev.caskeleton.shared.security.Permission; +import java.util.Set; +import org.aopalliance.intercept.MethodInvocation; +import org.junit.jupiter.api.Test; +import org.springframework.aop.Advisor; +import org.springframework.aop.support.annotation.AnnotationMatchingPointcut; +import org.springframework.beans.factory.config.BeanDefinition; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Role; +import org.springframework.mock.env.MockEnvironment; +import org.springframework.security.authorization.AuthorizationManager; +import org.springframework.security.authorization.method.AuthorizationManagerBeforeMethodInterceptor; + +/** + * 후처리기가 심은 매핑이 실행 중인 컨텍스트에서도 살아 있는지 본다. + * + *

    바인딩만 따로 확인하면 통과하지만 앱에서는 죽는 경우가 있어서다. {@code MethodSecurityConfig} 의 advisor 는 auto-proxy 보다 먼저 + * 만들어져야 하는 인프라 빈인데 {@link AuthorizationPort} 를 생성자 파라미터로 받는다. 그래서 {@code AuthorizationAdapter → + * RolePermissionRegistry → RolePermissionPolicy} 가 BeanPostProcessor 등록이 끝나기 전에 끌려 올라온다 — 운영 로그가 이 + * 세 빈에 대해 "not eligible for getting processed by all BeanPostProcessors" 를 정확히 그렇게 찍고 있다. + */ +class StudioAuthzWiringTest { + + @Configuration(proxyBeanMethods = false) + @EnableConfigurationProperties(RolePermissionPolicy.class) + static class Wiring { + + @Bean + RolePermissionRegistry rolePermissionRegistry(RolePermissionPolicy policy) { + return new RolePermissionRegistry(policy); + } + + @Bean + AuthorizationPort authorizationAdapter(RolePermissionRegistry registry) { + return new AuthorizationAdapter(registry); + } + + /** MethodSecurityConfig 와 같은 모양: 인프라 advisor 가 포트를 직접 받는다. */ + @Bean + @Role(BeanDefinition.ROLE_INFRASTRUCTURE) + static Advisor requiresPermissionAuthorizationAdvisor(AuthorizationPort authorizationPort) { + AuthorizationManager manager = (authentication, invocation) -> null; + return new AuthorizationManagerBeforeMethodInterceptor( + AnnotationMatchingPointcut.forMethodAnnotation(Deprecated.class), manager); + } + } + + @Test + void theConfiguredRoleKeepsBothPermissionsInsideARunningContext() { + MockEnvironment env = new MockEnvironment(); + env.setProperty("app.studio.author-role", "site-admin"); + new StudioAuthzEnvironmentPostProcessor().postProcessEnvironment(env, new SpringApplication()); + + try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext()) { + context.setEnvironment(env); + context.register(Wiring.class); + context.refresh(); + + RolePermissionRegistry registry = context.getBean(RolePermissionRegistry.class); + assertThat(registry.effectivePermissions(Set.of("site-admin"))) + .contains(Permission.parse("studio:read"), Permission.parse("studio:write")); + } + } +} From 365560efb65ac734fb6091e7fbb692433229e068 Mon Sep 17 00:00:00 2001 From: DongHyeonka Date: Thu, 20 Aug 2026 18:34:18 +0900 Subject: [PATCH 10/10] =?UTF-8?q?feat:=20Tech=20Log=20=EA=B3=B5=EA=B0=9C?= =?UTF-8?q?=20=EC=A1=B0=ED=9A=8C=20=EB=B0=B1=EC=97=94=EB=93=9C=20=E2=80=94?= =?UTF-8?q?=20public-v1=2018=EA=B0=9C=20operation=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit public-v1.yaml의 18개 operation 전부를 구현한다. 사이트·홈·프로필, 탐색 2종, 주제 2종, 문서 상세 3종, 프로젝트 5종, 릴리스 2종, 검색. studio-v1(19/19)에 이어 public-v1도 18/18이다. 생성기가 계약 필드를 조용히 빠뜨리고 있었다 — 근본 원인은 파생 단계의 YAML alias swagger-parser가 이 문서의 스키마 15개를 "is not of type `object`"로 거절했다. 거절당한 스키마들은 전부 type: object를 명시하고 있어서 계약 결함처럼 보이지 않았고, validateSpec을 끄면 생성은 성공했다. 그런데 그렇게 만든 모델에서 LatestEntry.publishedAt, ProjectListItem.updatedAt, SearchResultItem.matchedFields, ReleaseListItem.changeTypes가 사라져 있었다. 컴파일은 통과한다 — 아직 아무도 그 필드를 안 쓰니까. 원인은 prepare 단계였다. 변환들이 같은 Map 인스턴스를 여러 property에 재사용했고 snakeyaml이 그 지점을 anchor/alias(&id001 / *id001)로 덤프했다. swagger-parser는 alias 노드를 해석하지 못해 그 스키마 전체를 거절하고, generator는 검증을 끄면 문서를 받아들이되 alias였던 property를 말없이 버린다. 파생 스펙에 alias가 34곳 있었다. - 덤프 직전 deep copy로 노드 identity를 끊어 alias를 원천 차단하고, 남으면 빌드가 실패하도록 fail-closed 게이트를 뒀다. validateSpec은 다시 켰다 - verifyPublicGeneratedModels를 schema 이름 대조에서 property 대조로 강화했다. 이번 누락을 이 게이트가 통과시켰기 때문이다. 지금은 schema 62개 · property 250개를 센다 계약이 선언했는데 서버가 무시하던 필터를 채웠다 지정해도 오류가 아니라 "결과 0건"으로 보여서 소비자가 자기 요청이 틀렸다는 걸 알 수 없었다. - exploreQuestions: tag 필터 없음, sort 3값이 SQL에 반영되지 않음 - listPublicProjectDecisions: status 필터 없음 - listPublicProjectRecords: type/relation 필터 없음, QUESTION이 대상에서 빠져 있었음 - 필터는 목록과 총계 두 쿼리에 같이 걸린다. 갈라지면 마지막 페이지가 비어 보인다 - enum 파라미터는 요청 경계에서 검사해 PUBLIC_REQUEST_INVALID로 거절한다 응답 봉투와 오류 경계 - 컨트롤러는 봉투를 반환하지 않는다. EnvelopeBodyAdvice가 감싼다(ADR-006) - PublicExceptionHandler를 publicapi 스코프로 두고, StudioExceptionHandler의 스코프를 ...web.techlog → ...web.techlog.studio로 좁혔다. 좁히지 않으면 공개 조회의 파라미터 오류가 Studio 계약 코드(REQUEST_VALIDATION_FAILED, 422)로 나가는데, 그 코드는 public-v1의 ApiError.code enum에 없어 프론트엔드의 응답 파싱 자체가 깨진다 - FieldError 모양이 studio({path,message})와 public({field,code,message})이 다르다 실행이 잡아낸 결함 컴파일과 단위 테스트로는 드러나지 않았고 실제 PostgreSQL과 실제 기동이 잡았다. - profile()의 selectedEvidence가 List.of() 하드코딩이었다. 계약 필드가 항상 비어 있었다 - latestEntries/latestRecords가 projection의 모든 resource_type을 흘렸다. 계약의 LatestEntry.entryType은 4값뿐이라 QUESTION이 섞이면 매퍼가 500을 낸다 - home_focus_config.default_focus_type은 마이그레이션 직후 NULL인데 계약은 이 필드를 required + enum 3값으로 선언한다. 배포 직후 첫 요청부터 /home이 깨졌다. HomeFocusView.resolve가 반드시 유효한 값 하나를 정하도록 고쳤다 V9__techlog_public_surface.sql 설계 패키지 database/V1__init.sql이 정의한 공개 표면 6종(release, site_config, profile_page, home_focus_config, project_topic, topic_featured_document)과 단일 행 시딩. 릴리스는 Publication 파이프라인을 거치지 않고 자체 workflow_status로 공개된다. 게이트 - PublicContractDriftTest: springdoc이 게시하는 표면과 계약을 양방향 대조한다. 계약의 servers(/api/v1/public)를 경로에 더해 비교하며, operation 수 18을 함께 고정해 "비교 대상이 0건이라 통과"를 실패로 만든다. 봉투 래핑도 확인한다 - PublicErrorRegistryTest: PublicError ↔ error-codes.yaml ↔ 계약 enum 3자 대조. INTERNAL_ERROR는 스켈레톤 소유라 재선언하지 않으므로 "계약 = public 소유 ∪ 그 하나"로 고정한다. vendored 계약의 MANIFEST 해시도 확인한다 - postgresqlTechLogPublicPersistenceIntegrationTest: 어댑터 7종과 V9를 실제 PostgreSQL에서 돌린다. 표준 check는 Testcontainers를 돌리지 않으므로 이 태스크가 없으면 이 SQL은 한 번도 실행되지 않은 채 빌드가 통과한다. 게시 취소·비공개 자료를 함께 심어 어느 경로로도 새지 않는지 확인한다 검증 ./gradlew check BUILD SUCCESSFUL (248 task). 공개 조회 통합 테스트 24/24. 실제 PostgreSQL로 앱을 띄워 18개 operation 전부 실호출 — 5xx 0건, 파라미터 검증 5종 전부 계약 코드. 한때 사라졌던 publishedAt/matchedFields/changeTypes가 실응답에 있다. 알려진 선재 실패: ActuatorSecurityHttpTest가 /actuator/health 503으로 실패한다. 기저 커밋 743fee3에서도 동일하게 재현되며, 원인은 redis가 호스트 포트에 노출되지 않아 헬스가 DOWN인 환경 문제다. 이 커밋과 무관하다. AGENTS.md의 commit 정책은 human-only다. 이 커밋은 사용자가 "지금 변경했던 내용을 전부 반영하고 develop과 main에 반영하도록" 지시해 예외로 수행한다. Co-Authored-By: Claude Opus 5 (1M context) --- docs/registries/error-codes.yaml | 36 + docs/security/public-paths-snapshot.txt | 1 + src/.env | 2 +- src/adapter/inbound/web/build.gradle | 254 +- .../web/techlog/StudioExceptionHandler.java | 15 +- .../publicapi/PublicClientSafeMessages.java | 28 + .../publicapi/PublicExceptionHandler.java | 94 + .../controller/PublicDocumentController.java | 48 + .../controller/PublicExploreController.java | 106 + .../controller/PublicProjectController.java | 103 + .../controller/PublicReleaseController.java | 42 + .../controller/PublicRequestParams.java | 65 + .../controller/PublicSiteController.java | 55 + .../controller/PublicTopicController.java | 35 + .../mapper/DocumentResponseMapper.java | 169 ++ .../mapper/ExploreResponseMapper.java | 90 + .../mapper/ProjectResponseMapper.java | 113 + .../mapper/PublicResponseMapper.java | 142 ++ .../mapper/ReleaseResponseMapper.java | 48 + .../publicapi/mapper/SiteResponseMapper.java | 145 ++ .../publicapi/mapper/TopicResponseMapper.java | 48 + .../outbound/persistence-jpa/build.gradle | 7 + .../JdbcPublicDocumentQueryAdapter.java | 260 ++ .../JdbcPublicExploreQueryAdapter.java | 226 ++ .../JdbcPublicProjectQueryAdapter.java | 330 +++ .../JdbcPublicReleaseQueryAdapter.java | 100 + .../JdbcPublicSearchQueryAdapter.java | 146 ++ .../JdbcPublicSiteQueryAdapter.java | 271 +++ .../JdbcPublicTopicQueryAdapter.java | 178 ++ .../techlog/publicsite/PublicJson.java | 81 + .../publicsite/PublicRelationLookup.java | 87 + .../techlog/publicsite/PublicSql.java | 36 + .../postgresql/V9__techlog_public_surface.sql | 170 ++ .../PublicSitePersistenceIntegrationTest.java | 965 ++++++++ .../contract/PublicContractDriftTest.java | 542 +++++ .../contract/StudioContractDriftTest.java | 32 +- .../techlog/TechLogPublicConfig.java | 139 ++ .../src/main/resources/application-local.yml | 5 +- .../architecture/PublicErrorRegistryTest.java | 172 ++ .../techlog/publicsite/error/PublicError.java | 48 + .../publicsite/error/PublicException.java | 31 + .../publicsite/model/AssetReferenceView.java | 11 + .../publicsite/model/CaseDetailView.java | 8 + .../publicsite/model/CaseRelationsView.java | 17 + .../publicsite/model/ContactLinkView.java | 4 + .../publicsite/model/HomeFocusView.java | 106 + .../techlog/publicsite/model/HomeView.java | 11 + .../model/KnowledgeListItemView.java | 16 + .../publicsite/model/KnowledgePageView.java | 11 + .../publicsite/model/LatestEntryView.java | 13 + .../publicsite/model/PageMetadataView.java | 19 + .../techlog/publicsite/model/ProfileView.java | 28 + .../model/ProjectActivityItemView.java | 7 + .../model/ProjectActivityPageView.java | 11 + .../model/ProjectDecisionItemView.java | 14 + .../model/ProjectDecisionPageView.java | 11 + .../publicsite/model/ProjectDetailView.java | 17 + .../publicsite/model/ProjectListItemView.java | 14 + .../model/ProjectRecordPageView.java | 11 + .../publicsite/model/ProjectSummaryView.java | 4 + .../model/PublishedDocumentView.java | 41 + .../model/PublishedProjectView.java | 23 + .../model/PublishedQuestionView.java | 25 + .../publicsite/model/QuestionDetailView.java | 8 + .../model/QuestionListItemView.java | 14 + .../publicsite/model/QuestionPageView.java | 11 + .../model/QuestionPointGroupView.java | 15 + .../model/QuestionRelationsView.java | 15 + .../publicsite/model/QuestionUpdateView.java | 7 + .../publicsite/model/ReferenceDetailView.java | 8 + .../model/ReferenceRelationsView.java | 16 + .../publicsite/model/RelatedEntryView.java | 4 + .../publicsite/model/ReleaseDetailView.java | 25 + .../publicsite/model/ReleaseListItemView.java | 18 + .../model/SearchResultItemView.java | 21 + .../model/SearchResultPageView.java | 12 + .../techlog/publicsite/model/SiteView.java | 18 + .../publicsite/model/TagSummaryView.java | 4 + .../publicsite/model/TopicDetailView.java | 23 + .../publicsite/model/TopicListItemView.java | 4 + .../publicsite/model/TopicSummaryView.java | 4 + .../port/out/PublicDocumentQueryPort.java | 22 + .../port/out/PublicExploreQueryPort.java | 14 + .../port/out/PublicProjectQueryPort.java | 27 + .../port/out/PublicReleaseQueryPort.java | 14 + .../port/out/PublicSearchQueryPort.java | 11 + .../port/out/PublicSiteQueryPort.java | 22 + .../port/out/PublicTopicQueryPort.java | 14 + .../techlog/publicsite/query/EmptyQuery.java | 6 + .../query/ExploreKnowledgeQuery.java | 19 + .../query/ExploreQuestionsQuery.java | 18 + .../query/ProjectDecisionPageQuery.java | 14 + .../publicsite/query/ProjectPageQuery.java | 6 + .../query/ProjectRecordPageQuery.java | 12 + .../publicsite/query/PublicPageRequest.java | 28 + .../techlog/publicsite/query/SearchQuery.java | 7 + .../techlog/publicsite/query/SlugQuery.java | 6 + .../service/ExploreKnowledgeUseCase.java | 38 + .../service/ExploreQuestionsUseCase.java | 38 + .../service/GetPublicCaseUseCase.java | 39 + .../service/GetPublicHomeUseCase.java | 40 + .../service/GetPublicProfileUseCase.java | 38 + .../service/GetPublicProjectUseCase.java | 41 + .../service/GetPublicQuestionUseCase.java | 41 + .../service/GetPublicReferenceUseCase.java | 42 + .../service/GetPublicReleaseUseCase.java | 41 + .../service/GetPublicSiteUseCase.java | 38 + .../service/GetPublicTopicUseCase.java | 40 + .../ListPublicProjectActivitiesUseCase.java | 43 + .../ListPublicProjectDecisionsUseCase.java | 43 + .../ListPublicProjectRecordsUseCase.java | 42 + .../service/ListPublicProjectsUseCase.java | 39 + .../service/ListPublicReleasesUseCase.java | 39 + .../service/ListPublicTopicsUseCase.java | 39 + .../service/PublicReadUseCases.java | 25 + .../service/SearchPublicResourcesUseCase.java | 38 + src/config/openapi/MANIFEST.sha256 | 2 + src/config/openapi/public-v1.yaml | 2104 +++++++++++++++++ 118 files changed, 9160 insertions(+), 44 deletions(-) create mode 100644 src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/PublicClientSafeMessages.java create mode 100644 src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/PublicExceptionHandler.java create mode 100644 src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/controller/PublicDocumentController.java create mode 100644 src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/controller/PublicExploreController.java create mode 100644 src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/controller/PublicProjectController.java create mode 100644 src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/controller/PublicReleaseController.java create mode 100644 src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/controller/PublicRequestParams.java create mode 100644 src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/controller/PublicSiteController.java create mode 100644 src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/controller/PublicTopicController.java create mode 100644 src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/mapper/DocumentResponseMapper.java create mode 100644 src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/mapper/ExploreResponseMapper.java create mode 100644 src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/mapper/ProjectResponseMapper.java create mode 100644 src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/mapper/PublicResponseMapper.java create mode 100644 src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/mapper/ReleaseResponseMapper.java create mode 100644 src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/mapper/SiteResponseMapper.java create mode 100644 src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/mapper/TopicResponseMapper.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/publicsite/JdbcPublicDocumentQueryAdapter.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/publicsite/JdbcPublicExploreQueryAdapter.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/publicsite/JdbcPublicProjectQueryAdapter.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/publicsite/JdbcPublicReleaseQueryAdapter.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/publicsite/JdbcPublicSearchQueryAdapter.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/publicsite/JdbcPublicSiteQueryAdapter.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/publicsite/JdbcPublicTopicQueryAdapter.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/publicsite/PublicJson.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/publicsite/PublicRelationLookup.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/publicsite/PublicSql.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/resources/db/migration/postgresql/V9__techlog_public_surface.sql create mode 100644 src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/techlog/publicsite/PublicSitePersistenceIntegrationTest.java create mode 100644 src/app-bootstrap/src/functionalTest/java/dev/caskeleton/bootstrap/contract/PublicContractDriftTest.java create mode 100644 src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/techlog/TechLogPublicConfig.java create mode 100644 src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/PublicErrorRegistryTest.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/error/PublicError.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/error/PublicException.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/AssetReferenceView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/CaseDetailView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/CaseRelationsView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/ContactLinkView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/HomeFocusView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/HomeView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/KnowledgeListItemView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/KnowledgePageView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/LatestEntryView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/PageMetadataView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/ProfileView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/ProjectActivityItemView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/ProjectActivityPageView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/ProjectDecisionItemView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/ProjectDecisionPageView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/ProjectDetailView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/ProjectListItemView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/ProjectRecordPageView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/ProjectSummaryView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/PublishedDocumentView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/PublishedProjectView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/PublishedQuestionView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/QuestionDetailView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/QuestionListItemView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/QuestionPageView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/QuestionPointGroupView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/QuestionRelationsView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/QuestionUpdateView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/ReferenceDetailView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/ReferenceRelationsView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/RelatedEntryView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/ReleaseDetailView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/ReleaseListItemView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/SearchResultItemView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/SearchResultPageView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/SiteView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/TagSummaryView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/TopicDetailView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/TopicListItemView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/TopicSummaryView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/port/out/PublicDocumentQueryPort.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/port/out/PublicExploreQueryPort.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/port/out/PublicProjectQueryPort.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/port/out/PublicReleaseQueryPort.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/port/out/PublicSearchQueryPort.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/port/out/PublicSiteQueryPort.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/port/out/PublicTopicQueryPort.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/query/EmptyQuery.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/query/ExploreKnowledgeQuery.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/query/ExploreQuestionsQuery.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/query/ProjectDecisionPageQuery.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/query/ProjectPageQuery.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/query/ProjectRecordPageQuery.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/query/PublicPageRequest.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/query/SearchQuery.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/query/SlugQuery.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/ExploreKnowledgeUseCase.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/ExploreQuestionsUseCase.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/GetPublicCaseUseCase.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/GetPublicHomeUseCase.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/GetPublicProfileUseCase.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/GetPublicProjectUseCase.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/GetPublicQuestionUseCase.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/GetPublicReferenceUseCase.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/GetPublicReleaseUseCase.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/GetPublicSiteUseCase.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/GetPublicTopicUseCase.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/ListPublicProjectActivitiesUseCase.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/ListPublicProjectDecisionsUseCase.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/ListPublicProjectRecordsUseCase.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/ListPublicProjectsUseCase.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/ListPublicReleasesUseCase.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/ListPublicTopicsUseCase.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/PublicReadUseCases.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/SearchPublicResourcesUseCase.java create mode 100644 src/config/openapi/public-v1.yaml diff --git a/docs/registries/error-codes.yaml b/docs/registries/error-codes.yaml index fcd6ff7..4136e09 100644 --- a/docs/registries/error-codes.yaml +++ b/docs/registries/error-codes.yaml @@ -1228,3 +1228,39 @@ errors: runbook_link: "runbook://studio/unavailable" compatibility_impact: additive required_test: StudioErrorTest + + # === Tech Log Public (feature-techlog-public-v1) === + # + # public-v1.yaml 의 ApiError.code 는 세 값이다. 나머지 하나 INTERNAL_ERROR 는 스켈레톤 + # 공통 코드로 이미 이 레지스트리에 있으므로 여기서 다시 선언하지 않는다. + # + # Studio 와 이름을 겹치지 않게 한 이유: 이 레지스트리는 코드 하나에 http_status 하나만 + # 담는다. public 의 400 과 studio 의 422 를 같은 이름으로 쓸 수 없다. + + # source: public-v1.yaml ApiError.code — PUBLIC_REQUEST_INVALID (PublicError.PUBLIC_REQUEST_INVALID) + - code: PUBLIC_REQUEST_INVALID + category: VALIDATION + http_status: 400 + retryable: false + retry_after_seconds: null + owner_branch: feature-techlog-public-v1 + owner_layer: application + client_safe_message: "요청 값이 올바르지 않습니다" + log_level: INFO + runbook_link: null + compatibility_impact: additive + required_test: PublicErrorRegistryTest + + # source: public-v1.yaml ApiError.code — PUBLIC_RESOURCE_NOT_FOUND (PublicError.PUBLIC_RESOURCE_NOT_FOUND) + - code: PUBLIC_RESOURCE_NOT_FOUND + category: NOT_FOUND + http_status: 404 + retryable: false + retry_after_seconds: null + owner_branch: feature-techlog-public-v1 + owner_layer: application + client_safe_message: "요청한 자료를 찾을 수 없습니다" + log_level: INFO + runbook_link: null + compatibility_impact: additive + required_test: PublicErrorRegistryTest diff --git a/docs/security/public-paths-snapshot.txt b/docs/security/public-paths-snapshot.txt index 0b628e9..e98285e 100644 --- a/docs/security/public-paths-snapshot.txt +++ b/docs/security/public-paths-snapshot.txt @@ -2,3 +2,4 @@ # SSOT: SECURITY_PUBLIC_PATHS (src/.env) -> SecurityConfig permitAll(); anyRequest authenticated. # Update only after review with: ./gradlew updatePublicPathSnapshot -PapprovePublicPathChange /api/healthcheck +/api/v1/public/** diff --git a/src/.env b/src/.env index dd4c846..5a8c676 100644 --- a/src/.env +++ b/src/.env @@ -115,7 +115,7 @@ PRESENTATION_API_BASE_PATH=/api APP_SECURITY_AUTH_MODE=jwt APP_SECURITY_JWT_ISSUER=http://localhost:8081/realms/ca-skeleton APP_SECURITY_JWT_AUDIENCE=ca-skeleton-api -SECURITY_PUBLIC_PATHS=/api/healthcheck +SECURITY_PUBLIC_PATHS=/api/healthcheck, /api/v1/public/** APP_SESSION_COOKIE_NAME=CA_SESSION APP_SESSION_COOKIE_SECURE=true APP_SESSION_COOKIE_HTTP_ONLY=true diff --git a/src/adapter/inbound/web/build.gradle b/src/adapter/inbound/web/build.gradle index f5e3012..c06f7b5 100644 --- a/src/adapter/inbound/web/build.gradle +++ b/src/adapter/inbound/web/build.gradle @@ -27,6 +27,11 @@ sourceSets { // 이 인터페이스가 compileGeneratedOpenapiJava의 컴파일 클래스패스에 있어야 한다. // main sourceSet에 두면 main -> generatedOpenapi 단방향 배선(아래 참고) 때문에 보이지 않는다. java.srcDir(layout.buildDirectory.dir('generated/openapi-unions/src/main/java')) + // public-v1 도 같은 방식으로 model 만 생성한다. 별도 sourceSet 을 만들지 않는 이유는 + // 두 계약의 생성물이 같은 성질(생성 코드, 품질 게이트 제외 대상, jar/test 클래스패스에 + // 얹어야 함)을 갖기 때문이다 — sourceSet 을 늘리면 그 배선을 한 벌 더 복제하게 된다. + java.srcDir(layout.buildDirectory.dir('generated/openapi-public/src/main/java')) + java.srcDir(layout.buildDirectory.dir('generated/openapi-public-unions/src/main/java')) } // main이 생성 DTO를 참조할 수 있어야 한다(Task 8/9 controller). implementation // Configuration으로 연결하면(즉 main의 implementation에 generatedOpenapi.output을 @@ -164,18 +169,10 @@ ext.studioCodegenSpecFile = layout.buildDirectory.file('openapi/studio-v1-codege 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 { +// 이 파생은 계약 두 벌(studio-v1, public-v1)에 똑같이 적용된다. 두 벌을 각자 복사해 두면 +// 한쪽만 고쳐지는 날이 오므로 클로저 하나로 두고 태스크가 인자만 바꿔 호출한다. +ext.prepareTechLogCodegenSpec = { String label, File specSource, File specTarget, + File ignoreTarget, File unionDir, String modelPackage -> def doc = new org.yaml.snakeyaml.Yaml().load(specSource.getText('UTF-8')) def schemas = doc.components.schemas @@ -209,6 +206,32 @@ tasks.register('prepareStudioCodegenSpec') { } collapseStringOneOf(doc) + // boolean 프로퍼티의 `const` 를 코드젠 사본에서만 걷어낸다. + // + // 봉투의 success 는 계약상 `{type: boolean, const: true}` 다. 생성기는 이 문서를 검증 + // 경로 없이 읽으면 그 const 를 단일값 enum 으로 취급해 `enum SuccessEnum { TRUE("true") }` + // 를 만드는데, 그 enum 의 필드 타입은 Boolean 이고 생성자에는 String 을 넘겨 컴파일이 + // 깨진다(실측). 검증 경로를 타는 studio 쪽에서는 같은 계약이 평범한 Boolean 으로 나온다 — + // 즉 계약이 아니라 생성기의 경로 차이가 원인이다. + // + // 값이 하나로 고정된다는 사실은 소비자에게 의미가 있으므로 정본 계약에는 그대로 두고, + // 여기서만 뗀다. 서버가 이 값을 잘못 넣을 위험은 없다 — 봉투는 EnvelopeBodyAdvice 가 + // 만들고 컨트롤러가 손대지 않는다. + int[] consts = [0] + def dropBooleanConst + dropBooleanConst = { Object node -> + if (node instanceof Map) { + if (node.get('type') == 'boolean' && node.containsKey('const')) { + node.remove('const') + consts[0]++ + } + new ArrayList(node.values()).each { dropBooleanConst(it) } + } else if (node instanceof List) { + node.each { dropBooleanConst(it) } + } + } + dropBooleanConst(doc) + // (4) `oneOf: [X, {type: null}]` 는 OpenAPI 3.1 이 nullable 을 적는 방식이다. 그대로 두면 // 생성기가 분기들을 병합한 <부모><필드> 래퍼 클래스를 새로 만들고(예: DocumentSummary.project 가 // DisplayTarget 이 아니라 PublicRenderModelBaseProject 가 된다), 같은 모양의 타입이 여러 벌 @@ -282,7 +305,7 @@ tasks.register('prepareStudioCodegenSpec') { } } relaxNullableRequired(doc) - logger.lifecycle("prepareStudioCodegenSpec: nullable required 해제 ${relaxed[0]}건") + logger.lifecycle("${label}: nullable required 해제 ${relaxed[0]}건") // (1) x-implements 주입 + union 목록 수집 def unions = [:] @@ -326,29 +349,68 @@ tasks.register('prepareStudioCodegenSpec') { } unions.put(name, [property: property, variants: variants]) } - if (unions.isEmpty()) { - throw new GradleException('계약에서 discriminator union 을 하나도 찾지 못했다 — 파생 규칙이 깨졌다.') + // 이 가드의 목적은 "union 이 있어야 한다"가 아니라 "계약에 있는 union 을 하나도 빠뜨리지 + // 않았다"이다. public-v1 처럼 union 이 애초에 없는 계약도 있으므로 개수를 계약에서 세어 + // 대조한다. 원래 studio 전용으로 "0개면 실패"로 썼다가 public-v1 에서 걸렸다. + int declaredUnions = schemas.count { String name, Object schema -> + schema instanceof Map && schema.get('oneOf') instanceof List && + schema.get('discriminator') instanceof Map + } + if (unions.size() != declaredUnions) { + throw new GradleException( + "계약의 discriminator union ${declaredUnions}개 중 ${unions.size()}개만 파생했다 — " + + "파생 규칙이 계약을 따라가지 못한다.") } // 파생 계약 쓰기 + // + // deep copy 가 반드시 선행한다. 위 변환들이 같은 Map/List 인스턴스를 여러 위치에 + // 재사용하면 snakeyaml 이 그 지점을 YAML anchor/alias(&id001 / *id001)로 덤프한다. + // swagger-parser 는 alias 노드를 해석하지 못해 그 스키마를 + // "is not of type `object`" 로 거부하고, validateSpec 을 끄면 generator 가 해당 + // property 를 **조용히 누락한 채** 모델을 만든다(publishedAt, matchedFields 등이 + // 실제로 사라졌다). 노드 identity 를 전부 끊어 alias 자체를 원천 차단한다. + def deepCopy + deepCopy = { Object node -> + if (node instanceof Map) { + def copy = new LinkedHashMap() + node.each { k, v -> copy.put(k, deepCopy(v)) } + return copy + } + if (node instanceof List) { + return node.collect { deepCopy(it) } + } + return node + } + def dumperOptions = new org.yaml.snakeyaml.DumperOptions() dumperOptions.defaultFlowStyle = org.yaml.snakeyaml.DumperOptions.FlowStyle.BLOCK dumperOptions.width = 8192 - def specFile = specOut.get().asFile + def specFile = specTarget specFile.parentFile.mkdirs() - specFile.setText(new org.yaml.snakeyaml.Yaml(dumperOptions).dump(doc), 'UTF-8') + def rendered = new org.yaml.snakeyaml.Yaml(dumperOptions).dump(deepCopy(doc)) + + // fail-closed: alias 가 하나라도 남으면 생성물이 조용히 불완전해진다. + def aliasLines = rendered.readLines().findAll { it =~ /(?:&|\*)id\d{3}\b/ } + if (!aliasLines.isEmpty()) { + throw new GradleException( + "${label}: 파생 계약에 YAML alias 가 남았다 — swagger-parser 가 해당 스키마를 " + + "거부하고 property 가 조용히 누락된다. 위반 ${aliasLines.size()}줄, 예: " + + aliasLines.take(3).join(' | ')) + } + specFile.setText(rendered, 'UTF-8') // union 클래스 생성 억제 - def ignoreFile = ignoreOut.get().asFile + def ignoreFile = ignoreTarget ignoreFile.setText( - (['# prepareStudioCodegenSpec 가 생성한다 — 손으로 고치지 않는다.', + (["# ${label} 가 생성한다 — 손으로 고치지 않는다.", '# 이 파일들은 같은 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) + def packageDir = new File(unionDir, modelPackage.replace('.', '/')) + project.delete(unionDir) packageDir.mkdirs() unions.each { String name, Object spec -> def subtypes = spec.variants.collect { String typeId, String variant -> @@ -360,7 +422,7 @@ import com.fasterxml.jackson.annotation.JsonSubTypes; import com.fasterxml.jackson.annotation.JsonTypeInfo; /** - * {@code ${name}} — 계약의 discriminator union. prepareStudioCodegenSpec 가 계약의 + * {@code ${name}} — 계약의 discriminator union. ${label} 가 계약의 * {@code oneOf} + {@code discriminator.mapping} 에서 파생한다. 손으로 고치지 않는다. * *

    {@code As.EXISTING_PROPERTY} 다 — 하위 타입이 {@code ${spec.property}} 를 자기 필드로 @@ -380,11 +442,157 @@ public interface ${name} {} } logger.lifecycle( - "prepareStudioCodegenSpec: union ${unions.size()}개 파생(${unions.keySet().join(', ')}), " + + "${label}: union ${unions.size()}개 파생(${unions.keySet().join(', ')}), boolean const ${consts[0]}건 제거, " + "string oneOf ${collapsed[0]}건 · nullable oneOf ${nullable[0]}건 접음") +} + +ext.studioModelPackage = 'dev.caskeleton.adapter.inbound.web.techlog.studio.api.model' +ext.studioCodegenSpecFile = layout.buildDirectory.file('openapi/studio-v1-codegen.yaml') +ext.studioCodegenIgnoreFile = layout.buildDirectory.file('openapi/.openapi-generator-ignore') +ext.studioUnionSrcDir = layout.buildDirectory.dir('generated/openapi-unions/src/main/java') + +// `public` 은 Java 예약어라 패키지 조각으로 쓸 수 없다 — publicapi 로 둔다. +ext.publicModelPackage = 'dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model' +ext.publicCodegenSpecFile = layout.buildDirectory.file('openapi/public-v1-codegen.yaml') +ext.publicCodegenIgnoreFile = layout.buildDirectory.file('openapi/.openapi-generator-ignore-public') +ext.publicUnionSrcDir = layout.buildDirectory.dir('generated/openapi-public-unions/src/main/java') + +tasks.register('prepareStudioCodegenSpec') { + description = 'studio-v1 계약에서 생성기 입력을 파생시킨다.' + def specSource = file("${rootDir}/config/openapi/studio-v1.yaml") + def specOut = studioCodegenSpecFile + def ignoreOut = studioCodegenIgnoreFile + def unionDir = studioUnionSrcDir + def modelPackage = studioModelPackage + def prepare = prepareTechLogCodegenSpec + inputs.file(specSource) + outputs.file(specOut) + outputs.file(ignoreOut) + outputs.dir(unionDir) + doLast { + prepare('prepareStudioCodegenSpec', specSource, specOut.get().asFile, + ignoreOut.get().asFile, unionDir.get().asFile, modelPackage) } } +tasks.register('preparePublicCodegenSpec') { + description = 'public-v1 계약에서 생성기 입력을 파생시킨다.' + def specSource = file("${rootDir}/config/openapi/public-v1.yaml") + def specOut = publicCodegenSpecFile + def ignoreOut = publicCodegenIgnoreFile + def unionDir = publicUnionSrcDir + def modelPackage = publicModelPackage + def prepare = prepareTechLogCodegenSpec + inputs.file(specSource) + outputs.file(specOut) + outputs.file(ignoreOut) + outputs.dir(unionDir) + doLast { + prepare('preparePublicCodegenSpec', specSource, specOut.get().asFile, + ignoreOut.get().asFile, unionDir.get().asFile, modelPackage) + } +} + +// public-v1 생성. openApiGenerate 확장은 계약 하나만 다루므로 두 번째 계약은 GenerateTask 를 +// 직접 등록한다. 설정은 studio 쪽과 같은 근거를 따른다(model 만 생성, oneOf interface 미사용, +// openApiNullable=false) — 그 근거는 위 openApiGenerate 블록의 주석에 있다. +tasks.register('openApiGeneratePublic', + org.openapitools.generator.gradle.plugin.tasks.GenerateTask) { + dependsOn tasks.named('preparePublicCodegenSpec') + generatorName = 'spring' + inputSpec = publicCodegenSpecFile.get().asFile.path + ignoreFileOverride = publicCodegenIgnoreFile.get().asFile.path + outputDir = layout.buildDirectory.dir('generated/openapi-public').get().asFile.path + modelPackage = publicModelPackage + // 검증을 켠 채로 둔다. 한때 swagger-parser 가 이 문서의 스키마 15개를 + // "is not of type `object`" 로 거절했는데, 원인은 계약이 아니라 파생 단계였다. + // preparePublicCodegenSpec 의 변환이 같은 Map 인스턴스를 여러 property 에 재사용해 + // snakeyaml 이 YAML alias(*id001)로 덤프했고, swagger-parser 가 alias 노드를 + // 해석하지 못해 그 스키마 전체를 거절했다. validateSpec 을 끄면 generator 는 문서를 + // 받아들이되 alias 였던 property 를 **조용히 누락**한다 — publishedAt, updatedAt, + // matchedFields, changeTypes 가 실제로 모델에서 사라졌다. 파생 단계에서 deep copy 로 + // alias 를 원천 차단했으므로 검증을 다시 켠다. + validateSpec = true + globalProperties.set(['models': '']) + generateModelTests = false + generateModelDocumentation = false + configOptions = [ + useSpringBoot3: 'true', + useJakartaEe: 'true', + openApiNullable: 'false', + useOneOfInterfaces: 'false', + ] + // 생성기는 outputDir 를 비우지 않는다 — 계약에서 사라진 스키마의 .java 가 남아 드리프트를 + // 가린다(studio 쪽에서 실제로 겪었다). + doFirst { project.delete(layout.buildDirectory.dir('generated/openapi-public')) } +} + +// 생성기가 스키마나 property 를 조용히 빠뜨려도 컴파일은 그대로 통과한다(그 타입을 아직 +// 아무도 안 쓰니까) — 나중에 컨트롤러를 쓸 때서야 드러난다. 실제로 파생 계약의 YAML alias +// 때문에 publishedAt / updatedAt / matchedFields / changeTypes 가 모델에서 사라진 채로 +// 빌드가 성공한 적이 있고, 그때 이 게이트가 schema 이름만 봐서 놓쳤다. 그래서 property 까지 +// 대조한다. +tasks.register('verifyPublicGeneratedModels') { + group = 'verification' + description = 'public-v1 계약의 schema 와 property 가 전부 모델로 생성됐는지 대조한다.' + dependsOn tasks.named('openApiGeneratePublic') + def specFile = publicCodegenSpecFile + def modelDirProvider = layout.buildDirectory.dir('generated/openapi-public/src/main/java') + def modelPackage = publicModelPackage + doLast { + def doc = new org.yaml.snakeyaml.Yaml().load(specFile.get().asFile.getText('UTF-8')) + Set declared = new TreeSet<>(((Map) doc.components.schemas).keySet()) + File packageDir = new File(modelDirProvider.get().asFile, modelPackage.replace('.', '/')) + Set generated = new TreeSet<>() + if (packageDir.isDirectory()) { + packageDir.eachFile { File f -> + if (f.name.endsWith('.java')) generated << f.name[0..-6] + } + } + // 생성기는 이름 없는 중첩 object 에 <부모><필드> 형태의 모델을 더 만든다. 그건 초과분이라 + // 문제가 아니고, 부족분만 문제다. + Set missing = new TreeSet<>(declared - generated) + if (!missing.isEmpty()) { + throw new GradleException( + "public-v1 계약의 schema ${missing.size()}개가 모델로 생성되지 않았다: ${missing}") + } + + // property 대조. 생성기는 @JsonProperty 에 계약의 원래 이름을 그대로 쓰므로 + // 그 문자열 리터럴이 파일에 있는지로 판정한다. + int checkedProps = 0 + List lost = [] + ((Map) doc.components.schemas).each { String name, Object schema -> + if (!(schema instanceof Map)) return + Object props = ((Map) schema).get('properties') + if (!(props instanceof Map)) return + File modelFile = new File(packageDir, "${name}.java") + if (!modelFile.isFile()) return + String body = modelFile.getText('UTF-8') + ((Map) props).keySet().each { Object prop -> + checkedProps++ + if (!body.contains("\"${prop}\"")) lost << "${name}.${prop}" + } + } + if (!lost.isEmpty()) { + throw new GradleException( + "public-v1 계약의 property ${lost.size()}개가 모델에서 빠졌다 " + + "(생성기가 조용히 누락한다): ${lost.take(20)}") + } + + logger.lifecycle( + "verifyPublicGeneratedModels: 계약 schema ${declared.size()}개 · " + + "property ${checkedProps}개 전부 생성 (생성 모델 ${generated.size()}개)") + } +} + +tasks.named('check') { + dependsOn tasks.named('verifyPublicGeneratedModels') +} + +tasks.named('compileGeneratedOpenapiJava') { + dependsOn tasks.named('openApiGeneratePublic') +} + // openApiGenerate 는 확장(extension) 이름이자 태스크 이름이다 — 위 블록은 확장 설정이라 // dependsOn 을 받지 못한다. 태스크 쪽에 건다. tasks.named('openApiGenerate') { diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/StudioExceptionHandler.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/StudioExceptionHandler.java index 5c10a4f..e001305 100644 --- a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/StudioExceptionHandler.java +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/StudioExceptionHandler.java @@ -29,14 +29,17 @@ import org.springframework.web.method.annotation.MethodArgumentTypeMismatchExcep * handlePersistenceFailure}/{@code handleDependencyFailure}가 분류된 하위 계층 실패를 로깅하는 것과 같은 패턴이다. * *

    {@code basePackages} 스코프 (final whole-branch review B4). 이 advice는 {@code - * dev.caskeleton.adapter.inbound.web.techlog} 아래의 컨트롤러(현재 studio 컨트롤러 전부가 여기 산다, {@code - * studio.controller})에만 적용된다. {@link #handleMissingParameter}/{@link #handleTypeMismatch}는 Spring - * MVC 표준 바인딩 예외를 계약 코드로 옮기는데, 스코프 없이 전역으로 두면 fileserver·healthcheck 같은 studio 밖 컨트롤러의 같은 예외까지 가로채 그 - * 기능들의 기존 오류 응답 모양(바로 이 advice가 없었을 때의 {@code GlobalExceptionHandler} 동작)을 바꿔버린다 — 이 브랜치가 건드릴 권한이 - * 없는 기능이다. {@code StudioException} 처리는 애초에 studio 코드만 이 예외를 던지므로 스코프를 좁혀도 동작이 바뀌지 않는다. + * dev.caskeleton.adapter.inbound.web.techlog.studio} 아래의 컨트롤러(studio 컨트롤러 전부가 여기 산다, {@code + * studio.controller})에만 적용된다. 원래는 한 단계 위인 {@code ...web.techlog}였는데, 공개 조회 컨트롤러가 {@code + * ...web.techlog.publicapi}에 들어오면서 그 스코프가 남의 기능까지 덮게 되었다 — 아래 바인딩 예외 처리기들이 공개 조회의 파라미터 오류를 Studio + * 계약 코드로 바꿔 내보냈을 것이고, 그 코드는 public-v1 계약의 enum 에 없어서 프론트엔드의 응답 파싱을 깨뜨린다. 그래서 studio 로 좁혔다. {@link + * #handleMissingParameter}/{@link #handleTypeMismatch}는 Spring MVC 표준 바인딩 예외를 계약 코드로 옮기는데, 스코프 없이 + * 전역으로 두면 fileserver·healthcheck 같은 studio 밖 컨트롤러의 같은 예외까지 가로채 그 기능들의 기존 오류 응답 모양(바로 이 advice가 없었을 + * 때의 {@code GlobalExceptionHandler} 동작)을 바꿔버린다 — 이 브랜치가 건드릴 권한이 없는 기능이다. {@code StudioException} + * 처리는 애초에 studio 코드만 이 예외를 던지므로 스코프를 좁혀도 동작이 바뀌지 않는다. */ @Order(Ordered.HIGHEST_PRECEDENCE) -@RestControllerAdvice(basePackages = "dev.caskeleton.adapter.inbound.web.techlog") +@RestControllerAdvice(basePackages = "dev.caskeleton.adapter.inbound.web.techlog.studio") public class StudioExceptionHandler { private static final Logger log = LoggerFactory.getLogger(StudioExceptionHandler.class); diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/PublicClientSafeMessages.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/PublicClientSafeMessages.java new file mode 100644 index 0000000..fa49f1d --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/PublicClientSafeMessages.java @@ -0,0 +1,28 @@ +package dev.caskeleton.adapter.inbound.web.techlog.publicapi; + +import dev.caskeleton.application.techlog.publicsite.error.PublicError; + +/** + * 공개 조회 실패의 client-safe {@code error.message} 단일 출처. + * + *

    {@code PublicException#getMessage()}는 use case 가 진단용으로 채우는 원문이라 {@code ApiErrorCarrier} + * javadoc 이 경고하는 대로 저장소 내부 사정을 실을 수 있다. 그래서 응답에는 절대 흘리지 않고 이 클래스가 code 별 고정 문구만 내보낸다 — {@code + * StudioClientSafeMessages}가 {@code StudioError}에 대해 하는 것과 같은 역할이다. + * + *

    문구는 {@code docs/registries/error-codes.yaml}의 각 row {@code client_safe_message}와 정확히 같아야 한다 — + * {@code PublicErrorRegistryTest}가 그 일치를 고정한다. + * + *

    {@link PublicError}를 exhaustive switch 로 매핑하므로(default 없음) 새 상수를 추가하면 이 파일도 컴파일 타임에 고쳐야 한다 — + * 문구 누락이 생길 수 없다. + */ +public final class PublicClientSafeMessages { + + private PublicClientSafeMessages() {} + + public static String forError(PublicError error) { + return switch (error) { + case PUBLIC_REQUEST_INVALID -> "요청 값이 올바르지 않습니다"; + case PUBLIC_RESOURCE_NOT_FOUND -> "요청한 자료를 찾을 수 없습니다"; + }; + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/PublicExceptionHandler.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/PublicExceptionHandler.java new file mode 100644 index 0000000..e6aa9b5 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/PublicExceptionHandler.java @@ -0,0 +1,94 @@ +package dev.caskeleton.adapter.inbound.web.techlog.publicapi; + +import dev.caskeleton.adapter.inbound.web.error.ErrorResponseFactory; +import dev.caskeleton.application.techlog.publicsite.error.PublicError; +import dev.caskeleton.application.techlog.publicsite.error.PublicException; +import dev.caskeleton.shared.response.Envelope; +import java.util.List; +import java.util.Map; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.MissingServletRequestParameterException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; + +/** + * 공개 조회 실패를 스켈레톤 봉투로 옮긴다. 템플릿의 {@code GlobalExceptionHandler}를 수정하지 않기 위해 별도 advice 로 둔다 — 그 파일은 + * template sync 대상이다. + * + *

    {@code basePackages} 스코프. 이 advice 는 {@code + * dev.caskeleton.adapter.inbound.web.techlog.publicapi} 아래의 컨트롤러에만 적용된다. 형제인 {@code + * StudioExceptionHandler}가 원래 {@code ...web.techlog} 전체를 잡고 있었는데, 그 스코프는 이 패키지까지 포함하므로 공개 조회의 파라미터 + * 오류가 Studio 계약의 {@code REQUEST_VALIDATION_FAILED}(422)로 나갔을 것이다 — public-v1 계약의 {@code + * ApiError.code} enum 에 없는 코드라 프론트엔드의 응답 파싱 자체가 깨진다. 그래서 이 advice 를 추가하면서 Studio 쪽 스코프를 {@code + * ...web.techlog.studio}로 좁혔다. 두 스코프는 이제 겹치지 않는다. + * + *

    {@code error.message}에는 {@link PublicClientSafeMessages}가 주는 code 별 고정 문구만 싣는다 — {@link + * PublicException#getMessage()}(진단용 원문)는 그대로 내보내지 않는다({@code ApiErrorCarrier} javadoc). 원문은 버리지 않고 + * 서버 로그에만 남긴다. + */ +@Order(Ordered.HIGHEST_PRECEDENCE) +@RestControllerAdvice(basePackages = "dev.caskeleton.adapter.inbound.web.techlog.publicapi") +public class PublicExceptionHandler { + + private static final Logger log = LoggerFactory.getLogger(PublicExceptionHandler.class); + + /** + * 공개 조회는 인증이 없고 열람자가 익명이다. 없는 slug 하나하나를 ERROR 로 남기면 크롤러가 만드는 404 가 로그를 덮어 실제 장애를 가린다 — {@code + * NOT_FOUND}는 WARN 이하로 남기고 나머지만 ERROR 로 올린다. + */ + @ExceptionHandler(PublicException.class) + public ResponseEntity> handlePublic(PublicException ex) { + PublicError error = ex.publicError(); + if (error == PublicError.PUBLIC_RESOURCE_NOT_FOUND) { + log.debug("public resource not found: {}", ex.getMessage()); + } else { + log.warn( + "public request rejected as {} (category={}): {}", + error.code(), + error.category(), + ex.getMessage()); + } + return ErrorResponseFactory.envelope(error, PublicClientSafeMessages.forError(error), null); + } + + /** + * 필수 쿼리 파라미터 누락 — 계약에서 {@code GET /v1/public/search}의 {@code q}가 유일하다. 이 예외를 그냥 두면 부모 {@code + * ResponseEntityExceptionHandler}가 bare {@code ProblemDetail}(content-type {@code + * application/problem+json})을 만들고, {@code EnvelopeBodyAdvice}의 JSON 미디어타입 검사에 걸려 봉투를 못 씌운다 — + * ADR-006 이 쓰지 않기로 한 RFC 7807 이 그대로 나간다. + */ + @ExceptionHandler(MissingServletRequestParameterException.class) + public ResponseEntity> handleMissingParameter( + MissingServletRequestParameterException ex) { + return requestInvalid(ex.getParameterName(), "REQUIRED", "Required parameter is missing"); + } + + /** + * 쿼리 파라미터 타입 불일치(예: {@code page=abc}, {@code year=x}). {@code GlobalExceptionHandler}도 이 예외를 + * 처리하지만 {@code OperationalError.BAD_PARAMETER}를 낸다 — public-v1 계약의 세 코드에 없다. + */ + @ExceptionHandler(MethodArgumentTypeMismatchException.class) + public ResponseEntity> handleTypeMismatch(MethodArgumentTypeMismatchException ex) { + return requestInvalid(ex.getName(), "TYPE_MISMATCH", "Parameter value is invalid"); + } + + /** + * {@code details}를 계약의 {@code ValidationErrorDetails}({@code fieldErrors: [{field, code, + * message}]}) 모양에 맞춰 싣는다. 세 필드 전부 {@code required}이므로 하나라도 빠지면 계약 위반이다 — Studio 계약의 {@code {path, + * message}}와 모양이 다르니 그 코드를 복사해 오면 안 된다. + */ + private static ResponseEntity> requestInvalid( + String field, String code, String message) { + Map fieldError = Map.of("field", field, "code", code, "message", message); + Map details = Map.of("fieldErrors", List.of(fieldError)); + return ErrorResponseFactory.envelope( + PublicError.PUBLIC_REQUEST_INVALID, + PublicClientSafeMessages.forError(PublicError.PUBLIC_REQUEST_INVALID), + details); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/controller/PublicDocumentController.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/controller/PublicDocumentController.java new file mode 100644 index 0000000..e57d81f --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/controller/PublicDocumentController.java @@ -0,0 +1,48 @@ +package dev.caskeleton.adapter.inbound.web.techlog.publicapi.controller; + +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.CaseDetailResponse; +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.QuestionDetailResponse; +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ReferenceDetailResponse; +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.mapper.DocumentResponseMapper; +import dev.caskeleton.application.techlog.publicsite.query.SlugQuery; +import dev.caskeleton.application.techlog.publicsite.service.GetPublicCaseUseCase; +import dev.caskeleton.application.techlog.publicsite.service.GetPublicQuestionUseCase; +import dev.caskeleton.application.techlog.publicsite.service.GetPublicReferenceUseCase; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RestController; + +/** + * 문서 상세 세 종류. 계약 {@code getPublicCase} / {@code getPublicReference} / {@code getPublicQuestion}. + */ +@RestController +public class PublicDocumentController { + + private final GetPublicCaseUseCase getCase; + private final GetPublicReferenceUseCase getReference; + private final GetPublicQuestionUseCase getQuestion; + + public PublicDocumentController( + GetPublicCaseUseCase getCase, + GetPublicReferenceUseCase getReference, + GetPublicQuestionUseCase getQuestion) { + this.getCase = getCase; + this.getReference = getReference; + this.getQuestion = getQuestion; + } + + @GetMapping("/v1/public/cases/{slug}") + public CaseDetailResponse getPublicCase(@PathVariable("slug") String slug) { + return DocumentResponseMapper.caseDetail(getCase.handle(new SlugQuery(slug))); + } + + @GetMapping("/v1/public/references/{slug}") + public ReferenceDetailResponse getPublicReference(@PathVariable("slug") String slug) { + return DocumentResponseMapper.referenceDetail(getReference.handle(new SlugQuery(slug))); + } + + @GetMapping("/v1/public/questions/{slug}") + public QuestionDetailResponse getPublicQuestion(@PathVariable("slug") String slug) { + return DocumentResponseMapper.questionDetail(getQuestion.handle(new SlugQuery(slug))); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/controller/PublicExploreController.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/controller/PublicExploreController.java new file mode 100644 index 0000000..19b6eea --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/controller/PublicExploreController.java @@ -0,0 +1,106 @@ +package dev.caskeleton.adapter.inbound.web.techlog.publicapi.controller; + +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.KnowledgePage; +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.QuestionPage; +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.SearchResultPage; +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.mapper.ExploreResponseMapper; +import dev.caskeleton.application.techlog.publicsite.query.ExploreKnowledgeQuery; +import dev.caskeleton.application.techlog.publicsite.query.ExploreQuestionsQuery; +import dev.caskeleton.application.techlog.publicsite.query.SearchQuery; +import dev.caskeleton.application.techlog.publicsite.service.ExploreKnowledgeUseCase; +import dev.caskeleton.application.techlog.publicsite.service.ExploreQuestionsUseCase; +import dev.caskeleton.application.techlog.publicsite.service.SearchPublicResourcesUseCase; +import java.util.Set; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** + * 탐색과 검색. 계약 {@code exploreKnowledge} / {@code exploreQuestions} / {@code searchPublicResources}. + * + *

    계약이 enum 을 선언한 파라미터는 {@link PublicRequestParams} 로 검사한다 — 이유는 그 클래스 javadoc. + */ +@RestController +public class PublicExploreController { + + private static final Set KNOWLEDGE_TYPES = Set.of("CASE", "REFERENCE"); + private static final Set KNOWLEDGE_SORTS = + Set.of("PUBLISHED_DESC", "UPDATED_DESC", "VERIFIED_DESC"); + private static final Set QUESTION_STATUSES = + Set.of("OPEN", "INVESTIGATING", "PAUSED", "RESOLVED"); + private static final Set QUESTION_SORTS = + Set.of("UPDATED_DESC", "OPENED_DESC", "RESOLVED_DESC"); + private static final Set SEARCH_TYPES = + Set.of("CASE", "REFERENCE", "QUESTION", "PROJECT", "RELEASE"); + + private final ExploreKnowledgeUseCase exploreKnowledge; + private final ExploreQuestionsUseCase exploreQuestions; + private final SearchPublicResourcesUseCase search; + + public PublicExploreController( + ExploreKnowledgeUseCase exploreKnowledge, + ExploreQuestionsUseCase exploreQuestions, + SearchPublicResourcesUseCase search) { + this.exploreKnowledge = exploreKnowledge; + this.exploreQuestions = exploreQuestions; + this.search = search; + } + + @GetMapping("/v1/public/explore/knowledge") + public KnowledgePage exploreKnowledge( + @RequestParam(value = "type", required = false) String type, + @RequestParam(value = "topic", required = false) String topic, + @RequestParam(value = "project", required = false) String project, + @RequestParam(value = "tag", required = false) String tag, + @RequestParam(value = "year", required = false) Integer year, + @RequestParam(value = "sort", required = false) String sort, + @RequestParam(value = "page", defaultValue = "1") int page, + @RequestParam(value = "size", defaultValue = "20") int size) { + ExploreKnowledgeQuery query = + new ExploreKnowledgeQuery( + PublicRequestParams.oneOf("type", type, KNOWLEDGE_TYPES), + topic, + project, + tag, + PublicRequestParams.year(year), + PublicRequestParams.sort("sort", sort, "PUBLISHED_DESC", KNOWLEDGE_SORTS), + PublicRequestParams.page(page, size)); + return ExploreResponseMapper.knowledge(exploreKnowledge.handle(query)); + } + + @GetMapping("/v1/public/explore/questions") + public QuestionPage exploreQuestions( + @RequestParam(value = "status", required = false) String status, + @RequestParam(value = "topic", required = false) String topic, + @RequestParam(value = "project", required = false) String project, + @RequestParam(value = "tag", required = false) String tag, + @RequestParam(value = "sort", required = false) String sort, + @RequestParam(value = "page", defaultValue = "1") int page, + @RequestParam(value = "size", defaultValue = "20") int size) { + ExploreQuestionsQuery query = + new ExploreQuestionsQuery( + PublicRequestParams.oneOf("status", status, QUESTION_STATUSES), + topic, + project, + tag, + PublicRequestParams.sort("sort", sort, "UPDATED_DESC", QUESTION_SORTS), + PublicRequestParams.page(page, size)); + return ExploreResponseMapper.questions(exploreQuestions.handle(query)); + } + + @GetMapping("/v1/public/search") + public SearchResultPage searchPublicResources( + @RequestParam("q") String q, + @RequestParam(value = "type", required = false) String type, + @RequestParam(value = "topic", required = false) String topic, + @RequestParam(value = "page", defaultValue = "1") int page, + @RequestParam(value = "size", defaultValue = "20") int size) { + SearchQuery query = + new SearchQuery( + PublicRequestParams.searchTerm(q), + PublicRequestParams.oneOf("type", type, SEARCH_TYPES), + topic, + PublicRequestParams.page(page, size)); + return ExploreResponseMapper.search(search.handle(query)); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/controller/PublicProjectController.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/controller/PublicProjectController.java new file mode 100644 index 0000000..34f4a88 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/controller/PublicProjectController.java @@ -0,0 +1,103 @@ +package dev.caskeleton.adapter.inbound.web.techlog.publicapi.controller; + +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ProjectActivityPage; +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ProjectDecisionPage; +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ProjectDetailResponse; +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ProjectListResponse; +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ProjectRecordPage; +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.mapper.ProjectResponseMapper; +import dev.caskeleton.application.techlog.publicsite.query.EmptyQuery; +import dev.caskeleton.application.techlog.publicsite.query.ProjectDecisionPageQuery; +import dev.caskeleton.application.techlog.publicsite.query.ProjectPageQuery; +import dev.caskeleton.application.techlog.publicsite.query.ProjectRecordPageQuery; +import dev.caskeleton.application.techlog.publicsite.query.SlugQuery; +import dev.caskeleton.application.techlog.publicsite.service.GetPublicProjectUseCase; +import dev.caskeleton.application.techlog.publicsite.service.ListPublicProjectActivitiesUseCase; +import dev.caskeleton.application.techlog.publicsite.service.ListPublicProjectDecisionsUseCase; +import dev.caskeleton.application.techlog.publicsite.service.ListPublicProjectRecordsUseCase; +import dev.caskeleton.application.techlog.publicsite.service.ListPublicProjectsUseCase; +import java.util.Set; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** + * 프로젝트 허브. 계약 {@code listPublicProjects} / {@code getPublicProject} 와 하위 목록 셋({@code + * listPublicProjectDecisions} / {@code listPublicProjectRecords} / {@code + * listPublicProjectActivities}). + * + *

    하위 목록은 프로젝트 자체가 공개가 아니면 빈 페이지가 아니라 404 다 — 비공개 프로젝트의 존재가 "결정이 0건인 프로젝트"로 새어 나가면 안 된다. 그 구분은 + * port 가 {@code Optional} 로 표현하고 use case 가 404 로 옮긴다. + */ +@RestController +public class PublicProjectController { + + private static final Set RECORD_TYPES = Set.of("CASE", "REFERENCE", "QUESTION"); + private static final Set RECORD_RELATIONS = Set.of("PRIMARY", "RELATED"); + + private final ListPublicProjectsUseCase listProjects; + private final GetPublicProjectUseCase getProject; + private final ListPublicProjectDecisionsUseCase listDecisions; + private final ListPublicProjectRecordsUseCase listRecords; + private final ListPublicProjectActivitiesUseCase listActivities; + + public PublicProjectController( + ListPublicProjectsUseCase listProjects, + GetPublicProjectUseCase getProject, + ListPublicProjectDecisionsUseCase listDecisions, + ListPublicProjectRecordsUseCase listRecords, + ListPublicProjectActivitiesUseCase listActivities) { + this.listProjects = listProjects; + this.getProject = getProject; + this.listDecisions = listDecisions; + this.listRecords = listRecords; + this.listActivities = listActivities; + } + + @GetMapping("/v1/public/projects") + public ProjectListResponse listPublicProjects() { + return ProjectResponseMapper.list(listProjects.handle(new EmptyQuery())); + } + + @GetMapping("/v1/public/projects/{slug}") + public ProjectDetailResponse getPublicProject(@PathVariable("slug") String slug) { + return ProjectResponseMapper.detail(getProject.handle(new SlugQuery(slug))); + } + + @GetMapping("/v1/public/projects/{slug}/decisions") + public ProjectDecisionPage listPublicProjectDecisions( + @PathVariable("slug") String slug, + @RequestParam(value = "status", required = false) String status, + @RequestParam(value = "page", defaultValue = "1") int page, + @RequestParam(value = "size", defaultValue = "20") int size) { + return ProjectResponseMapper.decisions( + listDecisions.handle( + new ProjectDecisionPageQuery(slug, status, PublicRequestParams.page(page, size)))); + } + + @GetMapping("/v1/public/projects/{slug}/records") + public ProjectRecordPage listPublicProjectRecords( + @PathVariable("slug") String slug, + @RequestParam(value = "type", required = false) String type, + @RequestParam(value = "relation", required = false) String relation, + @RequestParam(value = "page", defaultValue = "1") int page, + @RequestParam(value = "size", defaultValue = "20") int size) { + ProjectRecordPageQuery query = + new ProjectRecordPageQuery( + slug, + PublicRequestParams.oneOf("type", type, RECORD_TYPES), + PublicRequestParams.oneOf("relation", relation, RECORD_RELATIONS), + PublicRequestParams.page(page, size)); + return ProjectResponseMapper.records(listRecords.handle(query)); + } + + @GetMapping("/v1/public/projects/{slug}/activities") + public ProjectActivityPage listPublicProjectActivities( + @PathVariable("slug") String slug, + @RequestParam(value = "page", defaultValue = "1") int page, + @RequestParam(value = "size", defaultValue = "20") int size) { + return ProjectResponseMapper.activities( + listActivities.handle(new ProjectPageQuery(slug, PublicRequestParams.page(page, size)))); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/controller/PublicReleaseController.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/controller/PublicReleaseController.java new file mode 100644 index 0000000..0e60baf --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/controller/PublicReleaseController.java @@ -0,0 +1,42 @@ +package dev.caskeleton.adapter.inbound.web.techlog.publicapi.controller; + +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ReleaseDetailResponse; +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ReleaseListResponse; +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.mapper.ReleaseResponseMapper; +import dev.caskeleton.application.techlog.publicsite.query.EmptyQuery; +import dev.caskeleton.application.techlog.publicsite.query.SlugQuery; +import dev.caskeleton.application.techlog.publicsite.service.GetPublicReleaseUseCase; +import dev.caskeleton.application.techlog.publicsite.service.ListPublicReleasesUseCase; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RestController; + +/** + * Tech Log 자체 변경 기록. 계약 {@code listPublicReleases} / {@code getPublicRelease}. + * + *

    {@code getPublicRelease} 의 path 변수는 slug 가 아니라 {@code version} 이다 — {@code SlugQuery} 를 그대로 쓰되 + * 어댑터가 {@code release.version} 으로 조회한다({@code PublicReleaseQueryPort#findByVersion}). 값의 의미가 다르므로 + * 이름을 그대로 옮겨 적는다. + */ +@RestController +public class PublicReleaseController { + + private final ListPublicReleasesUseCase listReleases; + private final GetPublicReleaseUseCase getRelease; + + public PublicReleaseController( + ListPublicReleasesUseCase listReleases, GetPublicReleaseUseCase getRelease) { + this.listReleases = listReleases; + this.getRelease = getRelease; + } + + @GetMapping("/v1/public/releases") + public ReleaseListResponse listPublicReleases() { + return ReleaseResponseMapper.list(listReleases.handle(new EmptyQuery())); + } + + @GetMapping("/v1/public/releases/{version}") + public ReleaseDetailResponse getPublicRelease(@PathVariable("version") String version) { + return ReleaseResponseMapper.detail(getRelease.handle(new SlugQuery(version))); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/controller/PublicRequestParams.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/controller/PublicRequestParams.java new file mode 100644 index 0000000..70c0c87 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/controller/PublicRequestParams.java @@ -0,0 +1,65 @@ +package dev.caskeleton.adapter.inbound.web.techlog.publicapi.controller; + +import dev.caskeleton.application.techlog.publicsite.error.PublicError; +import dev.caskeleton.application.techlog.publicsite.error.PublicException; +import dev.caskeleton.application.techlog.publicsite.query.PublicPageRequest; +import java.util.List; +import java.util.Set; + +/** + * 계약이 쿼리 파라미터에 건 제약을 요청 경계에서 강제한다. + * + *

    enum 값을 검사하지 않고 그대로 SQL 필터로 넘기면 오타(`type=CASES`)가 오류가 아니라 "결과 0건"으로 보인다 — 소비자는 자기 요청이 틀렸다는 사실을 + * 영영 알 수 없다. 계약이 enum 을 선언한 자리는 계약 밖 값을 {@code PUBLIC_REQUEST_INVALID} 로 거절한다. + * + *

    파라미터를 생성 DTO 의 enum 타입으로 바인딩하지 않는 이유는, 그 경우 Spring 이 던지는 {@code + * MethodArgumentTypeMismatchException} 이 "어떤 값이 허용되는지"를 응답에 남기지 못하고 스택 상위에서 잡히기 때문이다. 여기서 검사하면 거절 + * 이유를 계약의 {@code fieldErrors} 모양으로 정확히 실을 수 있다. + */ +final class PublicRequestParams { + + private PublicRequestParams() {} + + static PublicPageRequest page(int page, int size) { + return new PublicPageRequest(page, size); + } + + /** null(=필터 없음)은 통과시키고, 값이 있으면 계약의 허용 집합에 있어야 한다. */ + static String oneOf(String field, String value, Set allowed) { + if (value == null) { + return null; + } + if (!allowed.contains(value)) { + throw PublicException.of( + PublicError.PUBLIC_REQUEST_INVALID, + field + " must be one of " + List.copyOf(allowed) + " but was '" + value + "'"); + } + return value; + } + + /** 값이 없으면 계약의 default 를 쓴다 — 정렬은 optional 이지만 항상 하나로 정해져야 한다. */ + static String sort(String field, String value, String fallback, Set allowed) { + return value == null ? fallback : oneOf(field, value, allowed); + } + + /** 계약 {@code searchPublicResources.q}: minLength 1 / maxLength 100. */ + static String searchTerm(String q) { + String trimmed = q == null ? "" : q.strip(); + if (trimmed.isEmpty()) { + throw PublicException.of(PublicError.PUBLIC_REQUEST_INVALID, "q must not be blank"); + } + if (trimmed.length() > 100) { + throw PublicException.of( + PublicError.PUBLIC_REQUEST_INVALID, "q must be at most 100 characters"); + } + return trimmed; + } + + /** 계약 {@code exploreKnowledge.year}: minimum 2000. */ + static Integer year(Integer year) { + if (year != null && year < 2000) { + throw PublicException.of(PublicError.PUBLIC_REQUEST_INVALID, "year must be 2000 or later"); + } + return year; + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/controller/PublicSiteController.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/controller/PublicSiteController.java new file mode 100644 index 0000000..5b752b7 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/controller/PublicSiteController.java @@ -0,0 +1,55 @@ +package dev.caskeleton.adapter.inbound.web.techlog.publicapi.controller; + +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.HomeResponse; +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ProfileResponse; +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.SiteResponse; +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.mapper.SiteResponseMapper; +import dev.caskeleton.application.techlog.publicsite.query.EmptyQuery; +import dev.caskeleton.application.techlog.publicsite.service.GetPublicHomeUseCase; +import dev.caskeleton.application.techlog.publicsite.service.GetPublicProfileUseCase; +import dev.caskeleton.application.techlog.publicsite.service.GetPublicSiteUseCase; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * 사이트 껍데기 · 홈 · 운영자 프로필. 계약 {@code getPublicSite} / {@code getPublicHome} / {@code + * getPublicProfile}. + * + *

    반환값을 Envelope 로 감싸지 않는다 — {@code EnvelopeBodyAdvice} 가 감싼다. 계약의 {@code Envelope} 스키마로 + * 생성된 DTO 는 쓰지 않는다(그걸 반환하면 봉투가 두 번 씌워진다). + * + *

    경로에 {@code /api} 를 쓰지 않는다 — {@code PresentationWebConfig} 가 {@code + * ca-skeleton.presentation.api-base-path}("/api")를 모든 컨트롤러 매핑에 붙인다. 계약의 {@code servers} 가 {@code + * /api/v1/public} 이므로 여기 매핑은 {@code /v1/public/...} 이어야 최종 주소가 계약과 같아진다. + */ +@RestController +public class PublicSiteController { + + private final GetPublicSiteUseCase getSite; + private final GetPublicHomeUseCase getHome; + private final GetPublicProfileUseCase getProfile; + + public PublicSiteController( + GetPublicSiteUseCase getSite, + GetPublicHomeUseCase getHome, + GetPublicProfileUseCase getProfile) { + this.getSite = getSite; + this.getHome = getHome; + this.getProfile = getProfile; + } + + @GetMapping("/v1/public/site") + public SiteResponse getPublicSite() { + return SiteResponseMapper.site(getSite.handle(new EmptyQuery())); + } + + @GetMapping("/v1/public/home") + public HomeResponse getPublicHome() { + return SiteResponseMapper.home(getHome.handle(new EmptyQuery())); + } + + @GetMapping("/v1/public/profile") + public ProfileResponse getPublicProfile() { + return SiteResponseMapper.profile(getProfile.handle(new EmptyQuery())); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/controller/PublicTopicController.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/controller/PublicTopicController.java new file mode 100644 index 0000000..73ecb23 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/controller/PublicTopicController.java @@ -0,0 +1,35 @@ +package dev.caskeleton.adapter.inbound.web.techlog.publicapi.controller; + +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.TopicDetailResponse; +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.TopicListResponse; +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.mapper.TopicResponseMapper; +import dev.caskeleton.application.techlog.publicsite.query.EmptyQuery; +import dev.caskeleton.application.techlog.publicsite.query.SlugQuery; +import dev.caskeleton.application.techlog.publicsite.service.GetPublicTopicUseCase; +import dev.caskeleton.application.techlog.publicsite.service.ListPublicTopicsUseCase; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RestController; + +/** 주제 목록과 상세. 계약 {@code listPublicTopics} / {@code getPublicTopic}. */ +@RestController +public class PublicTopicController { + + private final ListPublicTopicsUseCase listTopics; + private final GetPublicTopicUseCase getTopic; + + public PublicTopicController(ListPublicTopicsUseCase listTopics, GetPublicTopicUseCase getTopic) { + this.listTopics = listTopics; + this.getTopic = getTopic; + } + + @GetMapping("/v1/public/topics") + public TopicListResponse listPublicTopics() { + return TopicResponseMapper.list(listTopics.handle(new EmptyQuery())); + } + + @GetMapping("/v1/public/topics/{topicSlug}") + public TopicDetailResponse getPublicTopic(@PathVariable("topicSlug") String topicSlug) { + return TopicResponseMapper.detail(getTopic.handle(new SlugQuery(topicSlug))); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/mapper/DocumentResponseMapper.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/mapper/DocumentResponseMapper.java new file mode 100644 index 0000000..c592c9f --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/mapper/DocumentResponseMapper.java @@ -0,0 +1,169 @@ +package dev.caskeleton.adapter.inbound.web.techlog.publicapi.mapper; + +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.CaseDetailResponse; +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.CaseDetailResponseCase; +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.CaseDetailResponseRelations; +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.QuestionDetailResponse; +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.QuestionDetailResponseQuestion; +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.QuestionDetailResponseQuestionResolution; +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.QuestionDetailResponseRelations; +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.QuestionPointGroup; +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.QuestionUpdatePublic; +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ReferenceDetailResponse; +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ReferenceDetailResponseReference; +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ReferenceDetailResponseRelations; +import dev.caskeleton.application.techlog.publicsite.model.CaseDetailView; +import dev.caskeleton.application.techlog.publicsite.model.PublishedDocumentView; +import dev.caskeleton.application.techlog.publicsite.model.PublishedQuestionView; +import dev.caskeleton.application.techlog.publicsite.model.QuestionDetailView; +import dev.caskeleton.application.techlog.publicsite.model.QuestionPointGroupView; +import dev.caskeleton.application.techlog.publicsite.model.QuestionUpdateView; +import dev.caskeleton.application.techlog.publicsite.model.ReferenceDetailView; + +/** + * {@code getPublicCase} / {@code getPublicReference} / {@code getPublicQuestion} 의 응답 조립. + * + *

    Case 와 Reference 는 같은 {@link PublishedDocumentView} 를 읽지만 계약이 요약 필드를 서로 다르게 이름 붙였다 — Case 는 + * {@code problemSummary}/{@code conclusionSummary}, Reference 는 {@code scopeSummary} 다. view 는 + * {@code primarySummary}/{@code secondarySummary} 라는 중립 이름을 쓰고 그 매핑을 여기서 한 번만 한다. ADR-003 이 말하는 + * "API 용어와 Domain 용어 분리"가 이 자리다. + */ +public final class DocumentResponseMapper { + + private DocumentResponseMapper() {} + + public static CaseDetailResponse caseDetail(CaseDetailView view) { + PublishedDocumentView doc = view.document(); + CaseDetailResponseCase body = new CaseDetailResponseCase(); + body.setTitle(doc.title()); + body.setProblemSummary(doc.primarySummary()); + body.setConclusionSummary(doc.secondarySummary()); + body.setEnvironmentSummary(doc.environmentSummary()); + body.setContent(doc.content()); + body.setContentFormat(CaseDetailResponseCase.ContentFormatEnum.fromValue(doc.contentFormat())); + body.setContentFormatVersion(doc.contentFormatVersion()); + body.setPrimaryTopic(PublicResponseMapper.topic(doc.primaryTopic())); + body.setTags(PublicResponseMapper.map(doc.tags(), PublicResponseMapper::tag)); + body.setPrimaryProject(PublicResponseMapper.project(doc.primaryProject())); + body.setCoverAsset(PublicResponseMapper.asset(doc.coverAsset())); + body.setPublishedAt(PublicResponseMapper.at(doc.publishedAt())); + body.setUpdatedAt(PublicResponseMapper.at(doc.updatedAt())); + body.setLastVerifiedAt(PublicResponseMapper.at(doc.lastVerifiedAt())); + + CaseDetailResponseRelations relations = new CaseDetailResponseRelations(); + relations.setOriginQuestion(PublicResponseMapper.related(view.relations().originQuestion())); + relations.setProjectDecisions( + PublicResponseMapper.relatedList(view.relations().projectDecisions())); + relations.setDerivedReferences( + PublicResponseMapper.relatedList(view.relations().derivedReferences())); + relations.setRelatedCases(PublicResponseMapper.relatedList(view.relations().relatedCases())); + + CaseDetailResponse dto = new CaseDetailResponse(); + dto.setCanonicalPath(view.canonicalPath()); + dto.setIndexable(view.indexable()); + dto.setCase(body); + dto.setRelations(relations); + return dto; + } + + public static ReferenceDetailResponse referenceDetail(ReferenceDetailView view) { + PublishedDocumentView doc = view.document(); + ReferenceDetailResponseReference body = new ReferenceDetailResponseReference(); + body.setTitle(doc.title()); + body.setScopeSummary(doc.primarySummary()); + body.setAppliesTo(doc.appliesTo()); + body.setExcludedScope(doc.excludedScope()); + body.setFreshnessStatus( + ReferenceDetailResponseReference.FreshnessStatusEnum.fromValue(doc.freshnessStatus())); + body.setContent(doc.content()); + body.setContentFormat( + ReferenceDetailResponseReference.ContentFormatEnum.fromValue(doc.contentFormat())); + body.setContentFormatVersion(doc.contentFormatVersion()); + body.setPrimaryTopic(PublicResponseMapper.topic(doc.primaryTopic())); + body.setTags(PublicResponseMapper.map(doc.tags(), PublicResponseMapper::tag)); + body.setPrimaryProject(PublicResponseMapper.project(doc.primaryProject())); + body.setCoverAsset(PublicResponseMapper.asset(doc.coverAsset())); + body.setPublishedAt(PublicResponseMapper.at(doc.publishedAt())); + body.setUpdatedAt(PublicResponseMapper.at(doc.updatedAt())); + body.setLastVerifiedAt(PublicResponseMapper.at(doc.lastVerifiedAt())); + + ReferenceDetailResponseRelations relations = new ReferenceDetailResponseRelations(); + relations.setSupportingCases( + PublicResponseMapper.relatedList(view.relations().supportingCases())); + relations.setRelatedDecisions( + PublicResponseMapper.relatedList(view.relations().relatedDecisions())); + relations.setRelatedReferences( + PublicResponseMapper.relatedList(view.relations().relatedReferences())); + + ReferenceDetailResponse dto = new ReferenceDetailResponse(); + dto.setCanonicalPath(view.canonicalPath()); + dto.setIndexable(view.indexable()); + dto.setReference(body); + dto.setRelations(relations); + return dto; + } + + public static QuestionDetailResponse questionDetail(QuestionDetailView view) { + PublishedQuestionView q = view.question(); + QuestionDetailResponseQuestion body = new QuestionDetailResponseQuestion(); + body.setQuestion(q.question()); + body.setSummary(q.summary()); + body.setContext(q.context()); + body.setImportance(q.importance()); + body.setStatus(QuestionDetailResponseQuestion.StatusEnum.fromValue(q.status())); + body.setNextVerification(q.nextVerification()); + body.setPoints(points(q.points())); + body.setUpdates(PublicResponseMapper.map(q.updates(), DocumentResponseMapper::update)); + body.setResolution(resolution(q)); + body.setOpenedAt(PublicResponseMapper.at(q.openedAt())); + body.setUpdatedAt(PublicResponseMapper.at(q.updatedAt())); + + QuestionDetailResponseRelations relations = new QuestionDetailResponseRelations(); + relations.setPrimaryProject(PublicResponseMapper.related(view.relations().primaryProject())); + relations.setResultCase(PublicResponseMapper.related(view.relations().resultCase())); + relations.setProducedDecision( + PublicResponseMapper.related(view.relations().producedDecision())); + relations.setDerivedReferences( + PublicResponseMapper.relatedList(view.relations().derivedReferences())); + + QuestionDetailResponse dto = new QuestionDetailResponse(); + dto.setCanonicalPath(view.canonicalPath()); + dto.setIndexable(view.indexable()); + dto.setQuestion(body); + dto.setRelations(relations); + return dto; + } + + private static QuestionPointGroup points(QuestionPointGroupView view) { + QuestionPointGroup dto = new QuestionPointGroup(); + dto.setFacts(view.facts()); + dto.setAssumptions(view.assumptions()); + dto.setUnknowns(view.unknowns()); + dto.setConstraints(view.constraints()); + return dto; + } + + private static QuestionUpdatePublic update(QuestionUpdateView view) { + QuestionUpdatePublic dto = new QuestionUpdatePublic(); + dto.setType(view.type()); + dto.setTitle(view.title()); + dto.setBodyMarkdown(view.bodyMarkdown()); + dto.setOccurredAt(PublicResponseMapper.at(view.occurredAt())); + return dto; + } + + /** + * 계약은 해결 정보를 별도 nullable object 로 묶었고 view 는 평평하게 들고 있다. 세 값이 전부 비어 있으면 빈 껍데기 object 대신 아예 내보내지 + * 않는다 — 미해결 질문에 {@code resolution: {}} 이 붙으면 소비자가 "해결됐지만 내용이 없다"로 읽는다. + */ + private static QuestionDetailResponseQuestionResolution resolution(PublishedQuestionView q) { + if (q.resolutionType() == null && q.resolutionSummary() == null && q.resolvedAt() == null) { + return null; + } + QuestionDetailResponseQuestionResolution dto = new QuestionDetailResponseQuestionResolution(); + dto.setType(q.resolutionType()); + dto.setSummary(q.resolutionSummary()); + dto.setResolvedAt(PublicResponseMapper.at(q.resolvedAt())); + return dto; + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/mapper/ExploreResponseMapper.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/mapper/ExploreResponseMapper.java new file mode 100644 index 0000000..d053de2 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/mapper/ExploreResponseMapper.java @@ -0,0 +1,90 @@ +package dev.caskeleton.adapter.inbound.web.techlog.publicapi.mapper; + +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.KnowledgeListItem; +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.KnowledgePage; +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.QuestionListItem; +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.QuestionPage; +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.SearchResultItem; +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.SearchResultPage; +import dev.caskeleton.application.techlog.publicsite.model.KnowledgeListItemView; +import dev.caskeleton.application.techlog.publicsite.model.KnowledgePageView; +import dev.caskeleton.application.techlog.publicsite.model.QuestionListItemView; +import dev.caskeleton.application.techlog.publicsite.model.QuestionPageView; +import dev.caskeleton.application.techlog.publicsite.model.SearchResultItemView; +import dev.caskeleton.application.techlog.publicsite.model.SearchResultPageView; + +/** + * {@code exploreKnowledge} / {@code exploreQuestions} / {@code searchPublicResources} 의 응답 조립. + * + *

    {@code fromValue} 는 계약 밖 값을 만나면 예외를 던진다. 그대로 둔다 — 여기서 조용히 null 을 넣으면 required 필드가 빈 채로 나가 소비자 + * 쪽에서 더 늦게, 더 알기 어려운 모양으로 깨진다. 공개 projection 이 계약 밖 상태값을 담고 있다면 그건 데이터 결함이고 500 으로 드러나야 한다({@code + * INTERNAL_ERROR} 는 계약이 열거한 코드다). + */ +public final class ExploreResponseMapper { + + private ExploreResponseMapper() {} + + public static KnowledgePage knowledge(KnowledgePageView view) { + KnowledgePage dto = new KnowledgePage(); + dto.setItems(PublicResponseMapper.map(view.items(), ExploreResponseMapper::knowledgeItem)); + dto.setPage(PublicResponseMapper.page(view.page())); + return dto; + } + + private static KnowledgeListItem knowledgeItem(KnowledgeListItemView view) { + KnowledgeListItem dto = new KnowledgeListItem(); + dto.setType(KnowledgeListItem.TypeEnum.fromValue(view.type())); + dto.setTitle(view.title()); + dto.setPath(view.path()); + dto.setPrimarySummary(view.primarySummary()); + dto.setSecondarySummary(view.secondarySummary()); + dto.setPrimaryTopic(PublicResponseMapper.topic(view.primaryTopic())); + dto.setPrimaryProject(PublicResponseMapper.project(view.primaryProject())); + dto.setPublishedAt(PublicResponseMapper.at(view.publishedAt())); + dto.setLastVerifiedAt(PublicResponseMapper.at(view.lastVerifiedAt())); + dto.setFreshnessStatus(view.freshnessStatus()); + return dto; + } + + public static QuestionPage questions(QuestionPageView view) { + QuestionPage dto = new QuestionPage(); + dto.setItems(PublicResponseMapper.map(view.items(), ExploreResponseMapper::questionItem)); + dto.setPage(PublicResponseMapper.page(view.page())); + return dto; + } + + private static QuestionListItem questionItem(QuestionListItemView view) { + QuestionListItem dto = new QuestionListItem(); + dto.setQuestion(view.question()); + dto.setPath(view.path()); + dto.setStatus(QuestionListItem.StatusEnum.fromValue(view.status())); + dto.setSummary(view.summary()); + dto.setCurrentUnderstanding(view.currentUnderstanding()); + dto.setNextVerification(view.nextVerification()); + dto.setPrimaryProject(PublicResponseMapper.project(view.primaryProject())); + dto.setUpdatedAt(PublicResponseMapper.at(view.updatedAt())); + return dto; + } + + public static SearchResultPage search(SearchResultPageView view) { + SearchResultPage dto = new SearchResultPage(); + dto.setQuery(view.query()); + dto.setItems(PublicResponseMapper.map(view.items(), ExploreResponseMapper::searchItem)); + dto.setPage(PublicResponseMapper.page(view.page())); + return dto; + } + + private static SearchResultItem searchItem(SearchResultItemView view) { + SearchResultItem dto = new SearchResultItem(); + dto.setContentType(view.contentType()); + dto.setTitle(view.title()); + dto.setPath(view.path()); + dto.setSnippet(view.snippet()); + dto.setMatchedFields(view.matchedFields()); + dto.setPrimaryTopic(PublicResponseMapper.topic(view.primaryTopic())); + dto.setPrimaryProject(PublicResponseMapper.project(view.primaryProject())); + dto.setPublishedAt(PublicResponseMapper.at(view.publishedAt())); + dto.setUpdatedAt(PublicResponseMapper.at(view.updatedAt())); + return dto; + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/mapper/ProjectResponseMapper.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/mapper/ProjectResponseMapper.java new file mode 100644 index 0000000..c5642ca --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/mapper/ProjectResponseMapper.java @@ -0,0 +1,113 @@ +package dev.caskeleton.adapter.inbound.web.techlog.publicapi.mapper; + +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ProjectActivityItem; +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ProjectActivityPage; +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ProjectDecisionItem; +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ProjectDecisionPage; +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ProjectDetailResponse; +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ProjectDetailResponseProject; +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ProjectListItem; +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ProjectListResponse; +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ProjectRecordPage; +import dev.caskeleton.application.techlog.publicsite.model.ProjectActivityItemView; +import dev.caskeleton.application.techlog.publicsite.model.ProjectActivityPageView; +import dev.caskeleton.application.techlog.publicsite.model.ProjectDecisionItemView; +import dev.caskeleton.application.techlog.publicsite.model.ProjectDecisionPageView; +import dev.caskeleton.application.techlog.publicsite.model.ProjectDetailView; +import dev.caskeleton.application.techlog.publicsite.model.ProjectListItemView; +import dev.caskeleton.application.techlog.publicsite.model.ProjectRecordPageView; +import dev.caskeleton.application.techlog.publicsite.model.PublishedProjectView; +import java.util.List; + +/** {@code listPublicProjects} 와 프로젝트 상세·하위 목록 세 개의 응답 조립. */ +public final class ProjectResponseMapper { + + private ProjectResponseMapper() {} + + public static ProjectListResponse list(List views) { + ProjectListResponse dto = new ProjectListResponse(); + dto.setItems(PublicResponseMapper.map(views, ProjectResponseMapper::listItem)); + return dto; + } + + private static ProjectListItem listItem(ProjectListItemView view) { + ProjectListItem dto = new ProjectListItem(); + dto.setName(view.name()); + dto.setSlug(view.slug()); + dto.setPath(view.path()); + dto.setOneLinePurpose(view.oneLinePurpose()); + dto.setPhase(view.phase()); + dto.setCurrentObjective(view.currentObjective()); + dto.setNextStep(view.nextStep()); + dto.setUpdatedAt(PublicResponseMapper.at(view.updatedAt())); + return dto; + } + + public static ProjectDetailResponse detail(ProjectDetailView view) { + PublishedProjectView p = view.project(); + ProjectDetailResponseProject body = new ProjectDetailResponseProject(); + body.setName(p.name()); + body.setSlug(p.slug()); + body.setOneLinePurpose(p.oneLinePurpose()); + body.setPurpose(p.purpose()); + body.setBoundary(p.boundary()); + body.setPhase(p.phase()); + body.setCurrentObjective(p.currentObjective()); + body.setNextStep(p.nextStep()); + body.setSystemOverviewMarkdown(p.systemOverviewMarkdown()); + body.setTechnologies(p.technologies()); + body.setUpdatedAt(PublicResponseMapper.at(p.updatedAt())); + + ProjectDetailResponse dto = new ProjectDetailResponse(); + dto.setCanonicalPath(view.canonicalPath()); + dto.setIndexable(view.indexable()); + dto.setProject(body); + dto.setFeaturedDecision(PublicResponseMapper.related(view.featuredDecision())); + dto.setActiveQuestion(PublicResponseMapper.related(view.activeQuestion())); + dto.setSelectedRecords(PublicResponseMapper.relatedList(view.selectedRecords())); + return dto; + } + + public static ProjectDecisionPage decisions(ProjectDecisionPageView view) { + ProjectDecisionPage dto = new ProjectDecisionPage(); + dto.setItems(PublicResponseMapper.map(view.items(), ProjectResponseMapper::decision)); + dto.setPage(PublicResponseMapper.page(view.page())); + return dto; + } + + private static ProjectDecisionItem decision(ProjectDecisionItemView view) { + ProjectDecisionItem dto = new ProjectDecisionItem(); + dto.setId(view.id()); + dto.setStatement(view.statement()); + dto.setStatus(view.status()); + dto.setRationaleSummary(view.rationaleSummary()); + dto.setDecidedAt(PublicResponseMapper.at(view.decidedAt())); + dto.setSourceQuestion(PublicResponseMapper.related(view.sourceQuestion())); + dto.setSourceCase(PublicResponseMapper.related(view.sourceCase())); + return dto; + } + + public static ProjectRecordPage records(ProjectRecordPageView view) { + ProjectRecordPage dto = new ProjectRecordPage(); + dto.setItems(PublicResponseMapper.relatedList(view.items())); + dto.setPage(PublicResponseMapper.page(view.page())); + return dto; + } + + public static ProjectActivityPage activities(ProjectActivityPageView view) { + ProjectActivityPage dto = new ProjectActivityPage(); + dto.setItems(PublicResponseMapper.map(view.items(), ProjectResponseMapper::activity)); + dto.setPage(PublicResponseMapper.page(view.page())); + return dto; + } + + private static ProjectActivityItem activity(ProjectActivityItemView view) { + ProjectActivityItem dto = new ProjectActivityItem(); + dto.setType(view.type()); + dto.setTitle(view.title()); + dto.setSummary(view.summary()); + dto.setOccurredAt(PublicResponseMapper.at(view.occurredAt())); + dto.setRelatedPath(view.relatedPath()); + return dto; + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/mapper/PublicResponseMapper.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/mapper/PublicResponseMapper.java new file mode 100644 index 0000000..72251b9 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/mapper/PublicResponseMapper.java @@ -0,0 +1,142 @@ +package dev.caskeleton.adapter.inbound.web.techlog.publicapi.mapper; + +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.AssetReference; +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ContactLink; +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.LatestEntry; +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.PageMetadata; +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ProjectSummary; +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.RelatedEntry; +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.TagSummary; +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.TopicSummary; +import dev.caskeleton.application.techlog.publicsite.model.AssetReferenceView; +import dev.caskeleton.application.techlog.publicsite.model.ContactLinkView; +import dev.caskeleton.application.techlog.publicsite.model.LatestEntryView; +import dev.caskeleton.application.techlog.publicsite.model.PageMetadataView; +import dev.caskeleton.application.techlog.publicsite.model.ProjectSummaryView; +import dev.caskeleton.application.techlog.publicsite.model.RelatedEntryView; +import dev.caskeleton.application.techlog.publicsite.model.TagSummaryView; +import dev.caskeleton.application.techlog.publicsite.model.TopicSummaryView; +import java.net.URI; +import java.time.Instant; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.List; +import java.util.function.Function; + +/** 여러 응답이 함께 쓰는 조각의 매핑. */ +public final class PublicResponseMapper { + + private PublicResponseMapper() {} + + public static OffsetDateTime at(Instant instant) { + return instant == null ? null : instant.atOffset(ZoneOffset.UTC); + } + + public static TopicSummary topic(TopicSummaryView view) { + if (view == null) { + return null; + } + TopicSummary dto = new TopicSummary(); + dto.setName(view.name()); + dto.setSlug(view.slug()); + return dto; + } + + public static TagSummary tag(TagSummaryView view) { + TagSummary dto = new TagSummary(); + dto.setName(view.name()); + dto.setSlug(view.slug()); + return dto; + } + + public static ProjectSummary project(ProjectSummaryView view) { + if (view == null) { + return null; + } + ProjectSummary dto = new ProjectSummary(); + dto.setName(view.name()); + dto.setSlug(view.slug()); + dto.setPath(view.path()); + return dto; + } + + public static RelatedEntry related(RelatedEntryView view) { + if (view == null) { + return null; + } + RelatedEntry dto = new RelatedEntry(); + dto.setType(RelatedEntry.TypeEnum.fromValue(view.type())); + dto.setTitle(view.title()); + dto.setSummary(view.summary()); + dto.setPath(view.path()); + return dto; + } + + public static AssetReference asset(AssetReferenceView view) { + if (view == null) { + return null; + } + AssetReference dto = new AssetReference(); + dto.setAssetId(view.assetId()); + dto.setUrl(view.url()); + dto.setAltText(view.altText()); + dto.setWidth(view.width()); + dto.setHeight(view.height()); + dto.setContentType(view.contentType()); + return dto; + } + + public static ContactLink contact(ContactLinkView view) { + ContactLink dto = new ContactLink(); + dto.setType(view.type()); + dto.setLabel(view.label()); + dto.setUrl(uri(view.url())); + return dto; + } + + public static LatestEntry latest(LatestEntryView view) { + LatestEntry dto = new LatestEntry(); + dto.setEntryType(LatestEntry.EntryTypeEnum.fromValue(view.entryType())); + dto.setTitle(view.title()); + dto.setSummary(view.summary()); + dto.setPath(view.path()); + dto.setPrimaryTopic(topic(view.primaryTopic())); + dto.setPrimaryProject(project(view.primaryProject())); + dto.setPublishedAt(at(view.publishedAt())); + return dto; + } + + public static PageMetadata page(PageMetadataView view) { + PageMetadata dto = new PageMetadata(); + dto.setNumber(view.number()); + dto.setSize(view.size()); + dto.setTotalElements(view.totalElements()); + dto.setTotalPages(view.totalPages()); + dto.setHasPrevious(view.hasPrevious()); + dto.setHasNext(view.hasNext()); + return dto; + } + + /** + * 계약이 {@code format: uri} 로 선언한 자리. 저장된 값이 URI 로 파싱되지 않으면 그 링크를 내보내지 않는다 — 깨진 주소를 넣는 것보다 없는 편이 + * 낫고, 소비자는 이 필드가 optional 임을 안다. + */ + static URI uri(String value) { + if (value == null || value.isBlank()) { + return null; + } + try { + return URI.create(value); + } catch (IllegalArgumentException e) { + return null; + } + } + + public static List map(List source, Function mapper) { + return source == null ? List.of() : source.stream().map(mapper).toList(); + } + + public static List relatedList(List views) { + return map(views, PublicResponseMapper::related); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/mapper/ReleaseResponseMapper.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/mapper/ReleaseResponseMapper.java new file mode 100644 index 0000000..759262f --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/mapper/ReleaseResponseMapper.java @@ -0,0 +1,48 @@ +package dev.caskeleton.adapter.inbound.web.techlog.publicapi.mapper; + +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ReleaseDetailResponse; +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ReleaseListItem; +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ReleaseListResponse; +import dev.caskeleton.application.techlog.publicsite.model.ReleaseDetailView; +import dev.caskeleton.application.techlog.publicsite.model.ReleaseListItemView; +import java.util.List; + +/** {@code listPublicReleases} / {@code getPublicRelease} 의 응답 조립. */ +public final class ReleaseResponseMapper { + + private ReleaseResponseMapper() {} + + public static ReleaseListResponse list(List views) { + ReleaseListResponse dto = new ReleaseListResponse(); + dto.setItems(PublicResponseMapper.map(views, ReleaseResponseMapper::item)); + return dto; + } + + private static ReleaseListItem item(ReleaseListItemView view) { + ReleaseListItem dto = new ReleaseListItem(); + dto.setVersion(view.version()); + dto.setTitle(view.title()); + dto.setSummary(view.summary()); + dto.setReleasedOn(view.releasedOn()); + dto.setChangeTypes(view.changeTypes()); + dto.setPath(view.path()); + return dto; + } + + public static ReleaseDetailResponse detail(ReleaseDetailView view) { + ReleaseDetailResponse dto = new ReleaseDetailResponse(); + dto.setVersion(view.version()); + dto.setTitle(view.title()); + dto.setSummary(view.summary()); + dto.setReleasedOn(view.releasedOn()); + dto.setChangeTypes(view.changeTypes()); + dto.setReasonMarkdown(view.reasonMarkdown()); + dto.setChangesMarkdown(view.changesMarkdown()); + dto.setUserImpactMarkdown(view.userImpactMarkdown()); + dto.setImplementationImpactMarkdown(view.implementationImpactMarkdown()); + dto.setVerificationMarkdown(view.verificationMarkdown()); + dto.setKnownLimitationsMarkdown(view.knownLimitationsMarkdown()); + dto.setRelatedRecords(PublicResponseMapper.relatedList(view.relatedRecords())); + return dto; + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/mapper/SiteResponseMapper.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/mapper/SiteResponseMapper.java new file mode 100644 index 0000000..abd2364 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/mapper/SiteResponseMapper.java @@ -0,0 +1,145 @@ +package dev.caskeleton.adapter.inbound.web.techlog.publicapi.mapper; + +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.CurrentWorkFocus; +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.HomeResponse; +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.HomeResponseFocus; +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.OpenQuestionFocus; +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ProfileResponse; +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ProfileResponsePosition; +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ProfileResponseTerritoriesInner; +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ProfileResponseTrajectoryInner; +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ProfileResponseWorkingModelInner; +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.RecentDecisionFocus; +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.SiteResponse; +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.SiteResponseBrand; +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.SiteResponseOperator; +import dev.caskeleton.application.techlog.publicsite.model.HomeFocusView; +import dev.caskeleton.application.techlog.publicsite.model.HomeView; +import dev.caskeleton.application.techlog.publicsite.model.ProfileView; +import dev.caskeleton.application.techlog.publicsite.model.SiteView; + +/** {@code getPublicSite} / {@code getPublicHome} / {@code getPublicProfile} 의 응답 조립. */ +public final class SiteResponseMapper { + + private SiteResponseMapper() {} + + public static SiteResponse site(SiteView view) { + SiteResponseBrand brand = new SiteResponseBrand(); + brand.setTitle(view.brandTitle()); + brand.setIdentityStatement(view.identityStatement()); + + SiteResponseOperator operator = new SiteResponseOperator(); + operator.setDisplayName(view.operatorDisplayName()); + operator.setShortIdentity(view.operatorShortIdentity()); + operator.setAvatar(PublicResponseMapper.asset(view.operatorAvatar())); + operator.setProfilePath(view.operatorProfilePath()); + + SiteResponse dto = new SiteResponse(); + dto.setBrand(brand); + dto.setOperator(operator); + dto.setContacts(PublicResponseMapper.map(view.contacts(), PublicResponseMapper::contact)); + return dto; + } + + public static HomeResponse home(HomeView view) { + HomeResponse dto = new HomeResponse(); + dto.setFocus(focus(view.focus())); + dto.setLatestEntries( + PublicResponseMapper.map(view.latestEntries(), PublicResponseMapper::latest)); + return dto; + } + + private static HomeResponseFocus focus(HomeFocusView view) { + HomeResponseFocus dto = new HomeResponseFocus(); + dto.setDefaultType(HomeResponseFocus.DefaultTypeEnum.fromValue(view.defaultType())); + dto.setCurrentWork(currentWork(view.currentWork())); + dto.setOpenQuestion(openQuestion(view.openQuestion())); + dto.setRecentDecision(recentDecision(view.recentDecision())); + return dto; + } + + private static CurrentWorkFocus currentWork(HomeFocusView.CurrentWork view) { + if (view == null) { + return null; + } + CurrentWorkFocus dto = new CurrentWorkFocus(); + dto.setProjectName(view.projectName()); + dto.setProjectPath(view.projectPath()); + dto.setPurpose(view.purpose()); + dto.setPhase(CurrentWorkFocus.PhaseEnum.fromValue(view.phase())); + dto.setCurrentObjective(view.currentObjective()); + dto.setNextStep(view.nextStep()); + dto.setUpdatedAt(PublicResponseMapper.at(view.updatedAt())); + return dto; + } + + private static OpenQuestionFocus openQuestion(HomeFocusView.OpenQuestion view) { + if (view == null) { + return null; + } + OpenQuestionFocus dto = new OpenQuestionFocus(); + dto.setQuestion(view.question()); + dto.setQuestionPath(view.questionPath()); + dto.setSummary(view.summary()); + dto.setKnownFacts(view.knownFacts()); + dto.setUnresolvedPoints(view.unresolvedPoints()); + dto.setNextVerification(view.nextVerification()); + dto.setUpdatedAt(PublicResponseMapper.at(view.updatedAt())); + return dto; + } + + private static RecentDecisionFocus recentDecision(HomeFocusView.RecentDecision view) { + if (view == null) { + return null; + } + RecentDecisionFocus dto = new RecentDecisionFocus(); + dto.setStatement(view.statement()); + dto.setDecisionPath(view.decisionPath()); + dto.setRationale(view.rationale()); + dto.setConsequences(view.consequences()); + dto.setDecidedAt(PublicResponseMapper.at(view.decidedAt())); + return dto; + } + + public static ProfileResponse profile(ProfileView view) { + ProfileResponsePosition position = new ProfileResponsePosition(); + position.setHeadline(view.headline()); + position.setDescription(view.description()); + + ProfileResponse dto = new ProfileResponse(); + dto.setPosition(position); + dto.setWorkingModel( + PublicResponseMapper.map(view.workingModel(), SiteResponseMapper::workingModel)); + dto.setTerritories(PublicResponseMapper.map(view.territories(), SiteResponseMapper::territory)); + dto.setSelectedEvidence(PublicResponseMapper.relatedList(view.selectedEvidence())); + dto.setTrajectory(PublicResponseMapper.map(view.trajectory(), SiteResponseMapper::trajectory)); + dto.setContacts(PublicResponseMapper.map(view.contacts(), PublicResponseMapper::contact)); + return dto; + } + + private static ProfileResponseWorkingModelInner workingModel(ProfileView.NamedDescription view) { + ProfileResponseWorkingModelInner dto = new ProfileResponseWorkingModelInner(); + dto.setName(view.name()); + dto.setDescription(view.description()); + return dto; + } + + private static ProfileResponseTerritoriesInner territory(ProfileView.Territory view) { + ProfileResponseTerritoriesInner dto = new ProfileResponseTerritoriesInner(); + dto.setName(view.name()); + dto.setCurrentQuestion(view.currentQuestion()); + dto.setTopicPath(view.topicPath()); + return dto; + } + + /** + * {@code trajectory} 의 계약 필드는 {@code title} 인데 view 는 {@code workingModel} 과 같은 {@code + * NamedDescription} 을 재사용한다 — 두 목록이 도메인적으로 같은 모양이라 record 를 나누지 않았고, 이름 차이는 여기서 흡수한다. + */ + private static ProfileResponseTrajectoryInner trajectory(ProfileView.NamedDescription view) { + ProfileResponseTrajectoryInner dto = new ProfileResponseTrajectoryInner(); + dto.setTitle(view.name()); + dto.setDescription(view.description()); + return dto; + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/mapper/TopicResponseMapper.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/mapper/TopicResponseMapper.java new file mode 100644 index 0000000..fbbae12 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/mapper/TopicResponseMapper.java @@ -0,0 +1,48 @@ +package dev.caskeleton.adapter.inbound.web.techlog.publicapi.mapper; + +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.TopicDetailResponse; +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.TopicDetailResponseTopic; +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.TopicListItem; +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.TopicListResponse; +import dev.caskeleton.application.techlog.publicsite.model.TopicDetailView; +import dev.caskeleton.application.techlog.publicsite.model.TopicListItemView; +import java.util.List; + +/** {@code listPublicTopics} / {@code getPublicTopic} 의 응답 조립. */ +public final class TopicResponseMapper { + + private TopicResponseMapper() {} + + public static TopicListResponse list(List views) { + TopicListResponse dto = new TopicListResponse(); + dto.setItems(PublicResponseMapper.map(views, TopicResponseMapper::item)); + return dto; + } + + private static TopicListItem item(TopicListItemView view) { + TopicListItem dto = new TopicListItem(); + dto.setName(view.name()); + dto.setSlug(view.slug()); + dto.setDescription(view.description()); + dto.setRecordCount(view.recordCount()); + return dto; + } + + public static TopicDetailResponse detail(TopicDetailView view) { + TopicDetailResponseTopic topic = new TopicDetailResponseTopic(); + topic.setName(view.name()); + topic.setSlug(view.slug()); + topic.setDescription(view.description()); + topic.setScope(view.scope()); + + TopicDetailResponse dto = new TopicDetailResponse(); + dto.setTopic(topic); + dto.setFeaturedReference(PublicResponseMapper.related(view.featuredReference())); + dto.setFeaturedCases(PublicResponseMapper.relatedList(view.featuredCases())); + dto.setActiveQuestions(PublicResponseMapper.relatedList(view.activeQuestions())); + dto.setRelatedProjects(PublicResponseMapper.relatedList(view.relatedProjects())); + dto.setLatestRecords( + PublicResponseMapper.map(view.latestRecords(), PublicResponseMapper::latest)); + return dto; + } +} diff --git a/src/adapter/outbound/persistence-jpa/build.gradle b/src/adapter/outbound/persistence-jpa/build.gradle index a718442..7b0a8a8 100644 --- a/src/adapter/outbound/persistence-jpa/build.gradle +++ b/src/adapter/outbound/persistence-jpa/build.gradle @@ -131,6 +131,13 @@ def postgresqlTechLogStudioPersistenceIntegrationTest = registerPostgreSqlReadin 'postgresqlTechLogStudioPersistenceIntegrationTest', 'dev.caskeleton.adapter.outbound.persistence.techlog.studio.StudioPersistenceIntegrationTest') +// public-v1: 공개 조회 영속 경로(사이트/홈/프로필, 탐색 2종, 주제, 문서 3종, 프로젝트 4종, 릴리스 2종, +// 검색)와 V9 스키마를 실제 PostgreSQL 위에서 돌린다. 같은 이유다 — 표준 check 는 Testcontainers 를 +// 돌리지 않으므로 이 태스크가 없으면 그 SQL 은 한 번도 실행되지 않은 채로 빌드가 통과한다. +def postgresqlTechLogPublicPersistenceIntegrationTest = registerPostgreSqlReadinessTest( + 'postgresqlTechLogPublicPersistenceIntegrationTest', + 'dev.caskeleton.adapter.outbound.persistence.techlog.publicsite.PublicSitePersistenceIntegrationTest') + def verifyJpaSqlConstructionSafety = tasks.register('verifyJpaSqlConstructionSafety') { group = 'verification' description = 'Rejects concatenated SQL construction and non-parameterized PostgreSQL timeout configuration.' diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/publicsite/JdbcPublicDocumentQueryAdapter.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/publicsite/JdbcPublicDocumentQueryAdapter.java new file mode 100644 index 0000000..5a0d327 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/publicsite/JdbcPublicDocumentQueryAdapter.java @@ -0,0 +1,260 @@ +package dev.caskeleton.adapter.outbound.persistence.techlog.publicsite; + +import dev.caskeleton.application.techlog.publicsite.model.AssetReferenceView; +import dev.caskeleton.application.techlog.publicsite.model.CaseDetailView; +import dev.caskeleton.application.techlog.publicsite.model.CaseRelationsView; +import dev.caskeleton.application.techlog.publicsite.model.ProjectSummaryView; +import dev.caskeleton.application.techlog.publicsite.model.PublishedDocumentView; +import dev.caskeleton.application.techlog.publicsite.model.PublishedQuestionView; +import dev.caskeleton.application.techlog.publicsite.model.QuestionDetailView; +import dev.caskeleton.application.techlog.publicsite.model.QuestionPointGroupView; +import dev.caskeleton.application.techlog.publicsite.model.QuestionRelationsView; +import dev.caskeleton.application.techlog.publicsite.model.QuestionUpdateView; +import dev.caskeleton.application.techlog.publicsite.model.ReferenceDetailView; +import dev.caskeleton.application.techlog.publicsite.model.ReferenceRelationsView; +import dev.caskeleton.application.techlog.publicsite.model.TagSummaryView; +import dev.caskeleton.application.techlog.publicsite.model.TopicSummaryView; +import dev.caskeleton.application.techlog.publicsite.port.out.PublicDocumentQueryPort; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import org.springframework.jdbc.core.simple.JdbcClient; +import org.springframework.stereotype.Repository; +import tools.jackson.databind.ObjectMapper; + +/** + * 공개된 Case / Reference / Question 상세. + * + *

    본문은 {@code public_resource_projection.payload}(Studio 렌더 모델)가 아니라 원본 테이블에서 읽는다 — 공개 계약은 블록 배열이 + * 아니라 Markdown 원문과 {@code contentFormat} 을 준다. projection 은 "공개됐는가"와 게시 시각을 정하는 데만 쓴다. + */ +@Repository +public class JdbcPublicDocumentQueryAdapter implements PublicDocumentQueryPort { + + private final JdbcClient jdbcClient; + private final PublicJson json; + private final PublicRelationLookup relations; + + public JdbcPublicDocumentQueryAdapter(JdbcClient jdbcClient, ObjectMapper objectMapper) { + this.jdbcClient = jdbcClient; + this.json = new PublicJson(objectMapper); + this.relations = new PublicRelationLookup(jdbcClient); + } + + @Override + public Optional findCase(String slug) { + return document("CASE", slug) + .map( + row -> + new CaseDetailView( + row.view().canonicalPath(), + true, + row.view(), + new CaseRelationsView( + relations.firstTargetOfType("CASE", row.id(), "QUESTION"), + relations.targetsOfType("CASE", row.id(), "PROJECT_DECISION"), + // 이 Case 에서 파생된 Reference 는 역방향이다 — Reference 쪽이 Case 를 가리킨다. + relations.sourcesOfType(row.id(), "REFERENCE"), + relations.targetsOfType("CASE", row.id(), "CASE")))); + } + + @Override + public Optional findReference(String slug) { + return document("REFERENCE", slug) + .map( + row -> + new ReferenceDetailView( + row.view().canonicalPath(), + true, + row.view(), + new ReferenceRelationsView( + relations.targetsOfType("REFERENCE", row.id(), "CASE"), + relations.targetsOfType("REFERENCE", row.id(), "PROJECT_DECISION"), + relations.targetsOfType("REFERENCE", row.id(), "REFERENCE")))); + } + + /** 관계 조회에 문서 id 가 필요한데 계약의 응답에는 id 가 없다. 뷰 밖으로 id 를 새로 노출하지 않고 이 안에서만 함께 나른다. */ + private record DocumentRow(UUID id, PublishedDocumentView view) {} + + private Optional document(String type, String slug) { + return jdbcClient + .sql( + "SELECT d.id, d.title, d.body_markdown, d.content_format," + + " d.content_format_version, d.cover_asset_id," + + " c.problem_summary, c.conclusion_summary, c.environment_items," + + " r.scope_summary, r.applies_to, r.excluded_scope, r.freshness_status," + + " p.navigation_path, p.published_at, p.updated_at, p.last_verified_at," + + " t.name AS topic_name, t.slug AS topic_slug," + + " pr.name AS project_name, pr.slug AS project_slug," + + " a.content_type AS cover_content_type, a.alt_text AS cover_alt," + + " a.width AS cover_width, a.height AS cover_height" + + " FROM document d" + + " JOIN public_resource_projection p" + + " ON p.resource_type = d.document_type AND p.resource_id = d.id" + + " LEFT JOIN case_detail c ON c.document_id = d.id" + + " LEFT JOIN reference_detail r ON r.document_id = d.id" + + " LEFT JOIN topic t ON t.id = d.primary_topic_id" + + " LEFT JOIN public_resource_project_link l" + + " ON l.resource_type = p.resource_type AND l.resource_id = p.resource_id" + + " AND l.relation_type = 'PRIMARY'" + + " LEFT JOIN project pr ON pr.id = l.project_id" + + " LEFT JOIN asset a ON a.id = d.cover_asset_id" + + " WHERE d.document_type = :type AND d.slug = :slug AND " + + PublicSql.ACTIVE) + .param("type", type) + .param("slug", slug) + .query( + (rs, rowNum) -> { + UUID id = rs.getObject("id", UUID.class); + boolean isCase = "CASE".equals(type); + return new DocumentRow( + id, + new PublishedDocumentView( + type, + rs.getString("navigation_path"), + rs.getString("title"), + // Case 는 문제/결론, Reference 는 범위/적용이 각각 앞뒤 요약 자리에 온다. + isCase ? rs.getString("problem_summary") : rs.getString("scope_summary"), + isCase ? rs.getString("conclusion_summary") : null, + isCase ? json.strings(rs.getString("environment_items")) : List.of(), + isCase ? List.of() : json.strings(rs.getString("applies_to")), + isCase ? List.of() : json.strings(rs.getString("excluded_scope")), + isCase ? null : rs.getString("freshness_status"), + rs.getString("body_markdown"), + rs.getString("content_format"), + rs.getInt("content_format_version"), + topic(rs), + tags(id), + project(rs), + cover(rs), + instant(rs, "published_at"), + instant(rs, "updated_at"), + instant(rs, "last_verified_at"))); + }) + .optional(); + } + + @Override + public Optional findQuestion(String slug) { + return jdbcClient + .sql( + "SELECT q.id, q.question, q.slug, q.summary, q.context_markdown," + + " q.importance_markdown, q.question_status, q.next_verification," + + " q.resolution_type, q.resolution_summary, q.resolved_at, q.opened_at," + + " p.navigation_path, p.updated_at" + + " FROM open_question q" + + " JOIN public_resource_projection p" + + " ON p.resource_type = 'QUESTION' AND p.resource_id = q.id" + + " WHERE q.slug = :slug AND " + + PublicSql.ACTIVE) + .param("slug", slug) + .query( + (rs, rowNum) -> { + UUID id = rs.getObject("id", UUID.class); + PublishedQuestionView question = + new PublishedQuestionView( + rs.getString("question"), + rs.getString("summary"), + rs.getString("context_markdown"), + rs.getString("importance_markdown"), + rs.getString("question_status"), + rs.getString("next_verification"), + new QuestionPointGroupView( + points(id, "FACT"), + points(id, "ASSUMPTION"), + points(id, "UNKNOWN"), + points(id, "CONSTRAINT")), + updates(id), + rs.getString("resolution_type"), + rs.getString("resolution_summary"), + instant(rs, "resolved_at"), + instant(rs, "opened_at"), + instant(rs, "updated_at")); + return new QuestionDetailView( + rs.getString("navigation_path"), + true, + question, + new QuestionRelationsView( + relations.primaryProject("project_question_link", "question_id", id), + relations.firstTargetOfType("QUESTION", id, "CASE"), + relations.firstTargetOfType("QUESTION", id, "PROJECT_DECISION"), + relations.targetsOfType("QUESTION", id, "REFERENCE"))); + }) + .optional(); + } + + private List points(UUID questionId, String pointKind) { + return jdbcClient + .sql( + "SELECT content FROM question_point WHERE question_id = :id AND point_kind = :kind" + + " ORDER BY display_order") + .param("id", questionId) + .param("kind", pointKind) + .query(String.class) + .list(); + } + + /** 공개된 조사 기록만 보여준다 — {@code PRIVATE} 기록은 Studio 안에만 있다. */ + private List updates(UUID questionId) { + return jdbcClient + .sql( + "SELECT update_type, title, body_markdown, occurred_at FROM question_update" + + " WHERE question_id = :id AND update_visibility = 'PUBLIC'" + + " ORDER BY sequence_no") + .param("id", questionId) + .query( + (rs, rowNum) -> + new QuestionUpdateView( + rs.getString("update_type"), + rs.getString("title"), + rs.getString("body_markdown"), + instant(rs, "occurred_at"))) + .list(); + } + + static Instant instant(ResultSet rs, String column) throws SQLException { + var value = rs.getTimestamp(column); + return value == null ? null : value.toInstant(); + } + + static TopicSummaryView topic(ResultSet rs) throws SQLException { + return rs.getString("topic_slug") == null + ? null + : new TopicSummaryView(rs.getString("topic_name"), rs.getString("topic_slug")); + } + + static ProjectSummaryView project(ResultSet rs) throws SQLException { + return rs.getString("project_slug") == null + ? null + : new ProjectSummaryView( + rs.getString("project_name"), + rs.getString("project_slug"), + "/projects/" + rs.getString("project_slug")); + } + + static AssetReferenceView cover(ResultSet rs) throws SQLException { + UUID assetId = rs.getObject("cover_asset_id", UUID.class); + return assetId == null + ? null + : new AssetReferenceView( + assetId, + "/media/" + assetId, + rs.getString("cover_alt"), + (Integer) rs.getObject("cover_width"), + (Integer) rs.getObject("cover_height"), + rs.getString("cover_content_type")); + } + + List tags(UUID documentId) { + return jdbcClient + .sql( + "SELECT g.name, g.slug FROM document_tag dt JOIN tag g ON g.id = dt.tag_id" + + " WHERE dt.document_id = :id ORDER BY dt.display_order") + .param("id", documentId) + .query((rs, rowNum) -> new TagSummaryView(rs.getString("name"), rs.getString("slug"))) + .list(); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/publicsite/JdbcPublicExploreQueryAdapter.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/publicsite/JdbcPublicExploreQueryAdapter.java new file mode 100644 index 0000000..a6de4d3 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/publicsite/JdbcPublicExploreQueryAdapter.java @@ -0,0 +1,226 @@ +package dev.caskeleton.adapter.outbound.persistence.techlog.publicsite; + +import dev.caskeleton.application.techlog.publicsite.model.KnowledgeListItemView; +import dev.caskeleton.application.techlog.publicsite.model.KnowledgePageView; +import dev.caskeleton.application.techlog.publicsite.model.PageMetadataView; +import dev.caskeleton.application.techlog.publicsite.model.ProjectSummaryView; +import dev.caskeleton.application.techlog.publicsite.model.QuestionListItemView; +import dev.caskeleton.application.techlog.publicsite.model.QuestionPageView; +import dev.caskeleton.application.techlog.publicsite.model.TopicSummaryView; +import dev.caskeleton.application.techlog.publicsite.port.out.PublicExploreQueryPort; +import dev.caskeleton.application.techlog.publicsite.query.ExploreKnowledgeQuery; +import dev.caskeleton.application.techlog.publicsite.query.ExploreQuestionsQuery; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.springframework.jdbc.core.simple.JdbcClient; +import org.springframework.stereotype.Repository; + +/** + * 탐색 목록. + * + *

    필터와 정렬을 SQL 로 처리하고 페이지 총계를 같은 조건으로 센다 — 목록과 총계가 다른 조건을 쓰면 마지막 페이지가 비어 보이거나 있지도 않은 페이지 번호가 생긴다. + */ +@Repository +public class JdbcPublicExploreQueryAdapter implements PublicExploreQueryPort { + + private final JdbcClient jdbcClient; + + public JdbcPublicExploreQueryAdapter(JdbcClient jdbcClient) { + this.jdbcClient = jdbcClient; + } + + @Override + public KnowledgePageView knowledge(ExploreKnowledgeQuery query) { + StringBuilder where = + new StringBuilder( + " WHERE " + PublicSql.ACTIVE + " AND p.resource_type IN ('CASE', 'REFERENCE')"); + Map params = new HashMap<>(); + if (query.type() != null) { + where.append(" AND p.resource_type = :type"); + params.put("type", query.type()); + } + if (query.topicSlug() != null) { + where.append(" AND t.slug = :topicSlug"); + params.put("topicSlug", query.topicSlug()); + } + if (query.projectSlug() != null) { + where.append(" AND pr.slug = :projectSlug"); + params.put("projectSlug", query.projectSlug()); + } + if (query.tagSlug() != null) { + where.append( + " AND EXISTS (SELECT 1 FROM public_resource_tag rt JOIN tag g ON g.id = rt.tag_id" + + " WHERE rt.resource_type = p.resource_type AND rt.resource_id = p.resource_id" + + " AND g.slug = :tagSlug)"); + params.put("tagSlug", query.tagSlug()); + } + if (query.year() != null) { + where.append(" AND date_part('year', p.published_at) = :year"); + params.put("year", query.year()); + } + + String joins = + " FROM public_resource_projection p" + + " LEFT JOIN topic t ON t.id = p.primary_topic_id" + + " LEFT JOIN public_resource_project_link l" + + " ON l.resource_type = p.resource_type AND l.resource_id = p.resource_id" + + " AND l.relation_type = 'PRIMARY'" + + " LEFT JOIN project pr ON pr.id = l.project_id"; + + long total = count(joins + where, params); + List items = + page( + "SELECT p.resource_type, p.title, p.navigation_path, p.summary, p.state_code," + + " p.published_at, p.last_verified_at," + + " t.name AS topic_name, t.slug AS topic_slug," + + " pr.name AS project_name, pr.slug AS project_slug" + + joins + + where + + knowledgeOrder(query.sort()), + params, + query.page().size(), + query.page().offset(), + JdbcPublicExploreQueryAdapter::readKnowledge); + + return new KnowledgePageView( + items, PageMetadataView.of(query.page().page(), query.page().size(), total)); + } + + /** 계약의 정렬 세 값. 같은 시각이 여럿일 때 페이지 경계가 흔들리지 않도록 id 를 tie-breaker 로 둔다. */ + private static String knowledgeOrder(String sort) { + String key = + switch (sort == null ? "PUBLISHED_DESC" : sort) { + case "UPDATED_DESC" -> "p.updated_at DESC"; + case "VERIFIED_DESC" -> "p.last_verified_at DESC NULLS LAST"; + default -> "p.published_at DESC"; + }; + return " ORDER BY " + key + ", p.resource_id DESC"; + } + + /** + * 계약 {@code exploreQuestions.sort} 의 세 값. {@code RESOLVED_DESC} 는 미해결 질문에 값이 없으므로 NULLS LAST 로 밀어 + * 낸다 — 그러지 않으면 PostgreSQL 의 DESC 기본값 NULLS FIRST 때문에 미해결 질문이 "가장 최근에 해결된 것" 자리에 올라온다. + */ + private static String questionOrder(String sort) { + String key = + switch (sort == null ? "UPDATED_DESC" : sort) { + case "OPENED_DESC" -> "q.opened_at DESC NULLS LAST"; + case "RESOLVED_DESC" -> "q.resolved_at DESC NULLS LAST"; + default -> "p.updated_at DESC"; + }; + return " ORDER BY " + key + ", p.resource_id DESC"; + } + + private static KnowledgeListItemView readKnowledge(ResultSet rs, int rowNum) throws SQLException { + return new KnowledgeListItemView( + rs.getString("resource_type"), + rs.getString("title"), + rs.getString("navigation_path"), + rs.getString("summary"), + null, + rs.getString("topic_slug") == null + ? null + : new TopicSummaryView(rs.getString("topic_name"), rs.getString("topic_slug")), + rs.getString("project_slug") == null + ? null + : new ProjectSummaryView( + rs.getString("project_name"), + rs.getString("project_slug"), + "/projects/" + rs.getString("project_slug")), + rs.getTimestamp("published_at").toInstant(), + rs.getTimestamp("last_verified_at") == null + ? null + : rs.getTimestamp("last_verified_at").toInstant(), + rs.getString("state_code")); + } + + @Override + public QuestionPageView questions(ExploreQuestionsQuery query) { + StringBuilder where = + new StringBuilder(" WHERE " + PublicSql.ACTIVE + " AND p.resource_type = 'QUESTION'"); + Map params = new HashMap<>(); + if (query.status() != null) { + where.append(" AND p.state_code = :status"); + params.put("status", query.status()); + } + if (query.topicSlug() != null) { + where.append(" AND t.slug = :topicSlug"); + params.put("topicSlug", query.topicSlug()); + } + if (query.projectSlug() != null) { + where.append(" AND pr.slug = :projectSlug"); + params.put("projectSlug", query.projectSlug()); + } + if (query.tagSlug() != null) { + where.append( + " AND EXISTS (SELECT 1 FROM public_resource_tag rt JOIN tag g ON g.id = rt.tag_id" + + " WHERE rt.resource_type = p.resource_type AND rt.resource_id = p.resource_id" + + " AND g.slug = :tagSlug)"); + params.put("tagSlug", query.tagSlug()); + } + + String joins = + " FROM public_resource_projection p" + + " JOIN open_question q ON q.id = p.resource_id" + + " LEFT JOIN topic t ON t.id = p.primary_topic_id" + + " LEFT JOIN public_resource_project_link l" + + " ON l.resource_type = p.resource_type AND l.resource_id = p.resource_id" + + " AND l.relation_type = 'PRIMARY'" + + " LEFT JOIN project pr ON pr.id = l.project_id"; + + long total = count(joins + where, params); + List items = + page( + "SELECT q.question, p.navigation_path, q.question_status, p.summary," + + " q.next_verification, p.updated_at," + + " pr.name AS project_name, pr.slug AS project_slug" + + joins + + where + + questionOrder(query.sort()), + params, + query.page().size(), + query.page().offset(), + (rs, rowNum) -> + new QuestionListItemView( + rs.getString("question"), + rs.getString("navigation_path"), + rs.getString("question_status"), + rs.getString("summary"), + null, + rs.getString("next_verification"), + rs.getString("project_slug") == null + ? null + : new ProjectSummaryView( + rs.getString("project_name"), + rs.getString("project_slug"), + "/projects/" + rs.getString("project_slug")), + rs.getTimestamp("updated_at").toInstant())); + + return new QuestionPageView( + items, PageMetadataView.of(query.page().page(), query.page().size(), total)); + } + + private long count(String fromAndWhere, Map params) { + var spec = jdbcClient.sql("SELECT count(*)" + fromAndWhere); + for (Map.Entry e : params.entrySet()) { + spec = spec.param(e.getKey(), e.getValue()); + } + return spec.query(Long.class).single(); + } + + private List page( + String sql, + Map params, + int size, + int offset, + org.springframework.jdbc.core.RowMapper mapper) { + var spec = jdbcClient.sql(sql + " LIMIT :size OFFSET :offset"); + for (Map.Entry e : params.entrySet()) { + spec = spec.param(e.getKey(), e.getValue()); + } + return spec.param("size", size).param("offset", offset).query(mapper).list(); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/publicsite/JdbcPublicProjectQueryAdapter.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/publicsite/JdbcPublicProjectQueryAdapter.java new file mode 100644 index 0000000..385259f --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/publicsite/JdbcPublicProjectQueryAdapter.java @@ -0,0 +1,330 @@ +package dev.caskeleton.adapter.outbound.persistence.techlog.publicsite; + +import dev.caskeleton.application.techlog.publicsite.model.PageMetadataView; +import dev.caskeleton.application.techlog.publicsite.model.ProjectActivityItemView; +import dev.caskeleton.application.techlog.publicsite.model.ProjectActivityPageView; +import dev.caskeleton.application.techlog.publicsite.model.ProjectDecisionItemView; +import dev.caskeleton.application.techlog.publicsite.model.ProjectDecisionPageView; +import dev.caskeleton.application.techlog.publicsite.model.ProjectDetailView; +import dev.caskeleton.application.techlog.publicsite.model.ProjectListItemView; +import dev.caskeleton.application.techlog.publicsite.model.ProjectRecordPageView; +import dev.caskeleton.application.techlog.publicsite.model.PublishedProjectView; +import dev.caskeleton.application.techlog.publicsite.model.RelatedEntryView; +import dev.caskeleton.application.techlog.publicsite.port.out.PublicProjectQueryPort; +import dev.caskeleton.application.techlog.publicsite.query.ProjectDecisionPageQuery; +import dev.caskeleton.application.techlog.publicsite.query.ProjectPageQuery; +import dev.caskeleton.application.techlog.publicsite.query.ProjectRecordPageQuery; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import org.springframework.jdbc.core.simple.JdbcClient; +import org.springframework.stereotype.Repository; +import tools.jackson.databind.ObjectMapper; + +/** 프로젝트 목록·상세와 그 하위 목록. */ +@Repository +public class JdbcPublicProjectQueryAdapter implements PublicProjectQueryPort { + + private static final int SECTION_LIMIT = 10; + + private final JdbcClient jdbcClient; + private final PublicJson json; + + public JdbcPublicProjectQueryAdapter(JdbcClient jdbcClient, ObjectMapper objectMapper) { + this.jdbcClient = jdbcClient; + this.json = new PublicJson(objectMapper); + } + + @Override + public List list() { + return jdbcClient + .sql( + "SELECT pr.name, pr.slug, pr.one_line_purpose, pr.phase, pr.current_objective," + + " pr.next_step, p.updated_at FROM project pr" + + " JOIN public_resource_projection p" + + " ON p.resource_type = 'PROJECT' AND p.resource_id = pr.id" + + " WHERE " + + PublicSql.ACTIVE + + " ORDER BY pr.featured_order NULLS LAST, p.updated_at DESC") + .query( + (rs, rowNum) -> + new ProjectListItemView( + rs.getString("name"), + rs.getString("slug"), + "/projects/" + rs.getString("slug"), + rs.getString("one_line_purpose"), + rs.getString("phase"), + rs.getString("current_objective"), + rs.getString("next_step"), + JdbcPublicDocumentQueryAdapter.instant(rs, "updated_at"))) + .list(); + } + + @Override + public Optional findBySlug(String slug) { + return jdbcClient + .sql( + "SELECT pr.id, pr.name, pr.slug, pr.one_line_purpose, pr.purpose_markdown," + + " pr.boundary_markdown, pr.phase, pr.current_objective, pr.next_step," + + " pr.system_overview_markdown, pr.technology_labels," + + " p.navigation_path, p.updated_at FROM project pr" + + " JOIN public_resource_projection p" + + " ON p.resource_type = 'PROJECT' AND p.resource_id = pr.id" + + " WHERE pr.slug = :slug AND " + + PublicSql.ACTIVE) + .param("slug", slug) + .query( + (rs, rowNum) -> { + UUID projectId = rs.getObject("id", UUID.class); + PublishedProjectView project = + new PublishedProjectView( + rs.getString("name"), + rs.getString("slug"), + rs.getString("one_line_purpose"), + rs.getString("purpose_markdown"), + rs.getString("boundary_markdown"), + rs.getString("phase"), + rs.getString("current_objective"), + rs.getString("next_step"), + rs.getString("system_overview_markdown"), + json.strings(rs.getString("technology_labels")), + JdbcPublicDocumentQueryAdapter.instant(rs, "updated_at")); + return new ProjectDetailView( + rs.getString("navigation_path"), + true, + project, + featuredDecision(projectId), + activeQuestion(projectId), + selectedRecords(projectId)); + }) + .optional(); + } + + private RelatedEntryView featuredDecision(UUID projectId) { + return jdbcClient + .sql( + "SELECT p.resource_type, p.title, p.summary, p.navigation_path" + + " FROM project_decision d" + + " JOIN public_resource_projection p" + + " ON p.resource_type = 'PROJECT_DECISION' AND p.resource_id = d.id" + + " WHERE d.project_id = :projectId AND " + + PublicSql.ACTIVE + + " ORDER BY d.is_featured DESC, d.decided_at DESC NULLS LAST LIMIT 1") + .param("projectId", projectId) + .query(JdbcPublicTopicQueryAdapter::relatedEntry) + .optional() + .orElse(null); + } + + private RelatedEntryView activeQuestion(UUID projectId) { + return jdbcClient + .sql( + "SELECT p.resource_type, p.title, p.summary, p.navigation_path" + + " FROM project_question_link l" + + " JOIN public_resource_projection p" + + " ON p.resource_type = 'QUESTION' AND p.resource_id = l.question_id" + + " WHERE l.project_id = :projectId AND p.state_code <> 'RESOLVED'" + + " AND " + + PublicSql.ACTIVE + + " ORDER BY p.updated_at DESC LIMIT 1") + .param("projectId", projectId) + .query(JdbcPublicTopicQueryAdapter::relatedEntry) + .optional() + .orElse(null); + } + + private List selectedRecords(UUID projectId) { + return jdbcClient + .sql( + "SELECT p.resource_type, p.title, p.summary, p.navigation_path" + + " FROM public_resource_project_link l" + + " JOIN public_resource_projection p" + + " ON p.resource_type = l.resource_type AND p.resource_id = l.resource_id" + + " WHERE l.project_id = :projectId AND " + + PublicSql.ACTIVE + + " ORDER BY l.featured_order NULLS LAST, p.published_at DESC LIMIT :limit") + .param("projectId", projectId) + .param("limit", SECTION_LIMIT) + .query(JdbcPublicTopicQueryAdapter::relatedEntry) + .list(); + } + + @Override + public Optional decisions(ProjectDecisionPageQuery query) { + return projectId(query.projectSlug()) + .map( + projectId -> { + // 계약의 status 필터. 총계와 목록이 반드시 같은 조건을 써야 마지막 페이지가 비어 보이지 않는다. + String from = + " FROM project_decision d" + + " JOIN public_resource_projection p" + + " ON p.resource_type = 'PROJECT_DECISION' AND p.resource_id = d.id" + + " WHERE d.project_id = :projectId AND " + + PublicSql.ACTIVE + + (query.status() == null ? "" : " AND d.decision_status = :status"); + long total = + bind(jdbcClient.sql("SELECT count(*)" + from), projectId, query.status()) + .query(Long.class) + .single(); + List items = + bind( + jdbcClient.sql( + "SELECT d.id, d.statement, d.decision_status, d.rationale_markdown," + + " d.decided_at, d.source_question_id, d.source_case_id" + + from + + " ORDER BY d.decided_at DESC NULLS LAST, d.id DESC" + + " LIMIT :size OFFSET :offset"), + projectId, + query.status()) + .param("size", query.page().size()) + .param("offset", query.page().offset()) + .query( + (rs, rowNum) -> + new ProjectDecisionItemView( + rs.getObject("id", UUID.class), + rs.getString("statement"), + rs.getString("decision_status"), + rs.getString("rationale_markdown"), + JdbcPublicDocumentQueryAdapter.instant(rs, "decided_at"), + publishedEntry(rs.getObject("source_question_id", UUID.class)), + publishedEntry(rs.getObject("source_case_id", UUID.class)))) + .list(); + return new ProjectDecisionPageView( + items, PageMetadataView.of(query.page().page(), query.page().size(), total)); + }); + } + + /** 지목된 원천이 비공개면 링크를 만들지 않는다 — 404 로 이어지는 링크를 내보내지 않는다. */ + private RelatedEntryView publishedEntry(UUID resourceId) { + if (resourceId == null) { + return null; + } + return jdbcClient + .sql( + "SELECT p.resource_type, p.title, p.summary, p.navigation_path" + + " FROM public_resource_projection p" + + " WHERE p.resource_id = :id AND " + + PublicSql.ACTIVE) + .param("id", resourceId) + .query(JdbcPublicTopicQueryAdapter::relatedEntry) + .optional() + .orElse(null); + } + + @Override + public Optional records(ProjectRecordPageQuery query) { + return projectId(query.projectSlug()) + .map( + projectId -> { + // 계약이 세는 record 는 CASE/REFERENCE/QUESTION 세 종류다. type 이 없으면 셋 다 센다. + String from = + " FROM public_resource_project_link l" + + " JOIN public_resource_projection p" + + " ON p.resource_type = l.resource_type AND p.resource_id = l.resource_id" + + " WHERE l.project_id = :projectId" + + " AND p.resource_type IN ('CASE', 'REFERENCE', 'QUESTION')" + + " AND " + + PublicSql.ACTIVE + + (query.type() == null ? "" : " AND p.resource_type = :type") + + (query.relation() == null ? "" : " AND l.relation_type = :relation"); + long total = + bindRecord(jdbcClient.sql("SELECT count(*)" + from), projectId, query) + .query(Long.class) + .single(); + List items = + bindRecord( + jdbcClient.sql( + "SELECT p.resource_type, p.title, p.summary, p.navigation_path" + + from + + " ORDER BY p.published_at DESC, p.resource_id DESC" + + " LIMIT :size OFFSET :offset"), + projectId, + query) + .param("size", query.page().size()) + .param("offset", query.page().offset()) + .query(JdbcPublicTopicQueryAdapter::relatedEntry) + .list(); + return new ProjectRecordPageView( + items, PageMetadataView.of(query.page().page(), query.page().size(), total)); + }); + } + + @Override + public Optional activities(ProjectPageQuery query) { + return projectId(query.projectSlug()) + .map( + projectId -> { + String from = + " FROM project_activity a" + + " WHERE a.project_id = :projectId AND a.visibility = 'PUBLIC'"; + long total = + jdbcClient + .sql("SELECT count(*)" + from) + .param("projectId", projectId) + .query(Long.class) + .single(); + List items = + jdbcClient + .sql( + "SELECT a.activity_type, a.title, a.summary, a.occurred_at," + + " a.related_resource_id" + + from + + " ORDER BY a.occurred_at DESC, a.id DESC" + + " LIMIT :size OFFSET :offset") + .param("projectId", projectId) + .param("size", query.page().size()) + .param("offset", query.page().offset()) + .query( + (rs, rowNum) -> { + RelatedEntryView related = + publishedEntry(rs.getObject("related_resource_id", UUID.class)); + return new ProjectActivityItemView( + rs.getString("activity_type"), + rs.getString("title"), + rs.getString("summary"), + JdbcPublicDocumentQueryAdapter.instant(rs, "occurred_at"), + related == null ? null : related.path()); + }) + .list(); + return new ProjectActivityPageView( + items, PageMetadataView.of(query.page().page(), query.page().size(), total)); + }); + } + + /** + * optional 필터는 SQL 조각과 파라미터 바인딩을 함께 켜고 꺼야 한다. 조각만 빼고 바인딩을 남기면 JdbcClient 가 "쓰이지 않은 파라미터"로 실패하고, + * 반대면 파라미터 미해결로 실패한다 — 총계와 목록 두 쿼리에서 같은 실수를 두 번 하지 않도록 한 곳에 모은다. + */ + private static org.springframework.jdbc.core.simple.JdbcClient.StatementSpec bind( + org.springframework.jdbc.core.simple.JdbcClient.StatementSpec spec, + UUID projectId, + String status) { + spec = spec.param("projectId", projectId); + return status == null ? spec : spec.param("status", status); + } + + private static org.springframework.jdbc.core.simple.JdbcClient.StatementSpec bindRecord( + org.springframework.jdbc.core.simple.JdbcClient.StatementSpec spec, + UUID projectId, + ProjectRecordPageQuery query) { + spec = spec.param("projectId", projectId); + if (query.type() != null) { + spec = spec.param("type", query.type()); + } + return query.relation() == null ? spec : spec.param("relation", query.relation()); + } + + /** 공개된 프로젝트만 하위 목록을 연다 — 비공개 프로젝트의 결정 목록이 새어 나가면 안 된다. */ + private Optional projectId(String slug) { + return jdbcClient + .sql( + "SELECT pr.id FROM project pr" + + " JOIN public_resource_projection p" + + " ON p.resource_type = 'PROJECT' AND p.resource_id = pr.id" + + " WHERE pr.slug = :slug AND " + + PublicSql.ACTIVE) + .param("slug", slug) + .query(UUID.class) + .optional(); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/publicsite/JdbcPublicReleaseQueryAdapter.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/publicsite/JdbcPublicReleaseQueryAdapter.java new file mode 100644 index 0000000..5ccfb70 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/publicsite/JdbcPublicReleaseQueryAdapter.java @@ -0,0 +1,100 @@ +package dev.caskeleton.adapter.outbound.persistence.techlog.publicsite; + +import dev.caskeleton.application.techlog.publicsite.model.ReleaseDetailView; +import dev.caskeleton.application.techlog.publicsite.model.ReleaseListItemView; +import dev.caskeleton.application.techlog.publicsite.port.out.PublicReleaseQueryPort; +import java.util.List; +import java.util.Optional; +import org.springframework.jdbc.core.simple.JdbcClient; +import org.springframework.stereotype.Repository; +import tools.jackson.databind.ObjectMapper; + +/** + * 릴리스 목록·상세. + * + *

    릴리스는 {@code public_resource_projection} 을 거치지 않는다 — 설계상 Publication 파이프라인의 대상이 아니라 자체 {@code + * workflow_status} 로 공개 여부를 정하는 기록이다. + */ +@Repository +public class JdbcPublicReleaseQueryAdapter implements PublicReleaseQueryPort { + + private final JdbcClient jdbcClient; + private final PublicJson json; + + public JdbcPublicReleaseQueryAdapter(JdbcClient jdbcClient, ObjectMapper objectMapper) { + this.jdbcClient = jdbcClient; + this.json = new PublicJson(objectMapper); + } + + @Override + public List list() { + return jdbcClient + .sql( + "SELECT version_label, title, summary, released_on, change_types FROM release" + + " WHERE workflow_status = 'PUBLISHED'" + + " ORDER BY released_on DESC NULLS LAST, version_label DESC") + .query( + (rs, rowNum) -> + new ReleaseListItemView( + rs.getString("version_label"), + rs.getString("title"), + rs.getString("summary"), + rs.getDate("released_on") == null + ? null + : rs.getDate("released_on").toLocalDate(), + json.strings(rs.getString("change_types")), + "/releases/" + rs.getString("version_label"))) + .list(); + } + + @Override + public Optional findByVersion(String version) { + return jdbcClient + .sql( + "SELECT version_label, title, summary, released_on, change_types, reason_markdown," + + " changes_markdown, user_impact_markdown, implementation_impact_markdown," + + " verification_markdown, known_limitations_markdown, related_resources" + + " FROM release WHERE version_label = :version AND workflow_status = 'PUBLISHED'") + .param("version", version) + .query( + (rs, rowNum) -> + new ReleaseDetailView( + rs.getString("version_label"), + rs.getString("title"), + rs.getString("summary"), + rs.getDate("released_on") == null + ? null + : rs.getDate("released_on").toLocalDate(), + json.strings(rs.getString("change_types")), + rs.getString("reason_markdown"), + rs.getString("changes_markdown"), + rs.getString("user_impact_markdown"), + rs.getString("implementation_impact_markdown"), + rs.getString("verification_markdown"), + rs.getString("known_limitations_markdown"), + relatedRecords(rs.getString("related_resources")))) + .optional(); + } + + /** + * {@code related_resources} 는 resource id 배열이다. 그중 공개된 것만 되살린다 — 릴리스가 지목한 기록이 비공개로 바뀌었을 수 + * 있고, 그 링크를 그대로 내보내면 404 로 이어진다. + */ + private List relatedRecords( + String relatedResourcesJson) { + List ids = json.strings(relatedResourcesJson); + if (ids.isEmpty()) { + return List.of(); + } + return jdbcClient + .sql( + "SELECT p.resource_type, p.title, p.summary, p.navigation_path" + + " FROM public_resource_projection p" + + " WHERE p.resource_id::text IN (:ids) AND " + + PublicSql.ACTIVE + + " ORDER BY p.published_at DESC") + .param("ids", ids) + .query(JdbcPublicTopicQueryAdapter::relatedEntry) + .list(); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/publicsite/JdbcPublicSearchQueryAdapter.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/publicsite/JdbcPublicSearchQueryAdapter.java new file mode 100644 index 0000000..fc921cc --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/publicsite/JdbcPublicSearchQueryAdapter.java @@ -0,0 +1,146 @@ +package dev.caskeleton.adapter.outbound.persistence.techlog.publicsite; + +import dev.caskeleton.application.techlog.publicsite.model.PageMetadataView; +import dev.caskeleton.application.techlog.publicsite.model.ProjectSummaryView; +import dev.caskeleton.application.techlog.publicsite.model.SearchResultItemView; +import dev.caskeleton.application.techlog.publicsite.model.SearchResultPageView; +import dev.caskeleton.application.techlog.publicsite.model.TopicSummaryView; +import dev.caskeleton.application.techlog.publicsite.port.out.PublicSearchQueryPort; +import dev.caskeleton.application.techlog.publicsite.query.SearchQuery; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import org.springframework.jdbc.core.simple.JdbcClient; +import org.springframework.stereotype.Repository; + +/** + * 공개 검색. + * + *

    게시 시 만들어 둔 {@code search_text}(제목 + 요약 + 본문 평문)를 본다. 검색 때 본문을 다시 훑지 않는 이유는 그 평문이 게시 시점에 확정된 + * 값이기 때문이다 — 나중에 초안이 바뀌어도 공개 검색 결과는 공개된 내용을 따라야 한다. + */ +@Repository +public class JdbcPublicSearchQueryAdapter implements PublicSearchQueryPort { + + /** 스니펫 길이. 너무 길면 목록이 읽히지 않고, 너무 짧으면 왜 걸렸는지 알 수 없다. */ + private static final int SNIPPET_LENGTH = 200; + + private final JdbcClient jdbcClient; + + public JdbcPublicSearchQueryAdapter(JdbcClient jdbcClient) { + this.jdbcClient = jdbcClient; + } + + @Override + public SearchResultPageView search(SearchQuery query) { + String pattern = "%" + query.query().toLowerCase(Locale.ROOT) + "%"; + StringBuilder where = + new StringBuilder(" WHERE " + PublicSql.ACTIVE + " AND lower(p.search_text) LIKE :pattern"); + Map params = new HashMap<>(); + params.put("pattern", pattern); + if (query.type() != null) { + where.append(" AND p.resource_type = :type"); + params.put("type", query.type()); + } + if (query.topicSlug() != null) { + where.append(" AND t.slug = :topicSlug"); + params.put("topicSlug", query.topicSlug()); + } + + String joins = + " FROM public_resource_projection p" + + " LEFT JOIN topic t ON t.id = p.primary_topic_id" + + " LEFT JOIN public_resource_project_link l" + + " ON l.resource_type = p.resource_type AND l.resource_id = p.resource_id" + + " AND l.relation_type = 'PRIMARY'" + + " LEFT JOIN project pr ON pr.id = l.project_id"; + + var countSpec = jdbcClient.sql("SELECT count(*)" + joins + where); + for (Map.Entry e : params.entrySet()) { + countSpec = countSpec.param(e.getKey(), e.getValue()); + } + long total = countSpec.query(Long.class).single(); + + var spec = + jdbcClient.sql( + "SELECT p.resource_type, p.title, p.navigation_path, p.summary, p.body_plain_text," + + " p.published_at, p.updated_at," + + " t.name AS topic_name, t.slug AS topic_slug," + + " pr.name AS project_name, pr.slug AS project_slug" + + joins + + where + + " ORDER BY p.published_at DESC, p.resource_id DESC" + + " LIMIT :size OFFSET :offset"); + for (Map.Entry e : params.entrySet()) { + spec = spec.param(e.getKey(), e.getValue()); + } + List items = + spec.param("size", query.page().size()) + .param("offset", query.page().offset()) + .query( + (rs, rowNum) -> + new SearchResultItemView( + rs.getString("resource_type"), + rs.getString("title"), + rs.getString("navigation_path"), + snippet( + rs.getString("body_plain_text"), + rs.getString("summary"), + query.query()), + matchedFields( + query.query(), + rs.getString("title"), + rs.getString("summary"), + rs.getString("body_plain_text")), + rs.getString("topic_slug") == null + ? null + : new TopicSummaryView( + rs.getString("topic_name"), rs.getString("topic_slug")), + rs.getString("project_slug") == null + ? null + : new ProjectSummaryView( + rs.getString("project_name"), + rs.getString("project_slug"), + "/projects/" + rs.getString("project_slug")), + rs.getTimestamp("published_at").toInstant(), + rs.getTimestamp("updated_at").toInstant())) + .list(); + + return new SearchResultPageView( + query.query(), items, PageMetadataView.of(query.page().page(), query.page().size(), total)); + } + + /** 검색어가 나온 자리를 중심으로 잘라 준다. 없으면 요약을 쓴다. */ + private static String snippet(String body, String summary, String term) { + String source = (body == null || body.isBlank()) ? summary : body; + if (source == null || source.isBlank()) { + return ""; + } + int at = source.toLowerCase(Locale.ROOT).indexOf(term.toLowerCase(Locale.ROOT)); + if (at < 0) { + return source.length() <= SNIPPET_LENGTH ? source : source.substring(0, SNIPPET_LENGTH); + } + int from = Math.max(0, at - SNIPPET_LENGTH / 2); + int to = Math.min(source.length(), from + SNIPPET_LENGTH); + return source.substring(from, to); + } + + /** 어느 필드에서 걸렸는지. 사용자가 왜 이 결과가 나왔는지 알 수 있어야 한다. */ + private static List matchedFields( + String term, String title, String summary, String body) { + String needle = term.toLowerCase(Locale.ROOT); + List fields = new ArrayList<>(); + if (title != null && title.toLowerCase(Locale.ROOT).contains(needle)) { + fields.add("title"); + } + if (summary != null && summary.toLowerCase(Locale.ROOT).contains(needle)) { + fields.add("summary"); + } + if (body != null && body.toLowerCase(Locale.ROOT).contains(needle)) { + fields.add("content"); + } + return fields; + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/publicsite/JdbcPublicSiteQueryAdapter.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/publicsite/JdbcPublicSiteQueryAdapter.java new file mode 100644 index 0000000..1e20806 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/publicsite/JdbcPublicSiteQueryAdapter.java @@ -0,0 +1,271 @@ +package dev.caskeleton.adapter.outbound.persistence.techlog.publicsite; + +import dev.caskeleton.application.techlog.publicsite.model.AssetReferenceView; +import dev.caskeleton.application.techlog.publicsite.model.HomeFocusView; +import dev.caskeleton.application.techlog.publicsite.model.HomeView; +import dev.caskeleton.application.techlog.publicsite.model.LatestEntryView; +import dev.caskeleton.application.techlog.publicsite.model.ProfileView; +import dev.caskeleton.application.techlog.publicsite.model.ProjectSummaryView; +import dev.caskeleton.application.techlog.publicsite.model.RelatedEntryView; +import dev.caskeleton.application.techlog.publicsite.model.SiteView; +import dev.caskeleton.application.techlog.publicsite.model.TopicSummaryView; +import dev.caskeleton.application.techlog.publicsite.port.out.PublicSiteQueryPort; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import org.springframework.jdbc.core.simple.JdbcClient; +import org.springframework.stereotype.Repository; +import tools.jackson.databind.ObjectMapper; + +/** 사이트 · 홈 · 프로필. 셋 다 단일 행 테이블이 원천이다. */ +@Repository +public class JdbcPublicSiteQueryAdapter implements PublicSiteQueryPort { + + private final JdbcClient jdbcClient; + private final PublicJson json; + + public JdbcPublicSiteQueryAdapter(JdbcClient jdbcClient, ObjectMapper objectMapper) { + this.jdbcClient = jdbcClient; + this.json = new PublicJson(objectMapper); + } + + @Override + public Optional site() { + return jdbcClient + .sql( + "SELECT s.brand_title, s.identity_statement, s.operator_display_name," + + " s.short_identity, s.contacts, s.avatar_asset_id," + + " a.content_type, a.alt_text, a.width, a.height" + + " FROM site_config s LEFT JOIN asset a ON a.id = s.avatar_asset_id") + .query( + (rs, rowNum) -> + new SiteView( + rs.getString("brand_title"), + rs.getString("identity_statement"), + rs.getString("operator_display_name"), + rs.getString("short_identity"), + avatar(rs), + "/profile", + json.contacts(rs.getString("contacts")))) + .optional(); + } + + private static AssetReferenceView avatar(java.sql.ResultSet rs) throws java.sql.SQLException { + UUID assetId = rs.getObject("avatar_asset_id", UUID.class); + if (assetId == null) { + return null; + } + return new AssetReferenceView( + assetId, + // 본문과 마찬가지로 저장소 경로가 아니라 안정적인 전송 경로를 노출한다(설계 05장 §3.1). + "/media/" + assetId, + rs.getString("alt_text"), + (Integer) rs.getObject("width"), + (Integer) rs.getObject("height"), + rs.getString("content_type")); + } + + @Override + public HomeView home(int latestEntryLimit) { + HomeFocusView focus = + jdbcClient + .sql( + "SELECT default_focus_type, current_project_id, open_question_id," + + " recent_decision_id FROM home_focus_config") + .query( + (rs, rowNum) -> + HomeFocusView.resolve( + rs.getString("default_focus_type"), + currentWork(rs.getObject("current_project_id", UUID.class)), + openQuestion(rs.getObject("open_question_id", UUID.class)), + recentDecision(rs.getObject("recent_decision_id", UUID.class)))) + .optional() + .orElseGet(() -> HomeFocusView.resolve(null, null, null, null)); + return new HomeView(focus, latestEntries(latestEntryLimit)); + } + + /** 지목한 프로젝트가 지워졌거나 비공개면 focus 는 비운다 — 없는 것을 억지로 채우지 않는다. */ + private HomeFocusView.CurrentWork currentWork(UUID projectId) { + if (projectId == null) { + return null; + } + return jdbcClient + .sql( + "SELECT pr.name, pr.slug, pr.one_line_purpose, pr.phase, pr.current_objective," + + " pr.next_step, pr.updated_at FROM project pr" + + " JOIN public_resource_projection p" + + " ON p.resource_type = 'PROJECT' AND p.resource_id = pr.id" + + " WHERE pr.id = :id AND " + + PublicSql.ACTIVE) + .param("id", projectId) + .query( + (rs, rowNum) -> + new HomeFocusView.CurrentWork( + rs.getString("name"), + "/projects/" + rs.getString("slug"), + rs.getString("one_line_purpose"), + rs.getString("phase"), + rs.getString("current_objective"), + rs.getString("next_step"), + rs.getTimestamp("updated_at").toInstant())) + .optional() + .orElse(null); + } + + private HomeFocusView.OpenQuestion openQuestion(UUID questionId) { + if (questionId == null) { + return null; + } + return jdbcClient + .sql( + "SELECT q.id, q.question, q.slug, q.summary, q.next_verification, q.updated_at" + + " FROM open_question q" + + " JOIN public_resource_projection p" + + " ON p.resource_type = 'QUESTION' AND p.resource_id = q.id" + + " WHERE q.id = :id AND " + + PublicSql.ACTIVE) + .param("id", questionId) + .query( + (rs, rowNum) -> + new HomeFocusView.OpenQuestion( + rs.getString("question"), + "/questions/" + rs.getString("slug"), + rs.getString("summary"), + points(questionId, "FACT"), + points(questionId, "UNKNOWN"), + rs.getString("next_verification"), + rs.getTimestamp("updated_at").toInstant())) + .optional() + .orElse(null); + } + + private List points(UUID questionId, String pointKind) { + return jdbcClient + .sql( + "SELECT content FROM question_point WHERE question_id = :id AND point_kind = :kind" + + " ORDER BY display_order") + .param("id", questionId) + .param("kind", pointKind) + .query(String.class) + .list(); + } + + private HomeFocusView.RecentDecision recentDecision(UUID decisionId) { + if (decisionId == null) { + return null; + } + return jdbcClient + .sql( + "SELECT d.statement, d.slug, d.rationale_markdown, d.consequences, d.decided_at," + + " pr.slug AS project_slug FROM project_decision d" + + " LEFT JOIN project pr ON pr.id = d.project_id" + + " JOIN public_resource_projection p" + + " ON p.resource_type = 'PROJECT_DECISION' AND p.resource_id = d.id" + + " WHERE d.id = :id AND " + + PublicSql.ACTIVE) + .param("id", decisionId) + .query( + (rs, rowNum) -> + new HomeFocusView.RecentDecision( + rs.getString("statement"), + PublicSql.pathOf( + "PROJECT_DECISION", rs.getString("slug"), rs.getString("project_slug")), + rs.getString("rationale_markdown"), + json.strings(rs.getString("consequences")), + rs.getTimestamp("decided_at") == null + ? null + : rs.getTimestamp("decided_at").toInstant())) + .optional() + .orElse(null); + } + + /** + * 계약 {@code LatestEntry.entryType} 은 {@code CASE / REFERENCE / PROJECT_ACTIVITY / RELEASE} 네 값만 + * 허용한다. projection 에는 {@code QUESTION}·{@code PROJECT}·{@code PROJECT_DECISION}·{@code PROFILE} 도 + * 들어 있으므로 여기서 걸러야 한다 — 거르지 않으면 응답 매퍼가 계약 밖 값을 만나 500 이 되고, 그 500 은 홈 화면 전체를 못 쓰게 만든다. + * + *

    {@code RELEASE} 가 결과에 없는 것은 누락이 아니다. 릴리스는 Publication 파이프라인을 거치지 않고 자체 {@code + * workflow_status} 로 공개되므로 이 projection 에 아예 행이 없다({@code JdbcPublicReleaseQueryAdapter} 클래스 주석). + * 계약은 그 값을 허용할 뿐 매번 포함하라고 요구하지 않는다. + */ + private List latestEntries(int limit) { + return jdbcClient + .sql( + "SELECT p.resource_type, p.title, p.summary, p.navigation_path, p.published_at," + + " t.name AS topic_name, t.slug AS topic_slug," + + " pr.name AS project_name, pr.slug AS project_slug" + + " FROM public_resource_projection p" + + " LEFT JOIN topic t ON t.id = p.primary_topic_id" + + " LEFT JOIN public_resource_project_link l" + + " ON l.resource_type = p.resource_type AND l.resource_id = p.resource_id" + + " AND l.relation_type = 'PRIMARY'" + + " LEFT JOIN project pr ON pr.id = l.project_id" + + " WHERE " + + PublicSql.ACTIVE + + " AND " + + PublicSql.LATEST_ENTRY_TYPES + + " ORDER BY p.published_at DESC LIMIT :limit") + .param("limit", limit) + .query( + (rs, rowNum) -> + new LatestEntryView( + rs.getString("resource_type"), + rs.getString("title"), + rs.getString("summary"), + rs.getString("navigation_path"), + rs.getString("topic_slug") == null + ? null + : new TopicSummaryView( + rs.getString("topic_name"), rs.getString("topic_slug")), + rs.getString("project_slug") == null + ? null + : new ProjectSummaryView( + rs.getString("project_name"), + rs.getString("project_slug"), + "/projects/" + rs.getString("project_slug")), + rs.getTimestamp("published_at").toInstant())) + .list(); + } + + @Override + public Optional profile() { + return jdbcClient + .sql( + "SELECT headline, introduction_markdown, working_model, territories," + + " selected_evidence, trajectory, contacts FROM profile_page" + + " WHERE target_visibility = 'PUBLIC'") + .query( + (rs, rowNum) -> + new ProfileView( + rs.getString("headline"), + rs.getString("introduction_markdown"), + json.namedDescriptions(rs.getString("working_model")), + json.territories(rs.getString("territories")), + selectedEvidence(rs.getString("selected_evidence")), + json.namedDescriptions(rs.getString("trajectory")), + json.contacts(rs.getString("contacts")))) + .optional(); + } + + /** + * {@code selected_evidence} 는 resource id 배열이다. 그중 공개된 것만 되살린다 — 프로필이 지목한 기록이 비공개로 바뀌었을 수 + * 있고, 그 링크를 그대로 내보내면 404 로 이어진다({@code JdbcPublicReleaseQueryAdapter} 의 {@code related_resources} + * 와 같은 규칙). + */ + private List selectedEvidence(String selectedEvidenceJson) { + List ids = json.strings(selectedEvidenceJson); + if (ids.isEmpty()) { + return List.of(); + } + return jdbcClient + .sql( + "SELECT p.resource_type, p.title, p.summary, p.navigation_path" + + " FROM public_resource_projection p" + + " WHERE p.resource_id::text IN (:ids) AND " + + PublicSql.ACTIVE + + " ORDER BY p.published_at DESC") + .param("ids", ids) + .query(JdbcPublicTopicQueryAdapter::relatedEntry) + .list(); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/publicsite/JdbcPublicTopicQueryAdapter.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/publicsite/JdbcPublicTopicQueryAdapter.java new file mode 100644 index 0000000..0e1cbd8 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/publicsite/JdbcPublicTopicQueryAdapter.java @@ -0,0 +1,178 @@ +package dev.caskeleton.adapter.outbound.persistence.techlog.publicsite; + +import dev.caskeleton.application.techlog.publicsite.model.LatestEntryView; +import dev.caskeleton.application.techlog.publicsite.model.ProjectSummaryView; +import dev.caskeleton.application.techlog.publicsite.model.RelatedEntryView; +import dev.caskeleton.application.techlog.publicsite.model.TopicDetailView; +import dev.caskeleton.application.techlog.publicsite.model.TopicListItemView; +import dev.caskeleton.application.techlog.publicsite.model.TopicSummaryView; +import dev.caskeleton.application.techlog.publicsite.port.out.PublicTopicQueryPort; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import org.springframework.jdbc.core.simple.JdbcClient; +import org.springframework.stereotype.Repository; + +/** 주제 목록·상세. 개수와 목록 모두 공개된 것만 센다. */ +@Repository +public class JdbcPublicTopicQueryAdapter implements PublicTopicQueryPort { + + /** 상세 화면이 한 화면에 담는 개수. */ + private static final int SECTION_LIMIT = 10; + + private final JdbcClient jdbcClient; + + public JdbcPublicTopicQueryAdapter(JdbcClient jdbcClient) { + this.jdbcClient = jdbcClient; + } + + @Override + public List list() { + return jdbcClient + .sql( + "SELECT t.name, t.slug, t.description," + + " (SELECT count(*) FROM public_resource_projection p" + + " WHERE p.primary_topic_id = t.id AND " + + PublicSql.ACTIVE + + ") AS record_count" + + " FROM topic t WHERE t.status = 'ACTIVE' ORDER BY t.name") + .query( + (rs, rowNum) -> + new TopicListItemView( + rs.getString("name"), + rs.getString("slug"), + rs.getString("description"), + rs.getInt("record_count"))) + .list(); + } + + @Override + public Optional findBySlug(String slug) { + return jdbcClient + .sql( + "SELECT id, name, slug, description, scope FROM topic WHERE slug = :slug AND status = 'ACTIVE'") + .param("slug", slug) + .query( + (rs, rowNum) -> { + UUID topicId = rs.getObject("id", UUID.class); + return new TopicDetailView( + rs.getString("name"), + rs.getString("slug"), + rs.getString("description"), + rs.getString("scope"), + featured(topicId, "START_HERE").stream().findFirst().orElse(null), + featured(topicId, "FEATURED_CASE"), + activeQuestions(topicId), + relatedProjects(topicId), + latestRecords(topicId)); + }) + .optional(); + } + + /** + * {@code topic_featured_document} 가 지목한 문서 중 공개된 것만 보여준다 — 지목은 Studio 의 편집 행위이고 공개 여부와 + * 별개다. + */ + private List featured(UUID topicId, String role) { + return jdbcClient + .sql( + "SELECT p.resource_type, p.title, p.summary, p.navigation_path" + + " FROM topic_featured_document f" + + " JOIN public_resource_projection p ON p.resource_id = f.document_id" + + " WHERE f.topic_id = :topicId AND f.feature_role = :role AND " + + PublicSql.ACTIVE + + " ORDER BY f.display_order") + .param("topicId", topicId) + .param("role", role) + .query(JdbcPublicTopicQueryAdapter::relatedEntry) + .list(); + } + + private List activeQuestions(UUID topicId) { + return jdbcClient + .sql( + "SELECT p.resource_type, p.title, p.summary, p.navigation_path" + + " FROM public_resource_projection p" + + " WHERE p.resource_type = 'QUESTION' AND p.primary_topic_id = :topicId" + + " AND p.state_code <> 'RESOLVED' AND " + + PublicSql.ACTIVE + + " ORDER BY p.updated_at DESC LIMIT :limit") + .param("topicId", topicId) + .param("limit", SECTION_LIMIT) + .query(JdbcPublicTopicQueryAdapter::relatedEntry) + .list(); + } + + private List relatedProjects(UUID topicId) { + return jdbcClient + .sql( + "SELECT p.resource_type, p.title, p.summary, p.navigation_path" + + " FROM project_topic pt" + + " JOIN public_resource_projection p" + + " ON p.resource_type = 'PROJECT' AND p.resource_id = pt.project_id" + + " WHERE pt.topic_id = :topicId AND " + + PublicSql.ACTIVE + + " ORDER BY pt.display_order") + .param("topicId", topicId) + .query(JdbcPublicTopicQueryAdapter::relatedEntry) + .list(); + } + + /** + * 계약 {@code LatestEntry.entryType} 은 {@code CASE / REFERENCE / PROJECT_ACTIVITY / RELEASE} 네 값만 + * 허용한다. projection 에는 {@code QUESTION}·{@code PROJECT}·{@code PROJECT_DECISION}·{@code PROFILE} 도 + * 들어 있으므로 여기서 걸러야 한다 — 거르지 않으면 응답 매퍼가 계약 밖 값을 만나 500 이 되고, 그 500 은 홈 화면 전체를 못 쓰게 만든다. + * + *

    {@code RELEASE} 가 결과에 없는 것은 누락이 아니다. 릴리스는 Publication 파이프라인을 거치지 않고 자체 {@code + * workflow_status} 로 공개되므로 이 projection 에 아예 행이 없다({@code JdbcPublicReleaseQueryAdapter} 클래스 주석). + * 계약은 그 값을 허용할 뿐 매번 포함하라고 요구하지 않는다. + */ + private List latestRecords(UUID topicId) { + return jdbcClient + .sql( + "SELECT p.resource_type, p.title, p.summary, p.navigation_path, p.published_at," + + " t.name AS topic_name, t.slug AS topic_slug," + + " pr.name AS project_name, pr.slug AS project_slug" + + " FROM public_resource_projection p" + + " LEFT JOIN topic t ON t.id = p.primary_topic_id" + + " LEFT JOIN public_resource_project_link l" + + " ON l.resource_type = p.resource_type AND l.resource_id = p.resource_id" + + " AND l.relation_type = 'PRIMARY'" + + " LEFT JOIN project pr ON pr.id = l.project_id" + + " WHERE p.primary_topic_id = :topicId AND " + + PublicSql.ACTIVE + + " AND " + + PublicSql.LATEST_ENTRY_TYPES + + " ORDER BY p.published_at DESC LIMIT :limit") + .param("topicId", topicId) + .param("limit", SECTION_LIMIT) + .query( + (rs, rowNum) -> + new LatestEntryView( + rs.getString("resource_type"), + rs.getString("title"), + rs.getString("summary"), + rs.getString("navigation_path"), + rs.getString("topic_slug") == null + ? null + : new TopicSummaryView( + rs.getString("topic_name"), rs.getString("topic_slug")), + rs.getString("project_slug") == null + ? null + : new ProjectSummaryView( + rs.getString("project_name"), + rs.getString("project_slug"), + "/projects/" + rs.getString("project_slug")), + rs.getTimestamp("published_at").toInstant())) + .list(); + } + + static RelatedEntryView relatedEntry(java.sql.ResultSet rs, int rowNum) + throws java.sql.SQLException { + return new RelatedEntryView( + rs.getString("resource_type"), + rs.getString("title"), + rs.getString("summary"), + rs.getString("navigation_path")); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/publicsite/PublicJson.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/publicsite/PublicJson.java new file mode 100644 index 0000000..9c4bbc4 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/publicsite/PublicJson.java @@ -0,0 +1,81 @@ +package dev.caskeleton.adapter.outbound.persistence.techlog.publicsite; + +import dev.caskeleton.application.techlog.publicsite.model.ContactLinkView; +import dev.caskeleton.application.techlog.publicsite.model.ProfileView; +import dev.caskeleton.shared.error.MappingException; +import java.util.ArrayList; +import java.util.List; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; + +/** + * 공개 조회가 읽는 jsonb 컬럼을 푼다. + * + *

    Jackson 의 POJO 바인딩을 쓰지 않고 key 를 명시적으로 읽는다 — 이 값들은 DB 에 영속된 모양이라 application record 의 필드 이름이 + * 바뀌면 이미 저장된 행을 못 읽게 된다. + */ +final class PublicJson { + + private final ObjectMapper mapper; + + PublicJson(ObjectMapper mapper) { + this.mapper = mapper; + } + + List strings(String json) { + List out = new ArrayList<>(); + for (JsonNode node : array(json)) { + // 설계의 배열 컬럼은 문자열이거나 {text: ...} 모양일 수 있다. 둘 다 받는다. + out.add(node.isString() ? node.asString("") : node.path("text").asString(node.toString())); + } + return out; + } + + List contacts(String json) { + List out = new ArrayList<>(); + for (JsonNode node : array(json)) { + out.add( + new ContactLinkView( + node.path("type").asString(""), + node.path("label").asString(""), + node.path("url").asString(""))); + } + return out; + } + + List namedDescriptions(String json) { + List out = new ArrayList<>(); + for (JsonNode node : array(json)) { + out.add( + new ProfileView.NamedDescription( + node.path("name").asString(node.path("title").asString("")), + node.path("description").asString(""))); + } + return out; + } + + List territories(String json) { + List out = new ArrayList<>(); + for (JsonNode node : array(json)) { + out.add( + new ProfileView.Territory( + node.path("name").asString(""), + node.path("currentQuestion").asString(null), + node.path("topicPath").asString(null))); + } + return out; + } + + private Iterable array(String json) { + if (json == null || json.isBlank()) { + return List.of(); + } + try { + JsonNode node = mapper.readTree(json); + return node.isArray() ? node : List.of(); + } catch (JacksonException e) { + throw new MappingException("failed to read a public jsonb column", e); + } + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/publicsite/PublicRelationLookup.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/publicsite/PublicRelationLookup.java new file mode 100644 index 0000000..3b75e51 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/publicsite/PublicRelationLookup.java @@ -0,0 +1,87 @@ +package dev.caskeleton.adapter.outbound.persistence.techlog.publicsite; + +import dev.caskeleton.application.techlog.publicsite.model.RelatedEntryView; +import java.util.List; +import java.util.UUID; +import org.springframework.jdbc.core.simple.JdbcClient; + +/** + * 공개 상세가 보여주는 관계. + * + *

    어디서 읽는지가 중요하다. 설계 스키마에는 유형별 링크 테이블({@code document_relation}, {@code + * question_document_link})이 있지만 그 테이블들에 쓰는 경로가 없다 — Studio 편집기가 만드는 관계는 전부 {@code + * studio_relation} 에 들어간다(계약의 relations[] 가 네 유형 공통이라 그렇게 설계했다). 그래서 공개도 같은 곳에서 읽는다. 링크 테이블을 읽으면 + * 관계가 항상 비어 보인다. + * + *

    관계의 종류는 저장돼 있지 않으므로 대상의 유형으로 나눈다 — 계약이 관계를 유형별 묶음 (relatedCases / derivedReferences / + * projectDecisions / originQuestion)으로 요구하기 때문이다. 공개되지 않은 대상은 제외한다. + */ +final class PublicRelationLookup { + + private final JdbcClient jdbcClient; + + PublicRelationLookup(JdbcClient jdbcClient) { + this.jdbcClient = jdbcClient; + } + + /** {@code sourceKind} 문서가 가리키는 관계 중 대상이 {@code targetType} 이고 공개된 것들. */ + List targetsOfType(String sourceKind, UUID sourceId, String targetType) { + return jdbcClient + .sql( + "SELECT p.resource_type, p.title, p.summary, p.navigation_path" + + " FROM studio_relation r" + + " JOIN public_resource_projection p ON p.resource_id = r.target_id" + + " WHERE r.source_kind = :sourceKind AND r.source_id = :sourceId" + + " AND p.resource_type = :targetType AND " + + PublicSql.ACTIVE + + " ORDER BY r.display_order") + .param("sourceKind", sourceKind) + .param("sourceId", sourceId) + .param("targetType", targetType) + .query(JdbcPublicTopicQueryAdapter::relatedEntry) + .list(); + } + + /** 같은 조회의 단수형. 계약이 하나만 받는 자리(originQuestion 등)에 쓴다. */ + RelatedEntryView firstTargetOfType(String sourceKind, UUID sourceId, String targetType) { + return targetsOfType(sourceKind, sourceId, targetType).stream().findFirst().orElse(null); + } + + /** 이 기록을 가리키는 역방향 관계. "이 Reference 를 적용한 Case" 같은 자리에 쓴다. */ + List sourcesOfType(UUID targetId, String sourceType) { + return jdbcClient + .sql( + "SELECT p.resource_type, p.title, p.summary, p.navigation_path" + + " FROM studio_relation r" + + " JOIN public_resource_projection p ON p.resource_id = r.source_id" + + " WHERE r.target_id = :targetId AND p.resource_type = :sourceType" + + " AND " + + PublicSql.ACTIVE + + " ORDER BY p.published_at DESC") + .param("targetId", targetId) + .param("sourceType", sourceType) + .query(JdbcPublicTopicQueryAdapter::relatedEntry) + .list(); + } + + /** 이 기록이 속한 프로젝트. {@code project_*_link} 의 PRIMARY 를 따른다. */ + RelatedEntryView primaryProject(String linkTable, String idColumn, UUID id) { + return jdbcClient + .sql( + "SELECT p.resource_type, p.title, p.summary, p.navigation_path" + + " FROM " + + linkTable + + " l" + + " JOIN public_resource_projection p" + + " ON p.resource_type = 'PROJECT' AND p.resource_id = l.project_id" + + " WHERE l." + + idColumn + + " = :id AND l.relation_type = 'PRIMARY'" + + " AND " + + PublicSql.ACTIVE) + .param("id", id) + .query(JdbcPublicTopicQueryAdapter::relatedEntry) + .optional() + .orElse(null); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/publicsite/PublicSql.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/publicsite/PublicSql.java new file mode 100644 index 0000000..4d9487c --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/publicsite/PublicSql.java @@ -0,0 +1,36 @@ +package dev.caskeleton.adapter.outbound.persistence.techlog.publicsite; + +/** + * 공개 조회가 공유하는 SQL 조각. + * + *

    "무엇이 공개인가"의 정의를 한 곳에 둔다. 각 쿼리가 조건을 따로 쓰면 어느 하나가 {@code publication_state} 를 빠뜨려도 드러나지 않고, 그 + * 결과는 게시 취소한 문서가 계속 보이는 사고다. + */ +final class PublicSql { + + /** 공개 노출 조건. 게시 취소({@code WITHDRAWN})와 비공개({@code UNLISTED})를 함께 배제한다. */ + static final String ACTIVE = " p.publication_state = 'ACTIVE' AND p.visibility = 'PUBLIC' "; + + /** + * 계약 {@code LatestEntry.entryType} 이 허용하는 값 중 이 projection 에 실제로 담기는 것들. 홈과 주제 상세가 같은 목록 의미를 쓰므로 + * 조건도 한 곳에서 정의한다. + */ + static final String LATEST_ENTRY_TYPES = + " p.resource_type IN ('CASE', 'REFERENCE', 'PROJECT_ACTIVITY') "; + + private PublicSql() {} + + /** 유형별 공개 경로. 게시 시 {@code navigation_path} 에 저장된 값을 그대로 쓴다. */ + static String pathOf(String resourceType, String slug, String projectSlug) { + return switch (resourceType) { + case "CASE" -> "/cases/" + slug; + case "REFERENCE" -> "/references/" + slug; + case "QUESTION" -> "/questions/" + slug; + case "PROJECT" -> "/projects/" + slug; + case "PROJECT_DECISION" -> + projectSlug == null ? null : "/projects/" + projectSlug + "/decisions/" + slug; + case "RELEASE" -> "/releases/" + slug; + default -> null; + }; + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/resources/db/migration/postgresql/V9__techlog_public_surface.sql b/src/adapter/outbound/persistence-jpa/src/main/resources/db/migration/postgresql/V9__techlog_public_surface.sql new file mode 100644 index 0000000..feb9835 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/resources/db/migration/postgresql/V9__techlog_public_surface.sql @@ -0,0 +1,170 @@ +-- public-v1 계약이 요구하는 나머지 테이블. +-- +-- 원본: tech-log-design-package/database/V1__init.sql (설계 패키지 커밋 55a9599 기준) +-- +-- V7 이 이 여섯을 제외하며 남긴 이유는 "이번 범위 밖(spec §2.2)" 이었다. 그 §2.2 가 +-- 미룬 것이 바로 public-v1 이고, 여섯 테이블은 전부 public-v1 전용이다. +-- +-- release -> listPublicReleases / getPublicRelease +-- site_config -> getPublicSite +-- profile_page -> getPublicProfile +-- home_focus_config -> getPublicHome (focus) +-- project_topic -> getPublicTopic (relatedProjects) +-- topic_featured_document -> getPublicTopic (featuredReference / featuredCases) +-- +-- 원본 DDL 을 그대로 옮긴다. V7 이 tech_log 전용 스키마를 쓰지 않고 public 스키마에 +-- 만들기로 한 결정만 이어받는다(원본의 CREATE SCHEMA / SET search_path 는 V7 이 이미 제외했다). +-- +-- 시딩 INSERT 3건도 원본 그대로 가져온다. site_config / profile_page / +-- home_focus_config 는 단일 행 테이블이고(PK 가 고정 UUID 로 CHECK 되어 있다) 그 행이 +-- 없으면 getPublicSite / getPublicProfile / getPublicHome 이 줄 것이 없다. 이 세 값을 +-- 편집하는 API 는 studio-management-v1 이 소유하며 아직 구현 범위 밖이라, 지금은 이 +-- 시딩이 유일한 공급원이다. + + +CREATE TABLE release ( + id uuid PRIMARY KEY, + version_label varchar(32) NOT NULL, + title varchar(180) NOT NULL, + summary varchar(600) NOT NULL DEFAULT '', + released_on date, + workflow_status varchar(20) NOT NULL DEFAULT 'DRAFT' + CHECK (workflow_status IN ('DRAFT', 'PUBLISHED', 'ARCHIVED')), + change_types jsonb NOT NULL DEFAULT '[]'::jsonb + CHECK (jsonb_typeof(change_types) = 'array'), + reason_markdown text NOT NULL DEFAULT '', + changes_markdown text NOT NULL DEFAULT '', + user_impact_markdown text NOT NULL DEFAULT '', + implementation_impact_markdown text NOT NULL DEFAULT '', + verification_markdown text NOT NULL DEFAULT '', + known_limitations_markdown text NOT NULL DEFAULT '', + related_resources jsonb NOT NULL DEFAULT '[]'::jsonb + CHECK (jsonb_typeof(related_resources) = 'array'), + 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_release_version_label UNIQUE (version_label) +); + +CREATE TABLE site_config ( + id uuid PRIMARY KEY CHECK (id = '00000000-0000-0000-0000-000000000001'::uuid), + brand_title varchar(80) NOT NULL DEFAULT 'Tech Log', + identity_statement varchar(600) NOT NULL DEFAULT '', + operator_display_name varchar(80) NOT NULL DEFAULT '', + short_identity varchar(120), + avatar_asset_id uuid REFERENCES asset(id), + contacts jsonb NOT NULL DEFAULT '[]'::jsonb + CHECK (jsonb_typeof(contacts) = 'array'), + 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 +); + +CREATE TABLE profile_page ( + id uuid PRIMARY KEY CHECK (id = '00000000-0000-0000-0000-000000000002'::uuid), + headline varchar(300) NOT NULL DEFAULT '', + introduction_markdown text NOT NULL DEFAULT '', + working_model jsonb NOT NULL DEFAULT '[]'::jsonb + CHECK (jsonb_typeof(working_model) = 'array'), + territories jsonb NOT NULL DEFAULT '[]'::jsonb + CHECK (jsonb_typeof(territories) = 'array'), + selected_evidence jsonb NOT NULL DEFAULT '[]'::jsonb + CHECK (jsonb_typeof(selected_evidence) = 'array'), + trajectory jsonb NOT NULL DEFAULT '[]'::jsonb + CHECK (jsonb_typeof(trajectory) = 'array'), + contacts jsonb NOT NULL DEFAULT '[]'::jsonb + CHECK (jsonb_typeof(contacts) = 'array'), + target_visibility varchar(20) NOT NULL DEFAULT 'PRIVATE' + CHECK (target_visibility IN ('PRIVATE', 'PUBLIC')), + 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 +); + +CREATE TABLE home_focus_config ( + id uuid PRIMARY KEY CHECK (id = '00000000-0000-0000-0000-000000000003'::uuid), + default_focus_type varchar(30) + CHECK (default_focus_type IS NULL OR default_focus_type IN ( + 'CURRENT_WORK', 'OPEN_QUESTION', 'RECENT_DECISION' + )), + current_project_id uuid REFERENCES project(id), + open_question_id uuid REFERENCES open_question(id), + recent_decision_id uuid REFERENCES project_decision(id), + 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 +); + +CREATE TABLE project_topic ( + project_id uuid NOT NULL REFERENCES project(id) ON DELETE CASCADE, + topic_id uuid NOT NULL REFERENCES topic(id), + display_order integer NOT NULL CHECK (display_order >= 0), + PRIMARY KEY (project_id, topic_id), + CONSTRAINT uq_project_topic_order UNIQUE (project_id, display_order) +); + +CREATE TABLE topic_featured_document ( + topic_id uuid NOT NULL REFERENCES topic(id) ON DELETE CASCADE, + document_id uuid NOT NULL REFERENCES document(id), + feature_role varchar(30) NOT NULL + CHECK (feature_role IN ('START_HERE', 'FEATURED_CASE')), + display_order integer NOT NULL DEFAULT 0 CHECK (display_order >= 0), + PRIMARY KEY (topic_id, document_id, feature_role) +); + +-- 한 Topic 의 START_HERE 는 하나뿐이다. +CREATE UNIQUE INDEX uq_topic_start_here + ON topic_featured_document(topic_id) + WHERE feature_role = 'START_HERE'; + +-- 단일 행 시딩. 이미 있으면 건드리지 않는다. + +INSERT INTO site_config ( + id, + brand_title, + identity_statement, + operator_display_name, + short_identity, + created_by, + updated_by +) VALUES ( + '00000000-0000-0000-0000-000000000001'::uuid, + 'Tech Log', + '문제를 재현하고 검증하여 운영 가능한 시스템 설계로 연결합니다.', + '동현', + 'Backend · Platform', + 'system:migration', + 'system:migration' +) ON CONFLICT (id) DO NOTHING; + +INSERT INTO profile_page ( + id, + created_by, + updated_by +) VALUES ( + '00000000-0000-0000-0000-000000000002'::uuid, + 'system:migration', + 'system:migration' +) ON CONFLICT (id) DO NOTHING; + +INSERT INTO home_focus_config ( + id, + created_by, + updated_by +) VALUES ( + '00000000-0000-0000-0000-000000000003'::uuid, + 'system:migration', + 'system:migration' +) ON CONFLICT (id) DO NOTHING; diff --git a/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/techlog/publicsite/PublicSitePersistenceIntegrationTest.java b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/techlog/publicsite/PublicSitePersistenceIntegrationTest.java new file mode 100644 index 0000000..e674b84 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/techlog/publicsite/PublicSitePersistenceIntegrationTest.java @@ -0,0 +1,965 @@ +package dev.caskeleton.adapter.outbound.persistence.techlog.publicsite; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.zaxxer.hikari.HikariConfig; +import com.zaxxer.hikari.HikariDataSource; +import dev.caskeleton.application.techlog.publicsite.model.CaseDetailView; +import dev.caskeleton.application.techlog.publicsite.model.HomeView; +import dev.caskeleton.application.techlog.publicsite.model.KnowledgePageView; +import dev.caskeleton.application.techlog.publicsite.model.ProfileView; +import dev.caskeleton.application.techlog.publicsite.model.ProjectDetailView; +import dev.caskeleton.application.techlog.publicsite.model.QuestionDetailView; +import dev.caskeleton.application.techlog.publicsite.model.QuestionPageView; +import dev.caskeleton.application.techlog.publicsite.model.ReferenceDetailView; +import dev.caskeleton.application.techlog.publicsite.model.ReleaseDetailView; +import dev.caskeleton.application.techlog.publicsite.model.SearchResultPageView; +import dev.caskeleton.application.techlog.publicsite.model.SiteView; +import dev.caskeleton.application.techlog.publicsite.model.TopicDetailView; +import dev.caskeleton.application.techlog.publicsite.query.ExploreKnowledgeQuery; +import dev.caskeleton.application.techlog.publicsite.query.ExploreQuestionsQuery; +import dev.caskeleton.application.techlog.publicsite.query.ProjectDecisionPageQuery; +import dev.caskeleton.application.techlog.publicsite.query.ProjectPageQuery; +import dev.caskeleton.application.techlog.publicsite.query.ProjectRecordPageQuery; +import dev.caskeleton.application.techlog.publicsite.query.PublicPageRequest; +import dev.caskeleton.application.techlog.publicsite.query.SearchQuery; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.List; +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; + +/** + * 공개 조회 영속 경로 전체를 실제 PostgreSQL 위에서 돌린다. + * + *

    {@code StudioPersistenceIntegrationTest} 와 같은 이유로 존재한다 — 이 저장소의 표준 {@code check} 는 + * Testcontainers 통합 테스트를 돌리지 않으므로, 여기 있는 SQL 은 이 테스트 없이는 한 번도 실행되지 않은 채 통과한다. 컴파일도 단위 테스트도 + * 컬럼 이름 오타, jsonb 캐스팅, {@code EXISTS} 서브쿼리의 상관 조건을 검증하지 못한다. + * + *

    특히 두 가지를 겨냥한다. + * + *

      + *
    1. 공개 조건({@code PublicSql#ACTIVE}) 이 모든 경로에 걸려 있는가 — 게시 취소({@code WITHDRAWN})나 + * 비공개({@code UNLISTED}) 자료가 어느 한 쿼리에서라도 새면 사고다. 그래서 모든 목록/상세 테스트에 "새면 안 되는 행"을 함께 심는다. + *
    2. 총계와 목록이 같은 조건을 쓰는가 — 페이지네이션이 있는 여섯 operation 은 count 쿼리와 목록 쿼리를 따로 만든다. 조건이 갈라지면 마지막 + * 페이지가 비어 보이거나 없는 페이지 번호가 생긴다. + *
    + */ +class PublicSitePersistenceIntegrationTest { + + private static final String IMAGE = + System.getProperty("jpa.evidence.postgresql.image", "postgres:16-alpine"); + + private static final UUID SITE_CONFIG_ID = + UUID.fromString("00000000-0000-0000-0000-000000000001"); + private static final UUID PROFILE_PAGE_ID = + UUID.fromString("00000000-0000-0000-0000-000000000002"); + private static final UUID HOME_FOCUS_ID = UUID.fromString("00000000-0000-0000-0000-000000000003"); + + private static PostgreSQLContainer postgres; + private static HikariDataSource dataSource; + private static JdbcClient jdbcClient; + + private static JdbcPublicSiteQueryAdapter site; + private static JdbcPublicExploreQueryAdapter explore; + private static JdbcPublicTopicQueryAdapter topics; + private static JdbcPublicDocumentQueryAdapter documents; + private static JdbcPublicProjectQueryAdapter projects; + private static JdbcPublicReleaseQueryAdapter releases; + private static JdbcPublicSearchQueryAdapter search; + + private static UUID topicId; + private static UUID projectId; + private static UUID tagId; + private static UUID caseId; + private static UUID referenceId; + private static UUID questionId; + private static UUID decisionId; + private static UUID hiddenCaseId; + + private static final Instant NOW = Instant.now().truncatedTo(ChronoUnit.MILLIS); + + @BeforeAll + static void migrateAndSeed() { + if (!DockerClientFactory.instance().isDockerAvailable()) { + throw new IllegalStateException( + "Docker is required for the public-site 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(); + + site = new JdbcPublicSiteQueryAdapter(jdbcClient, objectMapper); + explore = new JdbcPublicExploreQueryAdapter(jdbcClient); + topics = new JdbcPublicTopicQueryAdapter(jdbcClient); + documents = new JdbcPublicDocumentQueryAdapter(jdbcClient, objectMapper); + projects = new JdbcPublicProjectQueryAdapter(jdbcClient, objectMapper); + releases = new JdbcPublicReleaseQueryAdapter(jdbcClient, objectMapper); + search = new JdbcPublicSearchQueryAdapter(jdbcClient); + + seed(); + } + + @AfterAll + static void stopPostgreSql() { + if (dataSource != null) { + dataSource.close(); + } + if (postgres != null) { + postgres.stop(); + } + } + + // ---------------------------------------------------------------- V9 스키마 + + @Test + void v9CreatesEveryTableThePublicContractReads() { + assertThat(tableExists("release")).isTrue(); + assertThat(tableExists("site_config")).isTrue(); + assertThat(tableExists("profile_page")).isTrue(); + assertThat(tableExists("home_focus_config")).isTrue(); + assertThat(tableExists("project_topic")).isTrue(); + assertThat(tableExists("topic_featured_document")).isTrue(); + } + + /** 한 Topic 의 {@code START_HERE} 는 하나뿐이라는 부분 유니크 인덱스가 실제로 강제되는지. */ + @Test + void aTopicCanOnlyHaveOneStartHereDocument() { + UUID scratchTopic = insertTopic("start-here-probe", "Start Here Probe"); + jdbcClient + .sql( + "INSERT INTO topic_featured_document (topic_id, document_id, feature_role," + + " display_order) VALUES (:t, :d, 'START_HERE', 0)") + .param("t", scratchTopic) + .param("d", referenceId) + .update(); + + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> + jdbcClient + .sql( + "INSERT INTO topic_featured_document (topic_id, document_id, feature_role," + + " display_order) VALUES (:t, :d, 'START_HERE', 1)") + .param("t", scratchTopic) + .param("d", caseId) + .update()) + .as("uq_topic_start_here 가 한 주제의 두 번째 START_HERE 를 막아야 한다") + .isInstanceOf(org.springframework.dao.DuplicateKeyException.class); + + jdbcClient + .sql("DELETE FROM topic_featured_document WHERE topic_id = :t") + .param("t", scratchTopic) + .update(); + jdbcClient.sql("DELETE FROM topic WHERE id = :id").param("id", scratchTopic).update(); + } + + // ---------------------------------------------------------------- 사이트 · 홈 · 프로필 + + @Test + void siteReadsTheSingleRowConfigWithItsContacts() { + SiteView view = site.site().orElseThrow(); + + assertThat(view.brandTitle()).isEqualTo("Tech Log"); + assertThat(view.operatorDisplayName()).isEqualTo("동현"); + assertThat(view.operatorProfilePath()).isEqualTo("/profile"); + assertThat(view.contacts()).hasSize(1); + assertThat(view.contacts().getFirst().type()).isEqualTo("GITHUB"); + assertThat(view.contacts().getFirst().url()).isEqualTo("https://github.com/example"); + } + + @Test + void homeResolvesTheConfiguredFocusAndTheLatestEntries() { + HomeView view = site.home(10); + + assertThat(view.focus().defaultType()).isEqualTo("CURRENT_WORK"); + assertThat(view.focus().currentWork()).isNotNull(); + assertThat(view.focus().currentWork().projectPath()).isEqualTo("/projects/tech-log"); + assertThat(view.latestEntries()).isNotEmpty(); + assertThat(view.latestEntries()) + .as("게시 취소된 자료는 최신 목록에 없어야 한다") + .noneMatch(entry -> entry.title().contains("숨김")); + assertThat(view.latestEntries()) + .as( + "계약 LatestEntry.entryType 은 네 값만 허용한다 — projection 의 QUESTION/PROJECT 등이 섞이면" + + " 응답 매퍼가 계약 밖 값을 만나 500 이 된다") + .extracting("entryType") + .containsAnyOf("CASE", "REFERENCE", "PROJECT_ACTIVITY") + .allSatisfy( + type -> assertThat(type).isIn("CASE", "REFERENCE", "PROJECT_ACTIVITY", "RELEASE")); + } + + /** + * 갓 마이그레이션한 상태에서 {@code default_focus_type} 은 NULL 이다. 계약은 이 필드를 required 로 선언하고 값 셋만 허용하므로, NULL + * 이 그대로 나가면 홈 화면 전체가 500 이 된다 — 실제 앱 기동 후 첫 요청에서 그렇게 깨졌다. 설정이 비어도 계약이 아는 값 하나로 정해져야 한다. + */ + @Test + void homeFocusFallsBackToAContractValueWhenNothingIsConfigured() { + jdbcClient + .sql( + "UPDATE home_focus_config SET default_focus_type = NULL," + + " current_project_id = NULL, open_question_id = NULL," + + " recent_decision_id = NULL WHERE id = :id") + .param("id", HOME_FOCUS_ID) + .update(); + try { + HomeView view = site.home(10); + assertThat(view.focus().defaultType()) + .isIn("CURRENT_WORK", "OPEN_QUESTION", "RECENT_DECISION"); + assertThat(view.focus().currentWork()).isNull(); + assertThat(view.focus().openQuestion()).isNull(); + assertThat(view.focus().recentDecision()).isNull(); + + // 설정은 비어 있지만 내용이 있는 갈래가 있으면 그쪽을 고른다. + jdbcClient + .sql("UPDATE home_focus_config SET open_question_id = :q WHERE id = :id") + .param("q", questionId) + .param("id", HOME_FOCUS_ID) + .update(); + assertThat(site.home(10).focus().defaultType()).isEqualTo("OPEN_QUESTION"); + } finally { + jdbcClient + .sql( + "UPDATE home_focus_config SET default_focus_type = 'CURRENT_WORK'," + + " current_project_id = :project, open_question_id = :question," + + " recent_decision_id = :decision WHERE id = :id") + .param("id", HOME_FOCUS_ID) + .param("project", projectId) + .param("question", questionId) + .param("decision", decisionId) + .update(); + } + } + + @Test + void profileReadsItsJsonbColumnsIntoTypedViews() { + ProfileView view = site.profile().orElseThrow(); + + assertThat(view.headline()).isEqualTo("문제를 재현해 검증한다"); + assertThat(view.workingModel()).extracting(ProfileView.NamedDescription::name).contains("재현"); + assertThat(view.territories()).extracting(ProfileView.Territory::name).contains("Kafka"); + assertThat(view.contacts()).hasSize(1); + assertThat(view.selectedEvidence()) + .as("selected_evidence 는 공개된 것만 되살린다") + .extracting("title") + .containsExactly("Kafka 재처리"); + } + + // ---------------------------------------------------------------- 탐색 + + @Test + void knowledgeListsOnlyPublishedCasesAndReferences() { + KnowledgePageView page = + explore.knowledge( + new ExploreKnowledgeQuery(null, null, null, null, null, null, page(1, 20))); + + assertThat(page.items()).extracting("title").contains("Kafka 재처리", "Kafka 운영 기준"); + assertThat(page.items()).extracting("title").doesNotContain("숨김 Case"); + assertThat(page.page().totalElements()) + .as("총계와 목록이 같은 조건을 써야 한다") + .isEqualTo(page.items().size()); + } + + @Test + void knowledgeAppliesEveryContractFilter() { + assertThat( + explore + .knowledge( + new ExploreKnowledgeQuery("CASE", null, null, null, null, null, page(1, 20))) + .items()) + .extracting("type") + .containsOnly("CASE"); + + assertThat( + explore + .knowledge( + new ExploreKnowledgeQuery(null, "kafka", null, null, null, null, page(1, 20))) + .items()) + .isNotEmpty(); + + assertThat( + explore + .knowledge( + new ExploreKnowledgeQuery( + null, "no-such-topic", null, null, null, null, page(1, 20))) + .items()) + .isEmpty(); + + assertThat( + explore + .knowledge( + new ExploreKnowledgeQuery( + null, null, null, "reprocessing", null, null, page(1, 20))) + .items()) + .as("tag 필터의 상관 EXISTS 서브쿼리") + .extracting("title") + .containsExactly("Kafka 재처리"); + + assertThat( + explore + .knowledge( + new ExploreKnowledgeQuery(null, null, null, null, 1999, null, page(1, 20))) + .items()) + .as("year 필터는 date_part 로 건다") + .isEmpty(); + } + + /** 계약의 정렬 세 값이 전부 유효한 SQL 이어야 한다 — 오타는 문법 오류로만 드러난다. */ + @Test + void knowledgeAcceptsEveryContractSort() { + for (String sort : List.of("PUBLISHED_DESC", "UPDATED_DESC", "VERIFIED_DESC")) { + assertThat( + explore + .knowledge( + new ExploreKnowledgeQuery(null, null, null, null, null, sort, page(1, 20))) + .items()) + .as("sort=%s", sort) + .isNotEmpty(); + } + } + + @Test + void questionsListAppliesStatusTagAndEverySort() { + QuestionPageView all = + explore.questions(new ExploreQuestionsQuery(null, null, null, null, null, page(1, 20))); + assertThat(all.items()).extracting("question").contains("재처리 지연을 어떻게 줄일까"); + + assertThat( + explore + .questions( + new ExploreQuestionsQuery("RESOLVED", null, null, null, null, page(1, 20))) + .items()) + .isEmpty(); + + assertThat( + explore + .questions( + new ExploreQuestionsQuery(null, null, null, "reprocessing", null, page(1, 20))) + .items()) + .as("질문에도 tag 필터가 걸려야 한다") + .isNotEmpty(); + + for (String sort : List.of("UPDATED_DESC", "OPENED_DESC", "RESOLVED_DESC")) { + assertThat( + explore + .questions(new ExploreQuestionsQuery(null, null, null, null, sort, page(1, 20))) + .items()) + .as("sort=%s", sort) + .isNotEmpty(); + } + } + + // ---------------------------------------------------------------- 주제 + + @Test + void topicListCountsOnlyPublishedRecords() { + assertThat(topics.list()).extracting("slug").contains("kafka"); + + var kafka = + topics.list().stream().filter(t -> t.slug().equals("kafka")).findFirst().orElseThrow(); + // Case · Reference · Question 셋만 이 주제를 primary 로 가지며, 게시 취소된 Case 는 세지 않는다. + assertThat(kafka.recordCount()).as("게시 취소된 자료는 세지 않는다").isEqualTo(3); + } + + @Test + void topicDetailResolvesEverySection() { + TopicDetailView view = topics.findBySlug("kafka").orElseThrow(); + + assertThat(view.name()).isEqualTo("Kafka"); + assertThat(view.featuredReference()).isNotNull(); + assertThat(view.featuredReference().title()).isEqualTo("Kafka 운영 기준"); + assertThat(view.activeQuestions()).isNotEmpty(); + assertThat(view.relatedProjects()).extracting("title").contains("Tech Log"); + assertThat(view.latestRecords()).isNotEmpty(); + assertThat(view.latestRecords()) + .as("주제 상세의 최신 기록도 계약의 entryType 네 값을 벗어나면 안 된다") + .extracting("entryType") + .allSatisfy( + type -> assertThat(type).isIn("CASE", "REFERENCE", "PROJECT_ACTIVITY", "RELEASE")); + } + + @Test + void topicDetailIsAbsentForAnUnknownSlug() { + assertThat(topics.findBySlug("no-such-topic")).isEmpty(); + } + + // ---------------------------------------------------------------- 문서 상세 + + @Test + void caseDetailReadsTheOriginalTableNotTheProjectionPayload() { + CaseDetailView view = documents.findCase("kafka-reprocessing").orElseThrow(); + + assertThat(view.canonicalPath()).isEqualTo("/cases/kafka-reprocessing"); + assertThat(view.document().title()).isEqualTo("Kafka 재처리"); + assertThat(view.document().primarySummary()).isEqualTo("재처리가 지연된다"); + assertThat(view.document().secondarySummary()).isEqualTo("컨슈머 랙을 먼저 본다"); + assertThat(view.document().content()).contains("# 재처리"); + assertThat(view.document().contentFormat()).isEqualTo("MARKDOWN"); + assertThat(view.document().environmentSummary()).containsExactly("Kafka 3.7"); + assertThat(view.document().primaryTopic().slug()).isEqualTo("kafka"); + assertThat(view.document().primaryProject().slug()).isEqualTo("tech-log"); + assertThat(view.document().tags()).extracting("slug").containsExactly("reprocessing"); + } + + @Test + void referenceDetailReadsItsOwnScopeColumns() { + ReferenceDetailView view = documents.findReference("kafka-operations").orElseThrow(); + + assertThat(view.document().primarySummary()).isEqualTo("운영 기준을 정한다"); + assertThat(view.document().appliesTo()).containsExactly("Kafka 3.x"); + assertThat(view.document().excludedScope()).containsExactly("Kinesis"); + assertThat(view.document().freshnessStatus()).isEqualTo("CURRENT"); + } + + @Test + void questionDetailReadsItsTimelineAndResolutionColumns() { + QuestionDetailView view = documents.findQuestion("reprocessing-latency").orElseThrow(); + + assertThat(view.question().question()).isEqualTo("재처리 지연을 어떻게 줄일까"); + assertThat(view.question().status()).isEqualTo("OPEN"); + assertThat(view.question().resolvedAt()).isNull(); + assertThat(view.question().points()).isNotNull(); + } + + @Test + void aWithdrawnDocumentIsNotReadable() { + assertThat(documents.findCase("hidden-case")).as("게시 취소된 문서는 상세로도 열리면 안 된다").isEmpty(); + } + + // ---------------------------------------------------------------- 프로젝트 + + @Test + void projectListAndDetailReadEveryPublishedColumn() { + assertThat(projects.list()).extracting("slug").containsExactly("tech-log"); + + ProjectDetailView view = projects.findBySlug("tech-log").orElseThrow(); + assertThat(view.project().name()).isEqualTo("Tech Log"); + assertThat(view.project().technologies()).contains("Spring Boot"); + assertThat(view.canonicalPath()).isEqualTo("/projects/tech-log"); + assertThat(view.featuredDecision()).isNotNull(); + assertThat(view.selectedRecords()).isNotEmpty(); + } + + @Test + void projectSubListsReturnEmptyOptionalForAnUnknownProject() { + assertThat(projects.decisions(new ProjectDecisionPageQuery("nope", null, page(1, 20)))) + .isEmpty(); + assertThat(projects.records(new ProjectRecordPageQuery("nope", null, null, page(1, 20)))) + .isEmpty(); + assertThat(projects.activities(new ProjectPageQuery("nope", page(1, 20)))).isEmpty(); + } + + @Test + void projectDecisionsApplyTheStatusFilterToBothCountAndPage() { + var all = + projects + .decisions(new ProjectDecisionPageQuery("tech-log", null, page(1, 20))) + .orElseThrow(); + assertThat(all.items()).hasSize(1); + assertThat(all.page().totalElements()).isEqualTo(1); + + var accepted = + projects + .decisions(new ProjectDecisionPageQuery("tech-log", "ACCEPTED", page(1, 20))) + .orElseThrow(); + assertThat(accepted.items()).hasSize(1); + assertThat(accepted.page().totalElements()).isEqualTo(1); + + var proposed = + projects + .decisions(new ProjectDecisionPageQuery("tech-log", "PROPOSED", page(1, 20))) + .orElseThrow(); + assertThat(proposed.items()).isEmpty(); + assertThat(proposed.page().totalElements()).as("필터가 목록에만 걸리고 총계에 안 걸리면 여기서 드러난다").isZero(); + } + + @Test + void projectRecordsApplyTypeAndRelationFilters() { + var all = + projects + .records(new ProjectRecordPageQuery("tech-log", null, null, page(1, 20))) + .orElseThrow(); + assertThat(all.items()).isNotEmpty(); + assertThat(all.page().totalElements()).isEqualTo(all.items().size()); + + var cases = + projects + .records(new ProjectRecordPageQuery("tech-log", "CASE", null, page(1, 20))) + .orElseThrow(); + assertThat(cases.items()).extracting("type").containsOnly("CASE"); + + var related = + projects + .records(new ProjectRecordPageQuery("tech-log", null, "RELATED", page(1, 20))) + .orElseThrow(); + assertThat(related.page().totalElements()).isEqualTo(related.items().size()); + + var none = + projects + .records(new ProjectRecordPageQuery("tech-log", "QUESTION", "RELATED", page(1, 20))) + .orElseThrow(); + assertThat(none.page().totalElements()).isEqualTo(none.items().size()); + } + + @Test + void projectActivitiesListOnlyPublicOnes() { + var activities = + projects.activities(new ProjectPageQuery("tech-log", page(1, 20))).orElseThrow(); + + assertThat(activities.items()).extracting("title").containsExactly("첫 게시"); + assertThat(activities.page().totalElements()).isEqualTo(1); + } + + // ---------------------------------------------------------------- 릴리스 + + @Test + void releasesListOnlyPublishedOnesAndResolveRelatedRecords() { + assertThat(releases.list()).extracting("version").containsExactly("1.0.0"); + + ReleaseDetailView detail = releases.findByVersion("1.0.0").orElseThrow(); + assertThat(detail.title()).isEqualTo("첫 공개"); + assertThat(detail.changeTypes()).containsExactly("ADDED"); + assertThat(detail.relatedRecords()) + .as("related_resources 는 공개된 것만 되살린다") + .extracting("title") + .containsExactly("Kafka 재처리"); + + assertThat(releases.findByVersion("0.9.0")).as("DRAFT 릴리스는 열리면 안 된다").isEmpty(); + } + + // ---------------------------------------------------------------- 검색 + + @Test + void searchMatchesOnSearchTextAndAppliesFilters() { + SearchResultPageView hits = search.search(new SearchQuery("재처리", null, null, page(1, 20))); + + assertThat(hits.query()).isEqualTo("재처리"); + assertThat(hits.items()).isNotEmpty(); + assertThat(hits.page().totalElements()).isEqualTo(hits.items().size()); + assertThat(hits.items()).extracting("title").doesNotContain("숨김 Case"); + + assertThat(search.search(new SearchQuery("기준", "REFERENCE", null, page(1, 20))).items()) + .extracting("contentType") + .containsOnly("REFERENCE"); + assertThat(search.search(new SearchQuery("기준", "CASE", null, page(1, 20))).items()) + .as("type 필터가 실제로 걸려야 한다") + .isEmpty(); + + assertThat(search.search(new SearchQuery("존재하지않는단어", null, null, page(1, 20))).items()) + .isEmpty(); + } + + // ---------------------------------------------------------------- 시딩 + + /** + * 삽입 순서가 곧 제약이다. {@code public_resource_project_link}/{@code public_resource_tag} 는 {@code + * public_resource_projection} 을 복합 FK 로 참조하므로 원본 테이블 → projection → 링크/태그 순서를 지킨다. + */ + private static void seed() { + topicId = insertTopic("kafka", "Kafka"); + + projectId = UUID.randomUUID(); + jdbcClient + .sql( + "INSERT INTO project (id, slug, name, one_line_purpose, purpose_markdown," + + " boundary_markdown, system_overview_markdown, phase, current_objective," + + " next_step, technology_labels, workflow_status, target_visibility," + + " created_by, updated_by)" + + " VALUES (:id, 'tech-log', 'Tech Log', '기록을 남긴다', '목적', '경계', '개요'," + + " 'IMPLEMENTATION', '공개 API 완성', '통합 테스트'," + + " '[\"Spring Boot\", \"PostgreSQL\"]'::jsonb, 'PUBLISHED', 'PUBLIC'," + + " 'test', 'test')") + .param("id", projectId) + .update(); + + tagId = UUID.randomUUID(); + jdbcClient + .sql( + "INSERT INTO tag (id, name, normalized_name, slug, created_by, updated_by)" + + " VALUES (:id, 'reprocessing', 'reprocessing', 'reprocessing', 'test', 'test')") + .param("id", tagId) + .update(); + + // --- Case (공개) --- + caseId = UUID.randomUUID(); + insertDocument(caseId, "CASE", "kafka-reprocessing", "Kafka 재처리", topicId); + jdbcClient + .sql( + "INSERT INTO case_detail (document_id, problem_summary, conclusion_summary," + + " environment_items) VALUES (:id, '재처리가 지연된다', '컨슈머 랙을 먼저 본다'," + + " '[\"Kafka 3.7\"]'::jsonb)") + .param("id", caseId) + .update(); + publish( + "CASE", + caseId, + "Kafka 재처리", + "재처리가 지연된다", + "/cases/kafka-reprocessing", + "ACTIVE", + "PUBLIC", + topicId); + link(caseId, "CASE", "PRIMARY", 0); + tag(caseId, "CASE"); + // 목록의 tag 필터는 projection(public_resource_tag)을, 상세는 원본(document_tag)을 읽는다. + jdbcClient + .sql( + "INSERT INTO document_tag (document_id, tag_id, display_order)" + " VALUES (:d, :t, 0)") + .param("d", caseId) + .param("t", tagId) + .update(); + + // --- Reference (공개) --- + referenceId = UUID.randomUUID(); + insertDocument(referenceId, "REFERENCE", "kafka-operations", "Kafka 운영 기준", topicId); + jdbcClient + .sql( + "INSERT INTO reference_detail (document_id, scope_summary, applies_to," + + " excluded_scope, freshness_status) VALUES (:id, '운영 기준을 정한다'," + + " '[\"Kafka 3.x\"]'::jsonb, '[\"Kinesis\"]'::jsonb, 'CURRENT')") + .param("id", referenceId) + .update(); + publish( + "REFERENCE", + referenceId, + "Kafka 운영 기준", + "운영 기준을 정한다", + "/references/kafka-operations", + "ACTIVE", + "PUBLIC", + topicId); + link(referenceId, "REFERENCE", "RELATED", null); + + // --- Case (게시 취소) — 어느 경로로도 새면 안 된다 --- + hiddenCaseId = UUID.randomUUID(); + insertDocument(hiddenCaseId, "CASE", "hidden-case", "숨김 Case", topicId); + jdbcClient + .sql("INSERT INTO case_detail (document_id, problem_summary) VALUES (:id, '재처리 비밀')") + .param("id", hiddenCaseId) + .update(); + publish( + "CASE", + hiddenCaseId, + "숨김 Case", + "재처리 비밀", + "/cases/hidden-case", + "WITHDRAWN", + "PUBLIC", + topicId); + + // --- OpenQuestion (공개) --- + questionId = UUID.randomUUID(); + jdbcClient + .sql( + "INSERT INTO open_question (id, slug, question, summary, context_markdown," + + " importance_markdown, next_verification, question_status, target_visibility," + + " primary_topic_id, opened_at, created_by, updated_by)" + + " VALUES (:id, 'reprocessing-latency', '재처리 지연을 어떻게 줄일까'," + + " '지연 원인을 좁힌다', '맥락', '중요도', '컨슈머 랙 측정', 'OPEN', 'PUBLIC', :topic," + + " :openedAt, 'test', 'test')") + .param("id", questionId) + .param("topic", topicId) + .param("openedAt", java.sql.Timestamp.from(NOW.minus(10, ChronoUnit.DAYS))) + .update(); + publish( + "QUESTION", + questionId, + "재처리 지연을 어떻게 줄일까", + "지연 원인을 좁힌다", + "/questions/reprocessing-latency", + "ACTIVE", + "PUBLIC", + topicId); + // 질문 목록의 status 필터는 projection 의 state_code 를 본다. + jdbcClient + .sql( + "UPDATE public_resource_projection SET state_code = 'OPEN'" + + " WHERE resource_type = 'QUESTION' AND resource_id = :id") + .param("id", questionId) + .update(); + link(questionId, "QUESTION", "PRIMARY", 1); + tag(questionId, "QUESTION"); + + // --- ProjectDecision (공개) --- + decisionId = UUID.randomUUID(); + jdbcClient + .sql( + "INSERT INTO project_decision (id, project_id, statement, rationale_markdown," + + " consequences, decision_status, target_visibility, source_question_id," + + " source_case_id, is_featured, decided_at, created_by, updated_by)" + + " VALUES (:id, :project, '재처리는 별도 토픽으로 분리한다', '격리해야 관측이 쉬워진다'," + + " '[\"운영 토픽 증가\"]'::jsonb, 'ACCEPTED', 'PUBLIC', :question, :sourceCase," + + " true, :decidedAt, 'test', 'test')") + .param("id", decisionId) + .param("project", projectId) + .param("question", questionId) + .param("sourceCase", caseId) + .param("decidedAt", java.sql.Timestamp.from(NOW.minus(2, ChronoUnit.DAYS))) + .update(); + publish( + "PROJECT_DECISION", + decisionId, + "재처리는 별도 토픽으로 분리한다", + "격리해야 관측이 쉬워진다", + "/projects/tech-log/decisions/" + decisionId, + "ACTIVE", + "PUBLIC", + null); + + // --- Project (공개) --- + publish( + "PROJECT", + projectId, + "Tech Log", + "기록을 남긴다", + "/projects/tech-log", + "ACTIVE", + "PUBLIC", + null); + + // --- 활동: 공개 하나 · 비공개 하나 --- + jdbcClient + .sql( + "INSERT INTO project_activity (id, project_id, activity_type, title, summary," + + " visibility, origin, related_resource_type, related_resource_id, occurred_at," + + " created_by, updated_by)" + + " VALUES (gen_random_uuid(), :project, 'CASE_PUBLISHED', '첫 게시'," + + " '첫 문서를 공개했다', 'PUBLIC', 'AUTO', 'CASE', :relatedCase, :at," + + " 'test', 'test')") + .param("project", projectId) + .param("relatedCase", caseId) + .param("at", java.sql.Timestamp.from(NOW.minus(1, ChronoUnit.DAYS))) + .update(); + jdbcClient + .sql( + "INSERT INTO project_activity (id, project_id, activity_type, title, visibility," + + " origin, occurred_at, created_by, updated_by)" + + " VALUES (gen_random_uuid(), :project, 'MILESTONE_REACHED', '비공개 메모', 'PRIVATE'," + + " 'MANUAL', :at, 'test', 'test')") + .param("project", projectId) + .param("at", java.sql.Timestamp.from(NOW)) + .update(); + + // --- V9 연결 테이블 --- + jdbcClient + .sql("INSERT INTO project_topic (project_id, topic_id, display_order) VALUES (:p, :t, 0)") + .param("p", projectId) + .param("t", topicId) + .update(); + jdbcClient + .sql( + "INSERT INTO topic_featured_document (topic_id, document_id, feature_role," + + " display_order) VALUES (:t, :d, 'START_HERE', 0)") + .param("t", topicId) + .param("d", referenceId) + .update(); + jdbcClient + .sql( + "INSERT INTO topic_featured_document (topic_id, document_id, feature_role," + + " display_order) VALUES (:t, :d, 'FEATURED_CASE', 0)") + .param("t", topicId) + .param("d", caseId) + .update(); + + // --- 단일 행 설정 --- + jdbcClient + .sql("UPDATE site_config SET contacts = :contacts::jsonb WHERE id = :id") + .param("id", SITE_CONFIG_ID) + .param( + "contacts", + "[{\"type\":\"GITHUB\",\"label\":\"GitHub\"," + + "\"url\":\"https://github.com/example\"}]") + .update(); + + // V9 가 단일 행을 이미 시딩했으므로(INSERT ... ON CONFLICT DO NOTHING) 값 채우기는 UPDATE 다. + jdbcClient + .sql( + "UPDATE profile_page SET headline = '문제를 재현해 검증한다'," + + " introduction_markdown = '소개', working_model = :workingModel::jsonb," + + " territories = :territories::jsonb, selected_evidence = :evidence::jsonb," + + " trajectory = :trajectory::jsonb, contacts = :contacts::jsonb," + + " target_visibility = 'PUBLIC' WHERE id = :id") + .param("id", PROFILE_PAGE_ID) + .param("workingModel", "[{\"name\":\"재현\",\"description\":\"먼저 재현한다\"}]") + .param( + "territories", + "[{\"name\":\"Kafka\",\"currentQuestion\":\"재처리 지연\"," + + "\"topicPath\":\"/topics/kafka\"}]") + // 게시 취소된 자료 id 를 함께 넣는다 — 공개된 것만 되살아나야 한다. + .param("evidence", "[\"" + caseId + "\",\"" + hiddenCaseId + "\"]") + .param("trajectory", "[{\"title\":\"2026\",\"description\":\"Tech Log 시작\"}]") + .param( + "contacts", + "[{\"type\":\"EMAIL\",\"label\":\"Email\"," + "\"url\":\"mailto:a@example.com\"}]") + .update(); + + jdbcClient + .sql( + "UPDATE home_focus_config SET default_focus_type = 'CURRENT_WORK'," + + " current_project_id = :project, open_question_id = :question," + + " recent_decision_id = :decision WHERE id = :id") + .param("id", HOME_FOCUS_ID) + .param("project", projectId) + .param("question", questionId) + .param("decision", decisionId) + .update(); + + // --- 릴리스: 공개 하나 · 초안 하나 --- + jdbcClient + .sql( + "INSERT INTO release (id, version_label, title, summary, released_on," + + " workflow_status, change_types, changes_markdown, verification_markdown," + + " related_resources, created_by, updated_by)" + + " VALUES (gen_random_uuid(), '1.0.0', '첫 공개', '공개 API 를 열었다'," + + " DATE '2026-08-01', 'PUBLISHED', '[\"ADDED\"]'::jsonb, '변경', '검증'," + + " :related::jsonb, 'test', 'test')") + // 게시 취소된 자료 id 를 일부러 함께 넣는다 — 공개된 것만 되살아나야 한다. + .param("related", "[\"" + caseId + "\",\"" + hiddenCaseId + "\"]") + .update(); + jdbcClient + .sql( + "INSERT INTO release (id, version_label, title, summary, released_on," + + " workflow_status, created_by, updated_by)" + + " VALUES (gen_random_uuid(), '0.9.0', '초안', '아직 공개 전', DATE '2026-07-01'," + + " 'DRAFT', 'test', 'test')") + .update(); + } + + private static UUID insertTopic(String slug, String name) { + UUID id = UUID.randomUUID(); + jdbcClient + .sql( + "INSERT INTO topic (id, name, normalized_name, slug, description, scope," + + " status, created_by, updated_by)" + + " VALUES (:id, :name, lower(:name), :slug, :name || ' 설명', '범위'," + + " 'ACTIVE', 'test', 'test')") + .param("id", id) + .param("name", name) + .param("slug", slug) + .update(); + return id; + } + + private static void insertDocument(UUID id, String type, String slug, String title, UUID topic) { + jdbcClient + .sql( + "INSERT INTO document (id, document_type, slug, title, body_markdown," + + " content_format, content_format_version, workflow_status, target_visibility," + + " primary_topic_id, last_verified_at, created_by, updated_by)" + + " VALUES (:id, :type, :slug, :title, '# 재처리\n본문', 'MARKDOWN', 1," + + " 'PUBLISHED', 'PUBLIC', :topic, :verifiedAt, 'test', 'test')") + .param("id", id) + .param("type", type) + .param("slug", slug) + .param("title", title) + .param("topic", topic) + .param("verifiedAt", java.sql.Timestamp.from(NOW.minus(3, ChronoUnit.DAYS))) + .update(); + } + + private static void link(UUID resourceId, String type, String relation, Integer order) { + jdbcClient + .sql( + "INSERT INTO public_resource_project_link (resource_type, resource_id, project_id," + + " relation_type, featured_order)" + + " VALUES (:type, :id, :project, :relation, :order)") + .param("type", type) + .param("id", resourceId) + .param("project", projectId) + .param("relation", relation) + .param("order", order) + .update(); + } + + private static void tag(UUID resourceId, String type) { + jdbcClient + .sql( + "INSERT INTO public_resource_tag (resource_type, resource_id, tag_id, display_order)" + + " VALUES (:type, :id, :tag, 0)") + .param("type", type) + .param("id", resourceId) + .param("tag", tagId) + .update(); + } + + /** + * projection 행을 만든다. {@code public_resource_project_link} 와 {@code public_resource_tag} 가 이 행을 + * (resource_type, resource_id) 복합 FK 로 참조하므로 반드시 링크·태그보다 먼저 삽입해야 한다. + * + *

    {@code topic} 을 인자로 받는 이유는 주제별 record 수를 세는 쿼리가 {@code primary_topic_id} 를 보기 때문이다. 모든 + * projection 에 같은 주제를 박아 두면 Project 나 Decision 까지 그 주제의 기록으로 세어져, 실제 값과 다른 숫자에 테스트를 맞추게 된다. + */ + private static void publish( + String type, + UUID id, + String title, + String summary, + String path, + String state, + String visibility, + UUID topic) { + jdbcClient + .sql( + "INSERT INTO public_resource_projection (resource_type, resource_id, source_version," + + " publication_state, visibility, title, summary, primary_topic_id," + + " payload_schema_version, payload, body_plain_text, search_text, content_hash," + + " published_at, updated_at, last_verified_at, navigation_path)" + + " VALUES (:type, :id, 1, :state, :visibility, :title, :summary, :topic, 1," + + " '{}'::jsonb, :body, :search, repeat('a', 64), :publishedAt, :updatedAt," + + " :verifiedAt, :path)") + .param("type", type) + .param("id", id) + .param("state", state) + .param("visibility", visibility) + .param("title", title) + .param("summary", summary) + .param("topic", topic) + .param("body", title + " " + summary) + .param("search", title + " " + summary) + .param("publishedAt", java.sql.Timestamp.from(NOW.minus(5, ChronoUnit.DAYS))) + .param("updatedAt", java.sql.Timestamp.from(NOW.minus(4, ChronoUnit.DAYS))) + .param("verifiedAt", java.sql.Timestamp.from(NOW.minus(3, ChronoUnit.DAYS))) + .param("path", path) + .update(); + } + + private static PublicPageRequest page(int page, int size) { + return new PublicPageRequest(page, size); + } + + private static boolean tableExists(String table) { + return Boolean.TRUE.equals( + jdbcClient + .sql( + "SELECT EXISTS (SELECT 1 FROM information_schema.tables" + + " WHERE table_schema = 'public' AND table_name = :t)") + .param("t", table) + .query(Boolean.class) + .single()); + } +} diff --git a/src/app-bootstrap/src/functionalTest/java/dev/caskeleton/bootstrap/contract/PublicContractDriftTest.java b/src/app-bootstrap/src/functionalTest/java/dev/caskeleton/bootstrap/contract/PublicContractDriftTest.java new file mode 100644 index 0000000..85b2bbb --- /dev/null +++ b/src/app-bootstrap/src/functionalTest/java/dev/caskeleton/bootstrap/contract/PublicContractDriftTest.java @@ -0,0 +1,542 @@ +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.application.techlog.publicsite.model.CaseDetailView; +import dev.caskeleton.application.techlog.publicsite.model.HomeView; +import dev.caskeleton.application.techlog.publicsite.model.KnowledgePageView; +import dev.caskeleton.application.techlog.publicsite.model.PageMetadataView; +import dev.caskeleton.application.techlog.publicsite.model.ProfileView; +import dev.caskeleton.application.techlog.publicsite.model.ProjectActivityPageView; +import dev.caskeleton.application.techlog.publicsite.model.ProjectDecisionPageView; +import dev.caskeleton.application.techlog.publicsite.model.ProjectDetailView; +import dev.caskeleton.application.techlog.publicsite.model.ProjectListItemView; +import dev.caskeleton.application.techlog.publicsite.model.ProjectRecordPageView; +import dev.caskeleton.application.techlog.publicsite.model.QuestionDetailView; +import dev.caskeleton.application.techlog.publicsite.model.QuestionPageView; +import dev.caskeleton.application.techlog.publicsite.model.ReferenceDetailView; +import dev.caskeleton.application.techlog.publicsite.model.ReleaseDetailView; +import dev.caskeleton.application.techlog.publicsite.model.ReleaseListItemView; +import dev.caskeleton.application.techlog.publicsite.model.SearchResultPageView; +import dev.caskeleton.application.techlog.publicsite.model.SiteView; +import dev.caskeleton.application.techlog.publicsite.model.TopicDetailView; +import dev.caskeleton.application.techlog.publicsite.model.TopicListItemView; +import dev.caskeleton.application.techlog.publicsite.port.out.PublicDocumentQueryPort; +import dev.caskeleton.application.techlog.publicsite.port.out.PublicExploreQueryPort; +import dev.caskeleton.application.techlog.publicsite.port.out.PublicProjectQueryPort; +import dev.caskeleton.application.techlog.publicsite.port.out.PublicReleaseQueryPort; +import dev.caskeleton.application.techlog.publicsite.port.out.PublicSearchQueryPort; +import dev.caskeleton.application.techlog.publicsite.port.out.PublicSiteQueryPort; +import dev.caskeleton.application.techlog.publicsite.port.out.PublicTopicQueryPort; +import dev.caskeleton.application.techlog.publicsite.query.ExploreKnowledgeQuery; +import dev.caskeleton.application.techlog.publicsite.query.ExploreQuestionsQuery; +import dev.caskeleton.application.techlog.publicsite.query.ProjectDecisionPageQuery; +import dev.caskeleton.application.techlog.publicsite.query.ProjectPageQuery; +import dev.caskeleton.application.techlog.publicsite.query.ProjectRecordPageQuery; +import dev.caskeleton.application.techlog.publicsite.query.SearchQuery; +import dev.caskeleton.application.techlog.publicsite.service.ExploreKnowledgeUseCase; +import dev.caskeleton.application.techlog.publicsite.service.ExploreQuestionsUseCase; +import dev.caskeleton.application.techlog.publicsite.service.GetPublicCaseUseCase; +import dev.caskeleton.application.techlog.publicsite.service.GetPublicHomeUseCase; +import dev.caskeleton.application.techlog.publicsite.service.GetPublicProfileUseCase; +import dev.caskeleton.application.techlog.publicsite.service.GetPublicProjectUseCase; +import dev.caskeleton.application.techlog.publicsite.service.GetPublicQuestionUseCase; +import dev.caskeleton.application.techlog.publicsite.service.GetPublicReferenceUseCase; +import dev.caskeleton.application.techlog.publicsite.service.GetPublicReleaseUseCase; +import dev.caskeleton.application.techlog.publicsite.service.GetPublicSiteUseCase; +import dev.caskeleton.application.techlog.publicsite.service.GetPublicTopicUseCase; +import dev.caskeleton.application.techlog.publicsite.service.ListPublicProjectActivitiesUseCase; +import dev.caskeleton.application.techlog.publicsite.service.ListPublicProjectDecisionsUseCase; +import dev.caskeleton.application.techlog.publicsite.service.ListPublicProjectRecordsUseCase; +import dev.caskeleton.application.techlog.publicsite.service.ListPublicProjectsUseCase; +import dev.caskeleton.application.techlog.publicsite.service.ListPublicReleasesUseCase; +import dev.caskeleton.application.techlog.publicsite.service.ListPublicTopicsUseCase; +import dev.caskeleton.application.techlog.publicsite.service.SearchPublicResourcesUseCase; +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.Optional; +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.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.test.web.servlet.MockMvc; +import org.yaml.snakeyaml.Yaml; + +/** + * {@code PublicContractDriftTest} 는 {@code StudioContractDriftTest} 가 studio-v1 에 대해 하는 일을 + * public-v1 에 대해 한다: springdoc 이 실제로 게시하는 표면과 vendored {@code src/config/openapi/public-v1.yaml} 을 + * 양방향으로 대조한다. + * + *

    스캔 범위는 {@code dev.caskeleton.adapter.inbound.web.techlog.publicapi} 다. 이 아래에 새 컨트롤러를 두면 이 파일을 + * 고치지 않아도 자동으로 감시 대상이 되고, 밖에 두면 게이트가 그것을 보지 못한다 — 그 성질과 함정은 형제 테스트의 클래스 javadoc 에 자세히 적혀 있다. + * + *

    계약의 {@code servers} 를 경로에 더해야 한다

    + * + *

    studio-v1 은 {@code servers: "/"} 라 계약의 path 가 그대로 최종 주소지만, public-v1 은 {@code servers: + * "/api/v1/public"} 이고 path 는 {@code /site} 처럼 짧다. 그래서 대조 전에 server prefix 를 붙인다 — 이걸 빠뜨리면 + * published 와 계약이 한 건도 겹치지 않는데도 "published ⊆ 계약" 방향은 비교 대상이 0건이라 통과해 버린다. 아래 {@code compared} 비어있지 + * 않음 단언이 그 상태를 실패로 만든다. + */ +class PublicContractDriftTest { + + @Nested + @SpringBootTest(classes = ContractSurface.ContractSurfaceApp.class) + @AutoConfigureMockMvc(addFilters = false) + class ContractSurface { + + @Autowired private MockMvc mvc; + + @Test + void publishedPublicOperationsMatchTheContract() throws Exception { + JsonNode contract = readContract(); + String prefix = serverPrefix(contract); + JsonNode published = readPublishedApiDocs(); + + List problems = new ArrayList<>(); + List compared = new ArrayList<>(); + JsonNode publishedPaths = published.path("paths"); + for (Map.Entry path : publishedPaths.properties()) { + if (!path.getKey().startsWith(prefix + "/")) { + continue; + } + compared.add(path.getKey()); + String contractKey = path.getKey().substring(prefix.length()); + JsonNode contractPath = contract.path("paths").path(contractKey); + if (contractPath.isMissingNode()) { + problems.add("계약에 없는 path: " + path.getKey()); + continue; + } + for (Map.Entry method : path.getValue().properties()) { + JsonNode contractOp = contractPath.path(method.getKey()); + if (contractOp.isMissingNode()) { + problems.add("계약에 없는 method: " + method.getKey() + " " + path.getKey()); + continue; + } + String publishedId = method.getValue().path("operationId").asText(""); + String contractId = contractOp.path("operationId").asText(""); + if (!publishedId.equals(contractId)) { + problems.add( + "operationId 불일치 " + + method.getKey() + + " " + + path.getKey() + + ": published=" + + publishedId + + " contract=" + + contractId); + } + } + } + assertThat(problems).isEmpty(); + assertThat(compared) + .as( + "published 표면에서 " + + prefix + + " 경로를 하나도 대조하지 못했다 —" + + " PresentationWebConfig 의 api-base-path 배선이나 컨트롤러 매핑을 확인하라." + + " published paths=" + + publishedPaths.properties().stream().map(Map.Entry::getKey).toList()) + .isNotEmpty(); + } + + /** 반대 방향 — 계약의 18 operation 이 전부 published 표면에 있는가. */ + @Test + void everyContractOperationIsPublished() throws Exception { + JsonNode contract = readContract(); + String prefix = serverPrefix(contract); + JsonNode published = readPublishedApiDocs(); + + List missing = new ArrayList<>(); + int contractOperations = 0; + for (Map.Entry path : contract.path("paths").properties()) { + for (Map.Entry method : path.getValue().properties()) { + JsonNode operationId = method.getValue().path("operationId"); + if (operationId.isMissingNode()) { + continue; + } + contractOperations++; + JsonNode publishedOperation = + published.path("paths").path(prefix + 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(); + // 계약이 통째로 비거나 잘못 읽혀도 위 단언은 통과한다 — 순회할 게 없으면 missing 도 비니까. + assertThat(contractOperations).as("public-v1 계약의 operation 수").isEqualTo(18); + } + + private static String serverPrefix(JsonNode contract) { + String url = contract.path("servers").path(0).path("url").asText(""); + return url.endsWith("/") ? url.substring(0, url.length() - 1) : url; + } + + private static JsonNode readContract() throws Exception { + Path contractFile = + RepositoryContractResources.fromSystemProperty() + .requireTrackedFile("src/config/openapi/public-v1.yaml"); + Map contractYaml; + try (InputStream in = Files.newInputStream(contractFile)) { + contractYaml = new Yaml().load(in); + } + return new ObjectMapper().valueToTree(contractYaml); + } + + private JsonNode readPublishedApiDocs() throws Exception { + String body = + mvc.perform(get("/api/v3/api-docs")) + .andExpect(status().isOk()) + .andReturn() + .getResponse() + .getContentAsString(); + return new ObjectMapper().readTree(body); + } + + @SpringBootConfiguration + @EnableAutoConfiguration(exclude = SecurityAutoConfiguration.class) + @ComponentScan("dev.caskeleton.adapter.inbound.web.techlog.publicapi") + @Import({PresentationWebConfig.class, PublicContractDriftTest.PublicPortStubs.class}) + static class ContractSurfaceApp { + + @Bean + PresentationSettings presentationSettings() { + return new PresentationSettings("/api"); + } + } + } + + @Nested + @SpringBootTest(classes = EnvelopeWrapping.EnvelopeApp.class) + @AutoConfigureMockMvc(addFilters = false) + class EnvelopeWrapping { + + @Autowired private MockMvc mvc; + + /** + * ADR-006: 공개 조회 응답도 봉투로 나간다. {@code /topics} 를 고른 이유는 반환 타입이 평범한 POJO 라 {@code + * EnvelopeBodyAdvice} 가 감싸기 전후로 같은 JSON 컨버터가 처리하기 때문이다(형제 테스트가 {@code byte[]} 반환 컨트롤러에서 겪은 + * {@code ClassCastException} 을 피한다). + */ + @Test + void everyPublicResponseIsWrappedInTheEnvelope() throws Exception { + String body = + mvc.perform(get("/api/v1/public/topics")) + .andExpect(status().isOk()) + .andReturn() + .getResponse() + .getContentAsString(); + + assertThat(body).contains("\"success\"").contains("\"data\"").contains("\"meta\""); + assertThat(body).doesNotContain("\"data\":{\"success\""); + } + + @SpringBootConfiguration + @EnableAutoConfiguration(exclude = SecurityAutoConfiguration.class) + @ComponentScan("dev.caskeleton.adapter.inbound.web.techlog.publicapi") + @Import({ + EnvelopeBodyAdvice.class, + PresentationWebConfig.class, + PublicContractDriftTest.PublicPortStubs.class + }) + static class EnvelopeApp { + + @Bean + PresentationSettings presentationSettings() { + return new PresentationSettings("/api"); + } + } + } + + /** + * 7개 outbound port 의 빈 stub 과 그 위에 올린 18개 use case. 첫 번째 테스트는 springdoc 리플렉션이라 컨트롤러 메서드를 아예 호출하지 + * 않고, 두 번째 테스트는 {@code /topics} 하나만 두드리며 감싸는 모양만 본다 — 실제 영속성 어댑터를 끌어오면 이 게이트가 말하려는 것(계약 표면과 봉투)과 + * 무관한 DB 인프라가 딸려 온다. + * + *

    production 의 {@code TechLogPublicConfig} 를 그대로 {@code @Import} 하지 않는 이유는 그 클래스가 + * app-bootstrap 의 {@code main} 소스셋에 있고, 이 functionalTest 소스셋은 {@code main} output 을 클래스패스에 두지 않기 + * 때문이다. 억지로 넣으면 app-bootstrap 의 {@code AutoConfiguration.imports}(fileserver / httpclient)까지 함께 + * 활성화되어, 계약 표면만 보려는 최소 컨텍스트가 무관한 인프라를 요구하게 된다. 대신 같은 조립을 여기서 반복한다 — production 배선 자체는 앱 기동으로 + * 확인한다. + */ + @Configuration(proxyBeanMethods = false) + static class PublicPortStubs { + + @Bean + TransactionPort transactionPort() { + return new PassThroughTransactionPort(); + } + + @Bean + PublicSiteQueryPort publicSiteQueryPort() { + return new PublicSiteQueryPort() { + @Override + public Optional site() { + return Optional.empty(); + } + + @Override + public HomeView home(int latestEntryLimit) { + return new HomeView(null, List.of()); + } + + @Override + public Optional profile() { + return Optional.empty(); + } + }; + } + + @Bean + PublicExploreQueryPort publicExploreQueryPort() { + return new PublicExploreQueryPort() { + @Override + public KnowledgePageView knowledge(ExploreKnowledgeQuery query) { + return new KnowledgePageView(List.of(), PageMetadataView.of(1, 20, 0)); + } + + @Override + public QuestionPageView questions(ExploreQuestionsQuery query) { + return new QuestionPageView(List.of(), PageMetadataView.of(1, 20, 0)); + } + }; + } + + @Bean + PublicTopicQueryPort publicTopicQueryPort() { + return new PublicTopicQueryPort() { + @Override + public List list() { + return List.of(); + } + + @Override + public Optional findBySlug(String slug) { + return Optional.empty(); + } + }; + } + + @Bean + PublicDocumentQueryPort publicDocumentQueryPort() { + return new PublicDocumentQueryPort() { + @Override + public Optional findCase(String slug) { + return Optional.empty(); + } + + @Override + public Optional findReference(String slug) { + return Optional.empty(); + } + + @Override + public Optional findQuestion(String slug) { + return Optional.empty(); + } + }; + } + + @Bean + PublicProjectQueryPort publicProjectQueryPort() { + return new PublicProjectQueryPort() { + @Override + public List list() { + return List.of(); + } + + @Override + public Optional findBySlug(String slug) { + return Optional.empty(); + } + + @Override + public Optional decisions(ProjectDecisionPageQuery query) { + return Optional.empty(); + } + + @Override + public Optional records(ProjectRecordPageQuery query) { + return Optional.empty(); + } + + @Override + public Optional activities(ProjectPageQuery query) { + return Optional.empty(); + } + }; + } + + @Bean + PublicReleaseQueryPort publicReleaseQueryPort() { + return new PublicReleaseQueryPort() { + @Override + public List list() { + return List.of(); + } + + @Override + public Optional findByVersion(String version) { + return Optional.empty(); + } + }; + } + + @Bean + PublicSearchQueryPort publicSearchQueryPort() { + return new PublicSearchQueryPort() { + @Override + public SearchResultPageView search(SearchQuery query) { + return new SearchResultPageView(query.query(), List.of(), PageMetadataView.of(1, 20, 0)); + } + }; + } + + @Bean + GetPublicSiteUseCase getPublicSiteUseCase(PublicSiteQueryPort port, TransactionPort tx) { + return new GetPublicSiteUseCase(port, tx); + } + + @Bean + GetPublicHomeUseCase getPublicHomeUseCase(PublicSiteQueryPort port, TransactionPort tx) { + return new GetPublicHomeUseCase(port, tx); + } + + @Bean + GetPublicProfileUseCase getPublicProfileUseCase(PublicSiteQueryPort port, TransactionPort tx) { + return new GetPublicProfileUseCase(port, tx); + } + + @Bean + ExploreKnowledgeUseCase exploreKnowledgeUseCase( + PublicExploreQueryPort port, TransactionPort tx) { + return new ExploreKnowledgeUseCase(port, tx); + } + + @Bean + ExploreQuestionsUseCase exploreQuestionsUseCase( + PublicExploreQueryPort port, TransactionPort tx) { + return new ExploreQuestionsUseCase(port, tx); + } + + @Bean + ListPublicTopicsUseCase listPublicTopicsUseCase(PublicTopicQueryPort port, TransactionPort tx) { + return new ListPublicTopicsUseCase(port, tx); + } + + @Bean + GetPublicTopicUseCase getPublicTopicUseCase(PublicTopicQueryPort port, TransactionPort tx) { + return new GetPublicTopicUseCase(port, tx); + } + + @Bean + GetPublicCaseUseCase getPublicCaseUseCase(PublicDocumentQueryPort port, TransactionPort tx) { + return new GetPublicCaseUseCase(port, tx); + } + + @Bean + GetPublicReferenceUseCase getPublicReferenceUseCase( + PublicDocumentQueryPort port, TransactionPort tx) { + return new GetPublicReferenceUseCase(port, tx); + } + + @Bean + GetPublicQuestionUseCase getPublicQuestionUseCase( + PublicDocumentQueryPort port, TransactionPort tx) { + return new GetPublicQuestionUseCase(port, tx); + } + + @Bean + ListPublicProjectsUseCase listPublicProjectsUseCase( + PublicProjectQueryPort port, TransactionPort tx) { + return new ListPublicProjectsUseCase(port, tx); + } + + @Bean + GetPublicProjectUseCase getPublicProjectUseCase( + PublicProjectQueryPort port, TransactionPort tx) { + return new GetPublicProjectUseCase(port, tx); + } + + @Bean + ListPublicProjectDecisionsUseCase listPublicProjectDecisionsUseCase( + PublicProjectQueryPort port, TransactionPort tx) { + return new ListPublicProjectDecisionsUseCase(port, tx); + } + + @Bean + ListPublicProjectRecordsUseCase listPublicProjectRecordsUseCase( + PublicProjectQueryPort port, TransactionPort tx) { + return new ListPublicProjectRecordsUseCase(port, tx); + } + + @Bean + ListPublicProjectActivitiesUseCase listPublicProjectActivitiesUseCase( + PublicProjectQueryPort port, TransactionPort tx) { + return new ListPublicProjectActivitiesUseCase(port, tx); + } + + @Bean + ListPublicReleasesUseCase listPublicReleasesUseCase( + PublicReleaseQueryPort port, TransactionPort tx) { + return new ListPublicReleasesUseCase(port, tx); + } + + @Bean + GetPublicReleaseUseCase getPublicReleaseUseCase( + PublicReleaseQueryPort port, TransactionPort tx) { + return new GetPublicReleaseUseCase(port, tx); + } + + @Bean + SearchPublicResourcesUseCase searchPublicResourcesUseCase( + PublicSearchQueryPort port, TransactionPort tx) { + return new SearchPublicResourcesUseCase(port, tx); + } + } + + private static final class PassThroughTransactionPort implements TransactionPort { + @Override + public T inWrite(Supplier action) { + return action.get(); + } + + @Override + public T inRootWrite(Supplier action) { + return action.get(); + } + + @Override + public T inRead(Supplier action) { + return action.get(); + } + + @Override + public T inNew(Supplier action) { + return action.get(); + } + } +} diff --git a/src/app-bootstrap/src/functionalTest/java/dev/caskeleton/bootstrap/contract/StudioContractDriftTest.java b/src/app-bootstrap/src/functionalTest/java/dev/caskeleton/bootstrap/contract/StudioContractDriftTest.java index 32e857f..577128d 100644 --- a/src/app-bootstrap/src/functionalTest/java/dev/caskeleton/bootstrap/contract/StudioContractDriftTest.java +++ b/src/app-bootstrap/src/functionalTest/java/dev/caskeleton/bootstrap/contract/StudioContractDriftTest.java @@ -46,18 +46,24 @@ import org.yaml.snakeyaml.Yaml; * implemented operations are checked (direction is "published ⊆ contract", never the * reverse), so this stays green as slices 2-5 add the other 17 operations — on one * condition: the new controllers must live somewhere under {@code - * dev.caskeleton.adapter.inbound.web.techlog}, the package {@link + * dev.caskeleton.adapter.inbound.web.techlog.studio}, the package {@link * ContractSurface.ContractSurfaceApp} and {@link EnvelopeWrapping.EnvelopeApp} - * {@code @ComponentScan}. A controller placed there is picked up automatically, with no edit to - * this file. A controller placed outside that package tree is invisible to both minimal - * contexts — springdoc never sees it, so this gate stays green even if its path/method/operationId - * contradicts the contract — and the {@code @ComponentScan} base package below must be widened (or - * the new controller moved) before this gate can be trusted again. (An earlier draft of this class - * named the two controllers directly via {@code @Import} instead of scanning; that hardcoded list - * had exactly this blind spot — confirmed by temporarily reintroducing it and observing a - * controller with an out-of-contract mapping pass silently, see task-10-report.md.) This test also - * fails the moment an in-scan controller's method name drifts from its {@code operationId} or ships - * an endpoint outside the contract. + * {@code @ComponentScan}. (The scan sat one level higher — {@code ...web.techlog} — until the + * public-v1 controllers arrived under {@code ...web.techlog.publicapi}: scanning those pulled a + * second contract's controllers into a Studio-only context, which then needs their use-case beans + * and has nothing to say about their contract. {@code PublicContractDriftTest} is this same gate + * for that tree, scanning {@code ...web.techlog.publicapi} against {@code public-v1.yaml}, so each + * contract keeps the automatic-pickup property inside its own package.) A controller placed there + * is picked up automatically, with no edit to this file. A controller placed outside that + * package tree is invisible to both minimal contexts — springdoc never sees it, so this gate stays + * green even if its path/method/operationId contradicts the contract — and the + * {@code @ComponentScan} base package below must be widened (or the new controller moved) before + * this gate can be trusted again. (An earlier draft of this class named the two controllers + * directly via {@code @Import} instead of scanning; that hardcoded list had exactly this blind spot + * — confirmed by temporarily reintroducing it and observing a controller with an out-of-contract + * mapping pass silently, see task-10-report.md.) This test also fails the moment an in-scan + * controller's method name drifts from its {@code operationId} or ships an endpoint outside the + * contract. * *

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

    * @@ -277,7 +283,7 @@ class StudioContractDriftTest { */ @SpringBootConfiguration @EnableAutoConfiguration(exclude = SecurityAutoConfiguration.class) - @ComponentScan("dev.caskeleton.adapter.inbound.web.techlog") + @ComponentScan("dev.caskeleton.adapter.inbound.web.techlog.studio") @Import({PresentationWebConfig.class, StudioContractDriftTest.StudioDocumentTestBeans.class}) static class ContractSurfaceApp { @@ -333,7 +339,7 @@ class StudioContractDriftTest { */ @SpringBootConfiguration @EnableAutoConfiguration(exclude = SecurityAutoConfiguration.class) - @ComponentScan("dev.caskeleton.adapter.inbound.web.techlog") + @ComponentScan("dev.caskeleton.adapter.inbound.web.techlog.studio") @Import({ EnvelopeBodyAdvice.class, PresentationWebConfig.class, diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/techlog/TechLogPublicConfig.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/techlog/TechLogPublicConfig.java new file mode 100644 index 0000000..e1a4fa8 --- /dev/null +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/techlog/TechLogPublicConfig.java @@ -0,0 +1,139 @@ +package dev.caskeleton.bootstrap.techlog; + +import dev.caskeleton.application.techlog.publicsite.port.out.PublicDocumentQueryPort; +import dev.caskeleton.application.techlog.publicsite.port.out.PublicExploreQueryPort; +import dev.caskeleton.application.techlog.publicsite.port.out.PublicProjectQueryPort; +import dev.caskeleton.application.techlog.publicsite.port.out.PublicReleaseQueryPort; +import dev.caskeleton.application.techlog.publicsite.port.out.PublicSearchQueryPort; +import dev.caskeleton.application.techlog.publicsite.port.out.PublicSiteQueryPort; +import dev.caskeleton.application.techlog.publicsite.port.out.PublicTopicQueryPort; +import dev.caskeleton.application.techlog.publicsite.service.ExploreKnowledgeUseCase; +import dev.caskeleton.application.techlog.publicsite.service.ExploreQuestionsUseCase; +import dev.caskeleton.application.techlog.publicsite.service.GetPublicCaseUseCase; +import dev.caskeleton.application.techlog.publicsite.service.GetPublicHomeUseCase; +import dev.caskeleton.application.techlog.publicsite.service.GetPublicProfileUseCase; +import dev.caskeleton.application.techlog.publicsite.service.GetPublicProjectUseCase; +import dev.caskeleton.application.techlog.publicsite.service.GetPublicQuestionUseCase; +import dev.caskeleton.application.techlog.publicsite.service.GetPublicReferenceUseCase; +import dev.caskeleton.application.techlog.publicsite.service.GetPublicReleaseUseCase; +import dev.caskeleton.application.techlog.publicsite.service.GetPublicSiteUseCase; +import dev.caskeleton.application.techlog.publicsite.service.GetPublicTopicUseCase; +import dev.caskeleton.application.techlog.publicsite.service.ListPublicProjectActivitiesUseCase; +import dev.caskeleton.application.techlog.publicsite.service.ListPublicProjectDecisionsUseCase; +import dev.caskeleton.application.techlog.publicsite.service.ListPublicProjectRecordsUseCase; +import dev.caskeleton.application.techlog.publicsite.service.ListPublicProjectsUseCase; +import dev.caskeleton.application.techlog.publicsite.service.ListPublicReleasesUseCase; +import dev.caskeleton.application.techlog.publicsite.service.ListPublicTopicsUseCase; +import dev.caskeleton.application.techlog.publicsite.service.SearchPublicResourcesUseCase; +import dev.caskeleton.application.transaction.TransactionPort; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Tech Log 공개 조회(public-v1) 조립. application-core 는 Spring 을 보지 않으므로 여기서 배선한다 — {@link + * TechLogStudioConfig} 와 같은 이유·같은 모양이다. + * + *

    계약의 18 operation 이 18 use case 와 1:1 이다. 배선을 한 파일에 모아 두면 operation 이 늘거나 줄 때 어디를 고쳐야 하는지가 한 + * 곳으로 정해진다. + */ +@Configuration +public class TechLogPublicConfig { + + @Bean + GetPublicSiteUseCase getPublicSiteUseCase(PublicSiteQueryPort port, TransactionPort tx) { + return new GetPublicSiteUseCase(port, tx); + } + + @Bean + GetPublicHomeUseCase getPublicHomeUseCase(PublicSiteQueryPort port, TransactionPort tx) { + return new GetPublicHomeUseCase(port, tx); + } + + @Bean + GetPublicProfileUseCase getPublicProfileUseCase(PublicSiteQueryPort port, TransactionPort tx) { + return new GetPublicProfileUseCase(port, tx); + } + + @Bean + ExploreKnowledgeUseCase exploreKnowledgeUseCase(PublicExploreQueryPort port, TransactionPort tx) { + return new ExploreKnowledgeUseCase(port, tx); + } + + @Bean + ExploreQuestionsUseCase exploreQuestionsUseCase(PublicExploreQueryPort port, TransactionPort tx) { + return new ExploreQuestionsUseCase(port, tx); + } + + @Bean + ListPublicTopicsUseCase listPublicTopicsUseCase(PublicTopicQueryPort port, TransactionPort tx) { + return new ListPublicTopicsUseCase(port, tx); + } + + @Bean + GetPublicTopicUseCase getPublicTopicUseCase(PublicTopicQueryPort port, TransactionPort tx) { + return new GetPublicTopicUseCase(port, tx); + } + + @Bean + GetPublicCaseUseCase getPublicCaseUseCase(PublicDocumentQueryPort port, TransactionPort tx) { + return new GetPublicCaseUseCase(port, tx); + } + + @Bean + GetPublicReferenceUseCase getPublicReferenceUseCase( + PublicDocumentQueryPort port, TransactionPort tx) { + return new GetPublicReferenceUseCase(port, tx); + } + + @Bean + GetPublicQuestionUseCase getPublicQuestionUseCase( + PublicDocumentQueryPort port, TransactionPort tx) { + return new GetPublicQuestionUseCase(port, tx); + } + + @Bean + ListPublicProjectsUseCase listPublicProjectsUseCase( + PublicProjectQueryPort port, TransactionPort tx) { + return new ListPublicProjectsUseCase(port, tx); + } + + @Bean + GetPublicProjectUseCase getPublicProjectUseCase(PublicProjectQueryPort port, TransactionPort tx) { + return new GetPublicProjectUseCase(port, tx); + } + + @Bean + ListPublicProjectDecisionsUseCase listPublicProjectDecisionsUseCase( + PublicProjectQueryPort port, TransactionPort tx) { + return new ListPublicProjectDecisionsUseCase(port, tx); + } + + @Bean + ListPublicProjectRecordsUseCase listPublicProjectRecordsUseCase( + PublicProjectQueryPort port, TransactionPort tx) { + return new ListPublicProjectRecordsUseCase(port, tx); + } + + @Bean + ListPublicProjectActivitiesUseCase listPublicProjectActivitiesUseCase( + PublicProjectQueryPort port, TransactionPort tx) { + return new ListPublicProjectActivitiesUseCase(port, tx); + } + + @Bean + ListPublicReleasesUseCase listPublicReleasesUseCase( + PublicReleaseQueryPort port, TransactionPort tx) { + return new ListPublicReleasesUseCase(port, tx); + } + + @Bean + GetPublicReleaseUseCase getPublicReleaseUseCase(PublicReleaseQueryPort port, TransactionPort tx) { + return new GetPublicReleaseUseCase(port, tx); + } + + @Bean + SearchPublicResourcesUseCase searchPublicResourcesUseCase( + PublicSearchQueryPort port, TransactionPort tx) { + return new SearchPublicResourcesUseCase(port, tx); + } +} diff --git a/src/app-bootstrap/src/main/resources/application-local.yml b/src/app-bootstrap/src/main/resources/application-local.yml index 57af34b..03a9927 100644 --- a/src/app-bootstrap/src/main/resources/application-local.yml +++ b/src/app-bootstrap/src/main/resources/application-local.yml @@ -154,7 +154,10 @@ ca-skeleton: security: issuer-uri: http://localhost:8081/realms/ca-skeleton audience: ca-skeleton-api - public-paths: /api/healthcheck + # public-v1(공개 조회 계약)은 인증이 없다 — 계약의 security 가 비어 있고 서문이 + # "인증이 필요하지 않다"고 명시한다. deny-by-default 기준선을 넓히는 변경이라 + # docs/security/public-paths-snapshot.txt 가 함께 갱신되어야 통과한다. + public-paths: /api/healthcheck, /api/v1/public/** # 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 diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/PublicErrorRegistryTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/PublicErrorRegistryTest.java new file mode 100644 index 0000000..ad0729c --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/PublicErrorRegistryTest.java @@ -0,0 +1,172 @@ +package dev.caskeleton.bootstrap.architecture; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.PublicClientSafeMessages; +import dev.caskeleton.application.techlog.publicsite.error.PublicError; +import dev.caskeleton.bootstrap.contract.support.RepositoryContractResources; +import dev.caskeleton.shared.error.OperationalError; +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; + +/** + * {@code StudioErrorRegistryTest} 가 {@link dev.caskeleton.application.techlog.error.StudioError} 에 + * 대해 하는 일을 {@link PublicError} 에 대해 한다 — 같은 세 축(row 존재 / 값 드리프트 / client-safe 문구)에 계약 code 집합 대조와 + * vendored 계약 해시까지. + * + *

    계약의 {@code ApiError.code} enum 은 세 값인데 {@link PublicError} 는 둘뿐이다. 나머지 하나 {@code + * INTERNAL_ERROR} 는 이 기능이 아니라 스켈레톤 공통 처리기가 내는 코드({@link OperationalError#INTERNAL_ERROR}) 이며, 같은 + * code 를 두 enum 이 각자 status 와 함께 선언하면 레지스트리가 어느 쪽을 따라야 할지 알 수 없어 일부러 재선언하지 않았다. 그래서 code 집합 대조는 + * "정확히 일치"가 아니라 "계약 = public 소유 ∪ {@code INTERNAL_ERROR}" 를 고정한다 — 어느 쪽에 새 code 가 생기든 이 테스트가 먼저 + * 빨간불이 된다. + */ +class PublicErrorRegistryTest { + + /** 계약이 열거하지만 이 기능이 소유하지 않는 code. 근거는 클래스 javadoc. */ + private static final String SKELETON_OWNED_CODE = OperationalError.INTERNAL_ERROR.code(); + + private static Map> registryRowsByCode; + + @BeforeAll + @SuppressWarnings("unchecked") + static void loadRegistry() throws Exception { + Path registry = + RepositoryContractResources.fromSystemProperty() + .requireTrackedFile("docs/registries/error-codes.yaml"); + registryRowsByCode = new LinkedHashMap<>(); + try (InputStream in = Files.newInputStream(registry)) { + Map root = new Yaml().load(in); + List> errors = (List>) root.get("errors"); + for (Map row : errors) { + registryRowsByCode.put((String) row.get("code"), row); + } + } + } + + @Test + void everyPublicErrorHasARegistryRow() { + Set declared = + Arrays.stream(PublicError.values()).map(PublicError::code).collect(Collectors.toSet()); + + assertThat(registryRowsByCode.keySet()).containsAll(declared); + } + + @Test + void everyPublicErrorRowMatchesCategoryHttpStatusAndRetryable() { + for (PublicError error : PublicError.values()) { + Map row = registryRowsByCode.get(error.code()); + assertThat(row).as("registry row for %s", error.code()).isNotNull(); + + assertThat(row.get("category")) + .as("category for %s", error.code()) + .isEqualTo(error.category().name()); + assertThat(((Number) row.get("http_status")).intValue()) + .as("http_status for %s", error.code()) + .isEqualTo(error.httpStatus()); + assertThat(row.get("retryable")) + .as("retryable for %s", error.code()) + .isEqualTo(error.retryable()); + } + } + + @Test + void everyPublicErrorClientSafeMessageMatchesRegistry() { + for (PublicError error : PublicError.values()) { + Map row = registryRowsByCode.get(error.code()); + assertThat(row).as("registry row for %s", error.code()).isNotNull(); + + assertThat(PublicClientSafeMessages.forError(error)) + .as("client_safe_message for %s", error.code()) + .isEqualTo(row.get("client_safe_message")); + } + } + + @Test + void enumPlusTheSkeletonOwnedCodeMatchesTheContractCodeSet() throws Exception { + Path contract = + RepositoryContractResources.fromSystemProperty() + .requireTrackedFile("src/config/openapi/public-v1.yaml"); + Set contractCodes = contractApiErrorCodes(contract); + + Set enumCodes = + Arrays.stream(PublicError.values()).map(PublicError::code).collect(Collectors.toSet()); + + assertThat(contractCodes) + .as("public-v1.yaml ApiError.code enum vs PublicError + %s", SKELETON_OWNED_CODE) + .containsExactlyInAnyOrderElementsOf( + java.util.stream.Stream.concat( + enumCodes.stream(), java.util.stream.Stream.of(SKELETON_OWNED_CODE)) + .collect(Collectors.toSet())); + assertThat(enumCodes) + .as("PublicError 는 스켈레톤 소유 code 를 재선언하지 않는다") + .doesNotContain(SKELETON_OWNED_CODE); + } + + @SuppressWarnings("unchecked") + private static Set contractApiErrorCodes(Path contract) throws IOException { + try (InputStream in = Files.newInputStream(contract)) { + Map root = new Yaml().load(in); + Map components = (Map) root.get("components"); + Map schemas = (Map) components.get("schemas"); + Map apiError = (Map) schemas.get("ApiError"); + Map properties = (Map) apiError.get("properties"); + Map code = (Map) properties.get("code"); + List enumValues = (List) code.get("enum"); + return Set.copyOf(enumValues); + } + } + + /** + * {@code src/config/openapi/public-v1.yaml} 은 설계 패키지 계약의 vendored 사본이다({@code MANIFEST.sha256} 의 + * {@code # source:} 줄이 출처를 기록한다). 이 단언이 없으면 vendor 사본을 손으로 고쳐도 아무도 알아채지 못한다 — studio 쪽과 같은 이유의 같은 + * 게이트다. + */ + @Test + void vendoredContractMatchesTheRecordedManifestHash() throws Exception { + RepositoryContractResources resources = RepositoryContractResources.fromSystemProperty(); + Path contract = resources.requireTrackedFile("src/config/openapi/public-v1.yaml"); + Path manifest = resources.requireTrackedFile("src/config/openapi/MANIFEST.sha256"); + + assertThat(sha256Hex(contract)) + .as( + "src/config/openapi/public-v1.yaml sha256 must match the value MANIFEST.sha256 recorded" + + " for it (local edit or vendoring drift)") + .isEqualTo(recordedSha256(manifest, "public-v1.yaml")); + } + + private static String recordedSha256(Path manifest, String filename) throws IOException { + return Files.readAllLines(manifest).stream() + .map(String::strip) + .filter(line -> !line.isEmpty() && !line.startsWith("#")) + .filter(line -> line.endsWith(filename)) + .map(line -> line.substring(0, line.indexOf(' ')).strip()) + .findFirst() + .orElseThrow( + () -> + new IllegalStateException( + "MANIFEST.sha256 has no hash row for " + filename + ": " + manifest)); + } + + private static String sha256Hex(Path file) throws IOException, NoSuchAlgorithmException { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest(Files.readAllBytes(file)); + StringBuilder hex = new StringBuilder(hash.length * 2); + for (byte b : hash) { + hex.append(String.format("%02x", b)); + } + return hex.toString(); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/error/PublicError.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/error/PublicError.java new file mode 100644 index 0000000..bed8d17 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/error/PublicError.java @@ -0,0 +1,48 @@ +package dev.caskeleton.application.techlog.publicsite.error; + +import dev.caskeleton.shared.error.ApiErrorCode; +import dev.caskeleton.shared.error.Category; + +/** + * 공개 조회 계약(`public-v1.yaml`)의 `ApiError.code`. + * + *

    계약의 enum 은 세 값인데 여기엔 둘뿐이다. 나머지 하나 {@code INTERNAL_ERROR} 는 이 기능이 아니라 스켈레톤의 공통 예외 처리기가 내는 코드이므로 + * 여기서 다시 선언하지 않는다 — 같은 코드를 두 enum 이 각자 status 와 함께 선언하면 레지스트리가 어느 쪽을 따라야 할지 알 수 없다. + * + *

    Studio 의 코드와 이름을 겹치지 않게 한 것도 같은 이유다. 레지스트리는 코드 하나에 status 하나만 담을 수 있어서 public 의 400 과 studio 의 + * 422 를 같은 이름으로 쓸 수 없다. + */ +public enum PublicError implements ApiErrorCode { + PUBLIC_REQUEST_INVALID(Category.VALIDATION, 400, false), + PUBLIC_RESOURCE_NOT_FOUND(Category.NOT_FOUND, 404, false); + + private final Category category; + private final int httpStatus; + private final boolean retryable; + + PublicError(Category category, int httpStatus, boolean retryable) { + this.category = category; + this.httpStatus = httpStatus; + this.retryable = retryable; + } + + @Override + public String code() { + return name(); + } + + @Override + public Category category() { + return category; + } + + @Override + public int httpStatus() { + return httpStatus; + } + + @Override + public boolean retryable() { + return retryable; + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/error/PublicException.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/error/PublicException.java new file mode 100644 index 0000000..f479341 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/error/PublicException.java @@ -0,0 +1,31 @@ +package dev.caskeleton.application.techlog.publicsite.error; + +import dev.caskeleton.shared.error.ApiErrorCarrier; +import dev.caskeleton.shared.error.ApiErrorCode; + +/** + * 공개 조회 실패. {@link #getMessage()} 는 진단용이며 클라이언트에게 그대로 나가지 않는다 — 응답 문구는 레지스트리의 client-safe message 를 + * 쓴다({@code ApiErrorCarrier} javadoc). + */ +public final class PublicException extends RuntimeException implements ApiErrorCarrier { + + private final transient PublicError error; + + private PublicException(PublicError error, String message) { + super(message); + this.error = error; + } + + public static PublicException of(PublicError error, String message) { + return new PublicException(error, message); + } + + @Override + public ApiErrorCode errorCode() { + return error; + } + + public PublicError publicError() { + return error; + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/AssetReferenceView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/AssetReferenceView.java new file mode 100644 index 0000000..7ae1fad --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/AssetReferenceView.java @@ -0,0 +1,11 @@ +package dev.caskeleton.application.techlog.publicsite.model; + +import java.util.UUID; + +/** + * 계약 {@code AssetReference}. + * + * @param url 검증된 전송 경로다. object storage URL 이 아니다(설계 05장 §3.1). + */ +public record AssetReferenceView( + UUID assetId, String url, String altText, Integer width, Integer height, String contentType) {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/CaseDetailView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/CaseDetailView.java new file mode 100644 index 0000000..aa1bc1e --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/CaseDetailView.java @@ -0,0 +1,8 @@ +package dev.caskeleton.application.techlog.publicsite.model; + +/** 계약 {@code CaseDetailResponse}. */ +public record CaseDetailView( + String canonicalPath, + boolean indexable, + PublishedDocumentView document, + CaseRelationsView relations) {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/CaseRelationsView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/CaseRelationsView.java new file mode 100644 index 0000000..c33192e --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/CaseRelationsView.java @@ -0,0 +1,17 @@ +package dev.caskeleton.application.techlog.publicsite.model; + +import java.util.List; + +/** 계약 {@code CaseDetailResponse.relations}. */ +public record CaseRelationsView( + RelatedEntryView originQuestion, + List projectDecisions, + List derivedReferences, + List relatedCases) { + + public CaseRelationsView { + projectDecisions = projectDecisions == null ? List.of() : List.copyOf(projectDecisions); + derivedReferences = derivedReferences == null ? List.of() : List.copyOf(derivedReferences); + relatedCases = relatedCases == null ? List.of() : List.copyOf(relatedCases); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/ContactLinkView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/ContactLinkView.java new file mode 100644 index 0000000..88496ec --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/ContactLinkView.java @@ -0,0 +1,4 @@ +package dev.caskeleton.application.techlog.publicsite.model; + +/** 계약 {@code ContactLink}. */ +public record ContactLinkView(String type, String label, String url) {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/HomeFocusView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/HomeFocusView.java new file mode 100644 index 0000000..e751cc0 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/HomeFocusView.java @@ -0,0 +1,106 @@ +package dev.caskeleton.application.techlog.publicsite.model; + +import java.time.Instant; +import java.util.List; + +/** + * 계약 {@code HomeResponse.focus}. {@code defaultType} 이 어느 갈래를 보여줄지 정하고 세 갈래는 전부 optional 이다 — 계약이 + * 그렇게 정했다. 설정된 갈래가 비어 있을 수 있으므로(예: 지목한 질문이 비공개가 되었을 때) 타입으로 "반드시 하나는 있다"를 강제하지 않는다. + */ +public record HomeFocusView( + String defaultType, + CurrentWork currentWork, + OpenQuestion openQuestion, + RecentDecision recentDecision) { + + /** + * 설정된 값이 없거나 그 갈래가 비었을 때 쓸 순서. 계약이 {@code defaultType} 을 required 로 선언했으므로 "정해진 게 없다"를 null 로 표현할 + * 수 없다. + */ + private static final String[] FALLBACK_ORDER = { + "CURRENT_WORK", "OPEN_QUESTION", "RECENT_DECISION" + }; + + /** + * 계약이 {@code focus.defaultType} 을 required 로 선언하고 값 셋만 허용한다. 그런데 설정 테이블은 갓 마이그레이션한 상태에서 {@code + * default_focus_type} 이 NULL 이고, 지목한 갈래가 비공개로 바뀌어 비는 경우도 있다. 그대로 내보내면 응답 매퍼가 계약 밖 값을 만나 홈 화면 전체가 + * 실패한다 — 실제로 배포 직후 첫 요청이 그렇게 깨졌다. + * + *

    그래서 이 자리에서 반드시 유효한 값 하나를 정한다. + * + *

      + *
    1. 설정된 값이 유효하고 그 갈래에 내용이 있으면 그대로 쓴다. + *
    2. 아니면 내용이 있는 갈래를 {@link #FALLBACK_ORDER} 순으로 고른다. + *
    3. 셋 다 비었으면 첫 값을 쓴다 — 세 갈래는 전부 optional 이므로 비어 있어도 계약을 만족한다. + *
    + */ + public static HomeFocusView resolve( + String configuredType, + CurrentWork currentWork, + OpenQuestion openQuestion, + RecentDecision recentDecision) { + if (configuredType != null + && hasContent(configuredType, currentWork, openQuestion, recentDecision)) { + return new HomeFocusView(configuredType, currentWork, openQuestion, recentDecision); + } + for (String candidate : FALLBACK_ORDER) { + if (hasContent(candidate, currentWork, openQuestion, recentDecision)) { + return new HomeFocusView(candidate, currentWork, openQuestion, recentDecision); + } + } + return new HomeFocusView(FALLBACK_ORDER[0], currentWork, openQuestion, recentDecision); + } + + private static boolean hasContent( + String type, + CurrentWork currentWork, + OpenQuestion openQuestion, + RecentDecision recentDecision) { + return switch (type) { + case "CURRENT_WORK" -> currentWork != null; + case "OPEN_QUESTION" -> openQuestion != null; + case "RECENT_DECISION" -> recentDecision != null; + // 계약 밖 값이 설정에 들어 있는 경우다. 그대로 쓰면 응답이 깨지므로 없는 것으로 친다. + default -> false; + }; + } + + /** 계약 {@code CurrentWorkFocus}. */ + public record CurrentWork( + String projectName, + String projectPath, + String purpose, + String phase, + String currentObjective, + String nextStep, + Instant updatedAt) {} + + /** 계약 {@code OpenQuestionFocus}. */ + public record OpenQuestion( + String question, + String questionPath, + String summary, + List knownFacts, + List unresolvedPoints, + String nextVerification, + Instant updatedAt) { + + public OpenQuestion { + knownFacts = knownFacts == null ? List.of() : List.copyOf(knownFacts); + unresolvedPoints = unresolvedPoints == null ? List.of() : List.copyOf(unresolvedPoints); + } + } + + /** 계약 {@code RecentDecisionFocus}. */ + public record RecentDecision( + String statement, + String decisionPath, + String rationale, + List consequences, + Instant decidedAt) { + + public RecentDecision { + consequences = consequences == null ? List.of() : List.copyOf(consequences); + } + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/HomeView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/HomeView.java new file mode 100644 index 0000000..acfae3c --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/HomeView.java @@ -0,0 +1,11 @@ +package dev.caskeleton.application.techlog.publicsite.model; + +import java.util.List; + +/** 계약 {@code HomeResponse}. */ +public record HomeView(HomeFocusView focus, List latestEntries) { + + public HomeView { + latestEntries = latestEntries == null ? List.of() : List.copyOf(latestEntries); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/KnowledgeListItemView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/KnowledgeListItemView.java new file mode 100644 index 0000000..230ce4a --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/KnowledgeListItemView.java @@ -0,0 +1,16 @@ +package dev.caskeleton.application.techlog.publicsite.model; + +import java.time.Instant; + +/** 계약 {@code KnowledgeListItem}. */ +public record KnowledgeListItemView( + String type, + String title, + String path, + String primarySummary, + String secondarySummary, + TopicSummaryView primaryTopic, + ProjectSummaryView primaryProject, + Instant publishedAt, + Instant lastVerifiedAt, + String freshnessStatus) {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/KnowledgePageView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/KnowledgePageView.java new file mode 100644 index 0000000..04462fc --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/KnowledgePageView.java @@ -0,0 +1,11 @@ +package dev.caskeleton.application.techlog.publicsite.model; + +import java.util.List; + +/** 계약 {@code KnowledgePage}. */ +public record KnowledgePageView(List items, PageMetadataView page) { + + public KnowledgePageView { + items = items == null ? List.of() : List.copyOf(items); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/LatestEntryView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/LatestEntryView.java new file mode 100644 index 0000000..9ac52cc --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/LatestEntryView.java @@ -0,0 +1,13 @@ +package dev.caskeleton.application.techlog.publicsite.model; + +import java.time.Instant; + +/** 계약 {@code LatestEntry}. */ +public record LatestEntryView( + String entryType, + String title, + String summary, + String path, + TopicSummaryView primaryTopic, + ProjectSummaryView primaryProject, + Instant publishedAt) {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/PageMetadataView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/PageMetadataView.java new file mode 100644 index 0000000..2565ee8 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/PageMetadataView.java @@ -0,0 +1,19 @@ +package dev.caskeleton.application.techlog.publicsite.model; + +/** + * 계약 {@code PageMetadata}. 공개 조회는 studio 와 달리 offset 페이지네이션이다 — 계약이 그렇게 정했고, 공개 목록은 "3페이지로 바로 가기"가 + * 필요한 화면이라 cursor 로 대체할 수 없다. + */ +public record PageMetadataView( + int number, + int size, + long totalElements, + int totalPages, + boolean hasPrevious, + boolean hasNext) { + + public static PageMetadataView of(int page, int size, long total) { + int totalPages = size <= 0 ? 0 : (int) Math.ceil((double) total / size); + return new PageMetadataView(page, size, total, totalPages, page > 1, page < totalPages); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/ProfileView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/ProfileView.java new file mode 100644 index 0000000..226294d --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/ProfileView.java @@ -0,0 +1,28 @@ +package dev.caskeleton.application.techlog.publicsite.model; + +import java.util.List; + +/** 계약 {@code ProfileResponse}. */ +public record ProfileView( + String headline, + String description, + List workingModel, + List territories, + List selectedEvidence, + List trajectory, + List contacts) { + + public ProfileView { + workingModel = workingModel == null ? List.of() : List.copyOf(workingModel); + territories = territories == null ? List.of() : List.copyOf(territories); + selectedEvidence = selectedEvidence == null ? List.of() : List.copyOf(selectedEvidence); + trajectory = trajectory == null ? List.of() : List.copyOf(trajectory); + contacts = contacts == null ? List.of() : List.copyOf(contacts); + } + + /** {@code workingModel[]} 과 {@code trajectory[]} 가 같은 모양이라 하나로 쓴다. */ + public record NamedDescription(String name, String description) {} + + /** {@code territories[]}. */ + public record Territory(String name, String currentQuestion, String topicPath) {} +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/ProjectActivityItemView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/ProjectActivityItemView.java new file mode 100644 index 0000000..e405d78 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/ProjectActivityItemView.java @@ -0,0 +1,7 @@ +package dev.caskeleton.application.techlog.publicsite.model; + +import java.time.Instant; + +/** 계약 {@code ProjectActivityItem}. */ +public record ProjectActivityItemView( + String type, String title, String summary, Instant occurredAt, String relatedPath) {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/ProjectActivityPageView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/ProjectActivityPageView.java new file mode 100644 index 0000000..0139197 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/ProjectActivityPageView.java @@ -0,0 +1,11 @@ +package dev.caskeleton.application.techlog.publicsite.model; + +import java.util.List; + +/** 계약 {@code ProjectActivityPage}. */ +public record ProjectActivityPageView(List items, PageMetadataView page) { + + public ProjectActivityPageView { + items = items == null ? List.of() : List.copyOf(items); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/ProjectDecisionItemView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/ProjectDecisionItemView.java new file mode 100644 index 0000000..e55c431 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/ProjectDecisionItemView.java @@ -0,0 +1,14 @@ +package dev.caskeleton.application.techlog.publicsite.model; + +import java.time.Instant; +import java.util.UUID; + +/** 계약 {@code ProjectDecisionItem}. */ +public record ProjectDecisionItemView( + UUID id, + String statement, + String status, + String rationaleSummary, + Instant decidedAt, + RelatedEntryView sourceQuestion, + RelatedEntryView sourceCase) {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/ProjectDecisionPageView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/ProjectDecisionPageView.java new file mode 100644 index 0000000..cbd2443 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/ProjectDecisionPageView.java @@ -0,0 +1,11 @@ +package dev.caskeleton.application.techlog.publicsite.model; + +import java.util.List; + +/** 계약 {@code ProjectDecisionPage}. */ +public record ProjectDecisionPageView(List items, PageMetadataView page) { + + public ProjectDecisionPageView { + items = items == null ? List.of() : List.copyOf(items); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/ProjectDetailView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/ProjectDetailView.java new file mode 100644 index 0000000..2c4685c --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/ProjectDetailView.java @@ -0,0 +1,17 @@ +package dev.caskeleton.application.techlog.publicsite.model; + +import java.util.List; + +/** 계약 {@code ProjectDetailResponse}. */ +public record ProjectDetailView( + String canonicalPath, + boolean indexable, + PublishedProjectView project, + RelatedEntryView featuredDecision, + RelatedEntryView activeQuestion, + List selectedRecords) { + + public ProjectDetailView { + selectedRecords = selectedRecords == null ? List.of() : List.copyOf(selectedRecords); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/ProjectListItemView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/ProjectListItemView.java new file mode 100644 index 0000000..ef4ee64 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/ProjectListItemView.java @@ -0,0 +1,14 @@ +package dev.caskeleton.application.techlog.publicsite.model; + +import java.time.Instant; + +/** 계약 {@code ProjectListItem}. */ +public record ProjectListItemView( + String name, + String slug, + String path, + String oneLinePurpose, + String phase, + String currentObjective, + String nextStep, + Instant updatedAt) {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/ProjectRecordPageView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/ProjectRecordPageView.java new file mode 100644 index 0000000..5145083 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/ProjectRecordPageView.java @@ -0,0 +1,11 @@ +package dev.caskeleton.application.techlog.publicsite.model; + +import java.util.List; + +/** 계약 {@code ProjectRecordPage}. */ +public record ProjectRecordPageView(List items, PageMetadataView page) { + + public ProjectRecordPageView { + items = items == null ? List.of() : List.copyOf(items); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/ProjectSummaryView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/ProjectSummaryView.java new file mode 100644 index 0000000..79c491a --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/ProjectSummaryView.java @@ -0,0 +1,4 @@ +package dev.caskeleton.application.techlog.publicsite.model; + +/** 계약 {@code ProjectSummary}. */ +public record ProjectSummaryView(String name, String slug, String path) {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/PublishedDocumentView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/PublishedDocumentView.java new file mode 100644 index 0000000..27bdf31 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/PublishedDocumentView.java @@ -0,0 +1,41 @@ +package dev.caskeleton.application.techlog.publicsite.model; + +import java.time.Instant; +import java.util.List; + +/** + * 공개된 Case / Reference 의 본문과 메타데이터. + * + *

    계약의 {@code CaseDetailResponse.case} 와 {@code ReferenceDetailResponse.reference} 는 담는 필드가 + * 다르지만(문제/결론 vs 범위/적용), 원천이 같은 {@code document} + 유형별 detail 이라 하나의 레코드로 읽고 웹 계층에서 유형별 모양으로 나눈다. + * + * @param content Markdown 원문이다. studio 의 렌더 블록이 아니다 — 공개 계약은 {@code contentFormat} 과 함께 원문을 준다. + */ +public record PublishedDocumentView( + String type, + String canonicalPath, + String title, + String primarySummary, + String secondarySummary, + List environmentSummary, + List appliesTo, + List excludedScope, + String freshnessStatus, + String content, + String contentFormat, + int contentFormatVersion, + TopicSummaryView primaryTopic, + List tags, + ProjectSummaryView primaryProject, + AssetReferenceView coverAsset, + Instant publishedAt, + Instant updatedAt, + Instant lastVerifiedAt) { + + public PublishedDocumentView { + environmentSummary = environmentSummary == null ? List.of() : List.copyOf(environmentSummary); + appliesTo = appliesTo == null ? List.of() : List.copyOf(appliesTo); + excludedScope = excludedScope == null ? List.of() : List.copyOf(excludedScope); + tags = tags == null ? List.of() : List.copyOf(tags); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/PublishedProjectView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/PublishedProjectView.java new file mode 100644 index 0000000..b297bff --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/PublishedProjectView.java @@ -0,0 +1,23 @@ +package dev.caskeleton.application.techlog.publicsite.model; + +import java.time.Instant; +import java.util.List; + +/** 계약 {@code ProjectDetailResponse.project}. */ +public record PublishedProjectView( + String name, + String slug, + String oneLinePurpose, + String purpose, + String boundary, + String phase, + String currentObjective, + String nextStep, + String systemOverviewMarkdown, + List technologies, + Instant updatedAt) { + + public PublishedProjectView { + technologies = technologies == null ? List.of() : List.copyOf(technologies); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/PublishedQuestionView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/PublishedQuestionView.java new file mode 100644 index 0000000..741eaf3 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/PublishedQuestionView.java @@ -0,0 +1,25 @@ +package dev.caskeleton.application.techlog.publicsite.model; + +import java.time.Instant; +import java.util.List; + +/** 계약 {@code QuestionDetailResponse.question}. */ +public record PublishedQuestionView( + String question, + String summary, + String context, + String importance, + String status, + String nextVerification, + QuestionPointGroupView points, + List updates, + String resolutionType, + String resolutionSummary, + Instant resolvedAt, + Instant openedAt, + Instant updatedAt) { + + public PublishedQuestionView { + updates = updates == null ? List.of() : List.copyOf(updates); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/QuestionDetailView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/QuestionDetailView.java new file mode 100644 index 0000000..d09ff02 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/QuestionDetailView.java @@ -0,0 +1,8 @@ +package dev.caskeleton.application.techlog.publicsite.model; + +/** 계약 {@code QuestionDetailResponse}. */ +public record QuestionDetailView( + String canonicalPath, + boolean indexable, + PublishedQuestionView question, + QuestionRelationsView relations) {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/QuestionListItemView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/QuestionListItemView.java new file mode 100644 index 0000000..d206157 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/QuestionListItemView.java @@ -0,0 +1,14 @@ +package dev.caskeleton.application.techlog.publicsite.model; + +import java.time.Instant; + +/** 계약 {@code QuestionListItem}. */ +public record QuestionListItemView( + String question, + String path, + String status, + String summary, + String currentUnderstanding, + String nextVerification, + ProjectSummaryView primaryProject, + Instant updatedAt) {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/QuestionPageView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/QuestionPageView.java new file mode 100644 index 0000000..26a7753 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/QuestionPageView.java @@ -0,0 +1,11 @@ +package dev.caskeleton.application.techlog.publicsite.model; + +import java.util.List; + +/** 계약 {@code QuestionPage}. */ +public record QuestionPageView(List items, PageMetadataView page) { + + public QuestionPageView { + items = items == null ? List.of() : List.copyOf(items); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/QuestionPointGroupView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/QuestionPointGroupView.java new file mode 100644 index 0000000..ecf87c5 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/QuestionPointGroupView.java @@ -0,0 +1,15 @@ +package dev.caskeleton.application.techlog.publicsite.model; + +import java.util.List; + +/** 계약 {@code QuestionPointGroup}. */ +public record QuestionPointGroupView( + List facts, List assumptions, List unknowns, List constraints) { + + public QuestionPointGroupView { + facts = facts == null ? List.of() : List.copyOf(facts); + assumptions = assumptions == null ? List.of() : List.copyOf(assumptions); + unknowns = unknowns == null ? List.of() : List.copyOf(unknowns); + constraints = constraints == null ? List.of() : List.copyOf(constraints); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/QuestionRelationsView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/QuestionRelationsView.java new file mode 100644 index 0000000..84d56e9 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/QuestionRelationsView.java @@ -0,0 +1,15 @@ +package dev.caskeleton.application.techlog.publicsite.model; + +import java.util.List; + +/** 계약 {@code QuestionDetailResponse.relations}. */ +public record QuestionRelationsView( + RelatedEntryView primaryProject, + RelatedEntryView resultCase, + RelatedEntryView producedDecision, + List derivedReferences) { + + public QuestionRelationsView { + derivedReferences = derivedReferences == null ? List.of() : List.copyOf(derivedReferences); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/QuestionUpdateView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/QuestionUpdateView.java new file mode 100644 index 0000000..b633954 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/QuestionUpdateView.java @@ -0,0 +1,7 @@ +package dev.caskeleton.application.techlog.publicsite.model; + +import java.time.Instant; + +/** 계약 {@code QuestionUpdatePublic}. 공개된 조사 기록 한 건이다. */ +public record QuestionUpdateView( + String type, String title, String bodyMarkdown, Instant occurredAt) {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/ReferenceDetailView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/ReferenceDetailView.java new file mode 100644 index 0000000..667da15 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/ReferenceDetailView.java @@ -0,0 +1,8 @@ +package dev.caskeleton.application.techlog.publicsite.model; + +/** 계약 {@code ReferenceDetailResponse}. */ +public record ReferenceDetailView( + String canonicalPath, + boolean indexable, + PublishedDocumentView document, + ReferenceRelationsView relations) {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/ReferenceRelationsView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/ReferenceRelationsView.java new file mode 100644 index 0000000..4784dcd --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/ReferenceRelationsView.java @@ -0,0 +1,16 @@ +package dev.caskeleton.application.techlog.publicsite.model; + +import java.util.List; + +/** 계약 {@code ReferenceDetailResponse.relations}. Case 의 관계와 이름·구성이 다르다. */ +public record ReferenceRelationsView( + List supportingCases, + List relatedDecisions, + List relatedReferences) { + + public ReferenceRelationsView { + supportingCases = supportingCases == null ? List.of() : List.copyOf(supportingCases); + relatedDecisions = relatedDecisions == null ? List.of() : List.copyOf(relatedDecisions); + relatedReferences = relatedReferences == null ? List.of() : List.copyOf(relatedReferences); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/RelatedEntryView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/RelatedEntryView.java new file mode 100644 index 0000000..f335bb2 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/RelatedEntryView.java @@ -0,0 +1,4 @@ +package dev.caskeleton.application.techlog.publicsite.model; + +/** 계약 {@code RelatedEntry}. */ +public record RelatedEntryView(String type, String title, String summary, String path) {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/ReleaseDetailView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/ReleaseDetailView.java new file mode 100644 index 0000000..efa0b2b --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/ReleaseDetailView.java @@ -0,0 +1,25 @@ +package dev.caskeleton.application.techlog.publicsite.model; + +import java.time.LocalDate; +import java.util.List; + +/** 계약 {@code ReleaseDetailResponse}. */ +public record ReleaseDetailView( + String version, + String title, + String summary, + LocalDate releasedOn, + List changeTypes, + String reasonMarkdown, + String changesMarkdown, + String userImpactMarkdown, + String implementationImpactMarkdown, + String verificationMarkdown, + String knownLimitationsMarkdown, + List relatedRecords) { + + public ReleaseDetailView { + changeTypes = changeTypes == null ? List.of() : List.copyOf(changeTypes); + relatedRecords = relatedRecords == null ? List.of() : List.copyOf(relatedRecords); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/ReleaseListItemView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/ReleaseListItemView.java new file mode 100644 index 0000000..a3c2286 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/ReleaseListItemView.java @@ -0,0 +1,18 @@ +package dev.caskeleton.application.techlog.publicsite.model; + +import java.time.LocalDate; +import java.util.List; + +/** 계약 {@code ReleaseListItem}. */ +public record ReleaseListItemView( + String version, + String title, + String summary, + LocalDate releasedOn, + List changeTypes, + String path) { + + public ReleaseListItemView { + changeTypes = changeTypes == null ? List.of() : List.copyOf(changeTypes); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/SearchResultItemView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/SearchResultItemView.java new file mode 100644 index 0000000..3138a81 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/SearchResultItemView.java @@ -0,0 +1,21 @@ +package dev.caskeleton.application.techlog.publicsite.model; + +import java.time.Instant; +import java.util.List; + +/** 계약 {@code SearchResultItem}. */ +public record SearchResultItemView( + String contentType, + String title, + String path, + String snippet, + List matchedFields, + TopicSummaryView primaryTopic, + ProjectSummaryView primaryProject, + Instant publishedAt, + Instant updatedAt) { + + public SearchResultItemView { + matchedFields = matchedFields == null ? List.of() : List.copyOf(matchedFields); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/SearchResultPageView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/SearchResultPageView.java new file mode 100644 index 0000000..c8d20df --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/SearchResultPageView.java @@ -0,0 +1,12 @@ +package dev.caskeleton.application.techlog.publicsite.model; + +import java.util.List; + +/** 계약 {@code SearchResultPage}. */ +public record SearchResultPageView( + String query, List items, PageMetadataView page) { + + public SearchResultPageView { + items = items == null ? List.of() : List.copyOf(items); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/SiteView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/SiteView.java new file mode 100644 index 0000000..abb1256 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/SiteView.java @@ -0,0 +1,18 @@ +package dev.caskeleton.application.techlog.publicsite.model; + +import java.util.List; + +/** 계약 {@code SiteResponse}. */ +public record SiteView( + String brandTitle, + String identityStatement, + String operatorDisplayName, + String operatorShortIdentity, + AssetReferenceView operatorAvatar, + String operatorProfilePath, + List contacts) { + + public SiteView { + contacts = contacts == null ? List.of() : List.copyOf(contacts); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/TagSummaryView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/TagSummaryView.java new file mode 100644 index 0000000..866c452 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/TagSummaryView.java @@ -0,0 +1,4 @@ +package dev.caskeleton.application.techlog.publicsite.model; + +/** 계약 {@code TagSummary}. */ +public record TagSummaryView(String name, String slug) {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/TopicDetailView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/TopicDetailView.java new file mode 100644 index 0000000..07d38f0 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/TopicDetailView.java @@ -0,0 +1,23 @@ +package dev.caskeleton.application.techlog.publicsite.model; + +import java.util.List; + +/** 계약 {@code TopicDetailResponse}. */ +public record TopicDetailView( + String name, + String slug, + String description, + String scope, + RelatedEntryView featuredReference, + List featuredCases, + List activeQuestions, + List relatedProjects, + List latestRecords) { + + public TopicDetailView { + featuredCases = featuredCases == null ? List.of() : List.copyOf(featuredCases); + activeQuestions = activeQuestions == null ? List.of() : List.copyOf(activeQuestions); + relatedProjects = relatedProjects == null ? List.of() : List.copyOf(relatedProjects); + latestRecords = latestRecords == null ? List.of() : List.copyOf(latestRecords); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/TopicListItemView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/TopicListItemView.java new file mode 100644 index 0000000..4e21ce6 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/TopicListItemView.java @@ -0,0 +1,4 @@ +package dev.caskeleton.application.techlog.publicsite.model; + +/** 계약 {@code TopicListItem}. */ +public record TopicListItemView(String name, String slug, String description, int recordCount) {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/TopicSummaryView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/TopicSummaryView.java new file mode 100644 index 0000000..fb362fe --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/TopicSummaryView.java @@ -0,0 +1,4 @@ +package dev.caskeleton.application.techlog.publicsite.model; + +/** 계약 {@code TopicSummary}. */ +public record TopicSummaryView(String name, String slug) {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/port/out/PublicDocumentQueryPort.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/port/out/PublicDocumentQueryPort.java new file mode 100644 index 0000000..ef0c331 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/port/out/PublicDocumentQueryPort.java @@ -0,0 +1,22 @@ +package dev.caskeleton.application.techlog.publicsite.port.out; + +import dev.caskeleton.application.techlog.publicsite.model.CaseDetailView; +import dev.caskeleton.application.techlog.publicsite.model.QuestionDetailView; +import dev.caskeleton.application.techlog.publicsite.model.ReferenceDetailView; +import java.util.Optional; + +/** + * 공개된 기록 상세. + * + *

    본문은 {@code public_resource_projection.payload}(studio 렌더 모델)가 아니라 원본 테이블에서 읽는다 — 공개 계약의 상세 모양은 + * 렌더 모델과 다르다(본문이 블록 배열이 아니라 Markdown 원문이고, environmentSummary 가 배열이며, tags/coverAsset 과 유형별 관계가 따로 + * 있다). projection 은 "무엇이 공개됐는가"와 게시 시각을 정하는 데 쓴다. + */ +public interface PublicDocumentQueryPort { + + Optional findCase(String slug); + + Optional findReference(String slug); + + Optional findQuestion(String slug); +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/port/out/PublicExploreQueryPort.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/port/out/PublicExploreQueryPort.java new file mode 100644 index 0000000..a58d083 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/port/out/PublicExploreQueryPort.java @@ -0,0 +1,14 @@ +package dev.caskeleton.application.techlog.publicsite.port.out; + +import dev.caskeleton.application.techlog.publicsite.model.KnowledgePageView; +import dev.caskeleton.application.techlog.publicsite.model.QuestionPageView; +import dev.caskeleton.application.techlog.publicsite.query.ExploreKnowledgeQuery; +import dev.caskeleton.application.techlog.publicsite.query.ExploreQuestionsQuery; + +/** 탐색 목록. 공개된 것만 본다 — {@code public_resource_projection.publication_state = 'ACTIVE'}. */ +public interface PublicExploreQueryPort { + + KnowledgePageView knowledge(ExploreKnowledgeQuery query); + + QuestionPageView questions(ExploreQuestionsQuery query); +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/port/out/PublicProjectQueryPort.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/port/out/PublicProjectQueryPort.java new file mode 100644 index 0000000..76c5263 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/port/out/PublicProjectQueryPort.java @@ -0,0 +1,27 @@ +package dev.caskeleton.application.techlog.publicsite.port.out; + +import dev.caskeleton.application.techlog.publicsite.model.ProjectActivityPageView; +import dev.caskeleton.application.techlog.publicsite.model.ProjectDecisionPageView; +import dev.caskeleton.application.techlog.publicsite.model.ProjectDetailView; +import dev.caskeleton.application.techlog.publicsite.model.ProjectListItemView; +import dev.caskeleton.application.techlog.publicsite.model.ProjectRecordPageView; +import dev.caskeleton.application.techlog.publicsite.query.ProjectDecisionPageQuery; +import dev.caskeleton.application.techlog.publicsite.query.ProjectPageQuery; +import dev.caskeleton.application.techlog.publicsite.query.ProjectRecordPageQuery; +import java.util.List; +import java.util.Optional; + +/** 프로젝트 목록·상세와 그 하위 목록. */ +public interface PublicProjectQueryPort { + + List list(); + + Optional findBySlug(String slug); + + /** 프로젝트가 없으면 {@link Optional#empty()} — 빈 페이지와 404 를 호출자가 구분해야 한다. */ + Optional decisions(ProjectDecisionPageQuery query); + + Optional records(ProjectRecordPageQuery query); + + Optional activities(ProjectPageQuery query); +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/port/out/PublicReleaseQueryPort.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/port/out/PublicReleaseQueryPort.java new file mode 100644 index 0000000..e6ba7bb --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/port/out/PublicReleaseQueryPort.java @@ -0,0 +1,14 @@ +package dev.caskeleton.application.techlog.publicsite.port.out; + +import dev.caskeleton.application.techlog.publicsite.model.ReleaseDetailView; +import dev.caskeleton.application.techlog.publicsite.model.ReleaseListItemView; +import java.util.List; +import java.util.Optional; + +/** 릴리스 목록·상세. 공개된 것({@code workflow_status = 'PUBLISHED'})만 본다. */ +public interface PublicReleaseQueryPort { + + List list(); + + Optional findByVersion(String version); +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/port/out/PublicSearchQueryPort.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/port/out/PublicSearchQueryPort.java new file mode 100644 index 0000000..7a4ef5c --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/port/out/PublicSearchQueryPort.java @@ -0,0 +1,11 @@ +package dev.caskeleton.application.techlog.publicsite.port.out; + +import dev.caskeleton.application.techlog.publicsite.model.SearchResultPageView; +import dev.caskeleton.application.techlog.publicsite.query.SearchQuery; + +/** 공개 검색. {@code public_resource_projection.search_text} 를 본다. */ +@FunctionalInterface +public interface PublicSearchQueryPort { + + SearchResultPageView search(SearchQuery query); +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/port/out/PublicSiteQueryPort.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/port/out/PublicSiteQueryPort.java new file mode 100644 index 0000000..a470e5d --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/port/out/PublicSiteQueryPort.java @@ -0,0 +1,22 @@ +package dev.caskeleton.application.techlog.publicsite.port.out; + +import dev.caskeleton.application.techlog.publicsite.model.HomeView; +import dev.caskeleton.application.techlog.publicsite.model.ProfileView; +import dev.caskeleton.application.techlog.publicsite.model.SiteView; +import java.util.Optional; + +/** + * 사이트 정체성 · 홈 · 프로필. + * + *

    이 셋의 원천은 단일 행 테이블({@code site_config} / {@code home_focus_config} / {@code profile_page})이고 편집 + * API 는 {@code studio-management-v1}(범위 밖)이 소유한다. 지금은 V9 의 시딩이 유일한 공급원이며, 프로필은 공개로 전환되기 전까지 비어 있을 수 + * 있어 {@link Optional} 이다. + */ +public interface PublicSiteQueryPort { + + Optional site(); + + HomeView home(int latestEntryLimit); + + Optional profile(); +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/port/out/PublicTopicQueryPort.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/port/out/PublicTopicQueryPort.java new file mode 100644 index 0000000..2db73f2 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/port/out/PublicTopicQueryPort.java @@ -0,0 +1,14 @@ +package dev.caskeleton.application.techlog.publicsite.port.out; + +import dev.caskeleton.application.techlog.publicsite.model.TopicDetailView; +import dev.caskeleton.application.techlog.publicsite.model.TopicListItemView; +import java.util.List; +import java.util.Optional; + +/** 주제 목록·상세. */ +public interface PublicTopicQueryPort { + + List list(); + + Optional findBySlug(String slug); +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/query/EmptyQuery.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/query/EmptyQuery.java new file mode 100644 index 0000000..c7b5c1a --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/query/EmptyQuery.java @@ -0,0 +1,6 @@ +package dev.caskeleton.application.techlog.publicsite.query; + +import dev.caskeleton.application.query.Query; + +/** 파라미터가 없는 조회({@code getPublicSite} / {@code getPublicHome} / 목록 전체 등). */ +public record EmptyQuery() implements Query {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/query/ExploreKnowledgeQuery.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/query/ExploreKnowledgeQuery.java new file mode 100644 index 0000000..6c373b7 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/query/ExploreKnowledgeQuery.java @@ -0,0 +1,19 @@ +package dev.caskeleton.application.techlog.publicsite.query; + +import dev.caskeleton.application.query.Query; + +/** + * {@code exploreKnowledge} 의 입력. + * + * @param type CASE / REFERENCE. null 이면 둘 다 + * @param sort PUBLISHED_DESC / UPDATED_DESC / VERIFIED_DESC + */ +public record ExploreKnowledgeQuery( + String type, + String topicSlug, + String projectSlug, + String tagSlug, + Integer year, + String sort, + PublicPageRequest page) + implements Query {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/query/ExploreQuestionsQuery.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/query/ExploreQuestionsQuery.java new file mode 100644 index 0000000..ea9fe5a --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/query/ExploreQuestionsQuery.java @@ -0,0 +1,18 @@ +package dev.caskeleton.application.techlog.publicsite.query; + +import dev.caskeleton.application.query.Query; + +/** + * {@code exploreQuestions} 의 입력. + * + * @param status OPEN / INVESTIGATING / PAUSED / RESOLVED. null 이면 전부 + * @param sort UPDATED_DESC / OPENED_DESC / RESOLVED_DESC + */ +public record ExploreQuestionsQuery( + String status, + String topicSlug, + String projectSlug, + String tagSlug, + String sort, + PublicPageRequest page) + implements Query {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/query/ProjectDecisionPageQuery.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/query/ProjectDecisionPageQuery.java new file mode 100644 index 0000000..46ecc6e --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/query/ProjectDecisionPageQuery.java @@ -0,0 +1,14 @@ +package dev.caskeleton.application.techlog.publicsite.query; + +import dev.caskeleton.application.query.Query; + +/** + * {@code listPublicProjectDecisions} 의 입력. + * + *

    계약이 이 operation 에만 주는 {@code status} 필터 때문에 {@link ProjectPageQuery} 와 나눈다 — 하나의 record 에 세 + * operation 의 필터를 다 담으면 어느 필드가 어느 operation 에서 무시되는지 타입으로 알 수 없다. + * + * @param status 계약이 enum 을 두지 않은 자유 문자열이다 — 도메인의 decision lifecycle 값이 그대로 들어온다 + */ +public record ProjectDecisionPageQuery(String projectSlug, String status, PublicPageRequest page) + implements Query {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/query/ProjectPageQuery.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/query/ProjectPageQuery.java new file mode 100644 index 0000000..0bebb81 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/query/ProjectPageQuery.java @@ -0,0 +1,6 @@ +package dev.caskeleton.application.techlog.publicsite.query; + +import dev.caskeleton.application.query.Query; + +/** {@code listPublicProjectActivities} 의 입력. 계약이 이 operation 에는 필터를 두지 않았다. */ +public record ProjectPageQuery(String projectSlug, PublicPageRequest page) implements Query {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/query/ProjectRecordPageQuery.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/query/ProjectRecordPageQuery.java new file mode 100644 index 0000000..b7913ef --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/query/ProjectRecordPageQuery.java @@ -0,0 +1,12 @@ +package dev.caskeleton.application.techlog.publicsite.query; + +import dev.caskeleton.application.query.Query; + +/** + * {@code listPublicProjectRecords} 의 입력. + * + * @param type CASE / REFERENCE / QUESTION. null 이면 셋 다 + * @param relation PRIMARY / RELATED. null 이면 둘 다 + */ +public record ProjectRecordPageQuery( + String projectSlug, String type, String relation, PublicPageRequest page) implements Query {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/query/PublicPageRequest.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/query/PublicPageRequest.java new file mode 100644 index 0000000..56d644a --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/query/PublicPageRequest.java @@ -0,0 +1,28 @@ +package dev.caskeleton.application.techlog.publicsite.query; + +import dev.caskeleton.application.techlog.publicsite.error.PublicError; +import dev.caskeleton.application.techlog.publicsite.error.PublicException; + +/** + * 계약이 모든 목록에 쓰는 {@code page}/{@code size}. 1-based 다. + * + *

    검증을 한 곳에 모은 이유는 여섯 개 목록 operation 이 같은 규칙을 쓰기 때문이다 — 각자 검사하면 어느 하나가 한계를 빠뜨려도 드러나지 않는다. + */ +public record PublicPageRequest(int page, int size) { + + private static final int MAX_SIZE = 100; + + public PublicPageRequest { + if (page < 1) { + throw PublicException.of(PublicError.PUBLIC_REQUEST_INVALID, "page must be at least 1"); + } + if (size < 1 || size > MAX_SIZE) { + throw PublicException.of( + PublicError.PUBLIC_REQUEST_INVALID, "size must be between 1 and " + MAX_SIZE); + } + } + + public int offset() { + return (page - 1) * size; + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/query/SearchQuery.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/query/SearchQuery.java new file mode 100644 index 0000000..c34fec4 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/query/SearchQuery.java @@ -0,0 +1,7 @@ +package dev.caskeleton.application.techlog.publicsite.query; + +import dev.caskeleton.application.query.Query; + +/** {@code searchPublicResources} 의 입력. */ +public record SearchQuery(String query, String type, String topicSlug, PublicPageRequest page) + implements Query {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/query/SlugQuery.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/query/SlugQuery.java new file mode 100644 index 0000000..2a14b33 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/query/SlugQuery.java @@ -0,0 +1,6 @@ +package dev.caskeleton.application.techlog.publicsite.query; + +import dev.caskeleton.application.query.Query; + +/** slug 하나로 조회하는 operation 들의 공통 입력. */ +public record SlugQuery(String slug) implements Query {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/ExploreKnowledgeUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/ExploreKnowledgeUseCase.java new file mode 100644 index 0000000..4ab4487 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/ExploreKnowledgeUseCase.java @@ -0,0 +1,38 @@ +package dev.caskeleton.application.techlog.publicsite.service; + +import dev.caskeleton.application.capability.Idempotency; +import dev.caskeleton.application.capability.RepositoryAccess; +import dev.caskeleton.application.capability.UseCaseCapability; +import dev.caskeleton.application.techlog.publicsite.model.KnowledgePageView; +import dev.caskeleton.application.techlog.publicsite.port.out.PublicExploreQueryPort; +import dev.caskeleton.application.techlog.publicsite.query.ExploreKnowledgeQuery; +import dev.caskeleton.application.transaction.TransactionMode; +import dev.caskeleton.application.transaction.TransactionPort; +import dev.caskeleton.application.usecase.QueryUseCase; +import java.util.Objects; + +/** + * 계약 {@code exploreKnowledge}. 공개 조회이며 인증이 없다. + * + *

    공개된 것만 보여준다 — 어떤 경로로도 Working Copy 나 검증/미리보기 artifact 에 닿지 않는다 (계약 서문 "이 계약이 반환하지 않는 것"). + */ +@UseCaseCapability( + transactionMode = TransactionMode.READ_ONLY, + idempotency = Idempotency.IDEMPOTENT, + repositoryAccess = RepositoryAccess.READ_REPOSITORY) +public final class ExploreKnowledgeUseCase + implements QueryUseCase { + + private final PublicExploreQueryPort port; + private final TransactionPort transactions; + + public ExploreKnowledgeUseCase(PublicExploreQueryPort port, TransactionPort transactions) { + this.port = Objects.requireNonNull(port, "port"); + this.transactions = Objects.requireNonNull(transactions, "transactions"); + } + + @Override + public KnowledgePageView handle(ExploreKnowledgeQuery input) { + return transactions.inRead(() -> port.knowledge(input)); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/ExploreQuestionsUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/ExploreQuestionsUseCase.java new file mode 100644 index 0000000..0418ea8 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/ExploreQuestionsUseCase.java @@ -0,0 +1,38 @@ +package dev.caskeleton.application.techlog.publicsite.service; + +import dev.caskeleton.application.capability.Idempotency; +import dev.caskeleton.application.capability.RepositoryAccess; +import dev.caskeleton.application.capability.UseCaseCapability; +import dev.caskeleton.application.techlog.publicsite.model.QuestionPageView; +import dev.caskeleton.application.techlog.publicsite.port.out.PublicExploreQueryPort; +import dev.caskeleton.application.techlog.publicsite.query.ExploreQuestionsQuery; +import dev.caskeleton.application.transaction.TransactionMode; +import dev.caskeleton.application.transaction.TransactionPort; +import dev.caskeleton.application.usecase.QueryUseCase; +import java.util.Objects; + +/** + * 계약 {@code exploreQuestions}. 공개 조회이며 인증이 없다. + * + *

    공개된 것만 보여준다 — 어떤 경로로도 Working Copy 나 검증/미리보기 artifact 에 닿지 않는다 (계약 서문 "이 계약이 반환하지 않는 것"). + */ +@UseCaseCapability( + transactionMode = TransactionMode.READ_ONLY, + idempotency = Idempotency.IDEMPOTENT, + repositoryAccess = RepositoryAccess.READ_REPOSITORY) +public final class ExploreQuestionsUseCase + implements QueryUseCase { + + private final PublicExploreQueryPort port; + private final TransactionPort transactions; + + public ExploreQuestionsUseCase(PublicExploreQueryPort port, TransactionPort transactions) { + this.port = Objects.requireNonNull(port, "port"); + this.transactions = Objects.requireNonNull(transactions, "transactions"); + } + + @Override + public QuestionPageView handle(ExploreQuestionsQuery input) { + return transactions.inRead(() -> port.questions(input)); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/GetPublicCaseUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/GetPublicCaseUseCase.java new file mode 100644 index 0000000..5deecd1 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/GetPublicCaseUseCase.java @@ -0,0 +1,39 @@ +package dev.caskeleton.application.techlog.publicsite.service; + +import dev.caskeleton.application.capability.Idempotency; +import dev.caskeleton.application.capability.RepositoryAccess; +import dev.caskeleton.application.capability.UseCaseCapability; +import dev.caskeleton.application.techlog.publicsite.model.CaseDetailView; +import dev.caskeleton.application.techlog.publicsite.port.out.PublicDocumentQueryPort; +import dev.caskeleton.application.techlog.publicsite.query.SlugQuery; +import dev.caskeleton.application.transaction.TransactionMode; +import dev.caskeleton.application.transaction.TransactionPort; +import dev.caskeleton.application.usecase.QueryUseCase; +import java.util.Objects; + +/** + * 계약 {@code getPublicCase}. 공개 조회이며 인증이 없다. + * + *

    공개된 것만 보여준다 — 어떤 경로로도 Working Copy 나 검증/미리보기 artifact 에 닿지 않는다 (계약 서문 "이 계약이 반환하지 않는 것"). + */ +@UseCaseCapability( + transactionMode = TransactionMode.READ_ONLY, + idempotency = Idempotency.IDEMPOTENT, + repositoryAccess = RepositoryAccess.READ_REPOSITORY) +public final class GetPublicCaseUseCase implements QueryUseCase { + + private final PublicDocumentQueryPort port; + private final TransactionPort transactions; + + public GetPublicCaseUseCase(PublicDocumentQueryPort port, TransactionPort transactions) { + this.port = Objects.requireNonNull(port, "port"); + this.transactions = Objects.requireNonNull(transactions, "transactions"); + } + + @Override + public CaseDetailView handle(SlugQuery input) { + String slug = PublicReadUseCases.requireSlug(input.slug()); + return transactions.inRead( + () -> port.findCase(slug).orElseThrow(() -> PublicReadUseCases.notFound("case " + slug))); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/GetPublicHomeUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/GetPublicHomeUseCase.java new file mode 100644 index 0000000..79e8fd0 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/GetPublicHomeUseCase.java @@ -0,0 +1,40 @@ +package dev.caskeleton.application.techlog.publicsite.service; + +import dev.caskeleton.application.capability.Idempotency; +import dev.caskeleton.application.capability.RepositoryAccess; +import dev.caskeleton.application.capability.UseCaseCapability; +import dev.caskeleton.application.techlog.publicsite.model.HomeView; +import dev.caskeleton.application.techlog.publicsite.port.out.PublicSiteQueryPort; +import dev.caskeleton.application.techlog.publicsite.query.EmptyQuery; +import dev.caskeleton.application.transaction.TransactionMode; +import dev.caskeleton.application.transaction.TransactionPort; +import dev.caskeleton.application.usecase.QueryUseCase; +import java.util.Objects; + +/** + * 계약 {@code getPublicHome}. 공개 조회이며 인증이 없다. + * + *

    공개된 것만 보여준다 — 어떤 경로로도 Working Copy 나 검증/미리보기 artifact 에 닿지 않는다 (계약 서문 "이 계약이 반환하지 않는 것"). + */ +@UseCaseCapability( + transactionMode = TransactionMode.READ_ONLY, + idempotency = Idempotency.IDEMPOTENT, + repositoryAccess = RepositoryAccess.READ_REPOSITORY) +public final class GetPublicHomeUseCase implements QueryUseCase { + + /** 계약 {@code HomeResponse.latestEntries} 는 최신 목록이며 화면이 한 화면에 담는 개수다. */ + private static final int LATEST_ENTRY_LIMIT = 10; + + private final PublicSiteQueryPort port; + private final TransactionPort transactions; + + public GetPublicHomeUseCase(PublicSiteQueryPort port, TransactionPort transactions) { + this.port = Objects.requireNonNull(port, "port"); + this.transactions = Objects.requireNonNull(transactions, "transactions"); + } + + @Override + public HomeView handle(EmptyQuery input) { + return transactions.inRead(() -> port.home(LATEST_ENTRY_LIMIT)); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/GetPublicProfileUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/GetPublicProfileUseCase.java new file mode 100644 index 0000000..14f994a --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/GetPublicProfileUseCase.java @@ -0,0 +1,38 @@ +package dev.caskeleton.application.techlog.publicsite.service; + +import dev.caskeleton.application.capability.Idempotency; +import dev.caskeleton.application.capability.RepositoryAccess; +import dev.caskeleton.application.capability.UseCaseCapability; +import dev.caskeleton.application.techlog.publicsite.model.ProfileView; +import dev.caskeleton.application.techlog.publicsite.port.out.PublicSiteQueryPort; +import dev.caskeleton.application.techlog.publicsite.query.EmptyQuery; +import dev.caskeleton.application.transaction.TransactionMode; +import dev.caskeleton.application.transaction.TransactionPort; +import dev.caskeleton.application.usecase.QueryUseCase; +import java.util.Objects; + +/** + * 계약 {@code getPublicProfile}. 공개 조회이며 인증이 없다. + * + *

    공개된 것만 보여준다 — 어떤 경로로도 Working Copy 나 검증/미리보기 artifact 에 닿지 않는다 (계약 서문 "이 계약이 반환하지 않는 것"). + */ +@UseCaseCapability( + transactionMode = TransactionMode.READ_ONLY, + idempotency = Idempotency.IDEMPOTENT, + repositoryAccess = RepositoryAccess.READ_REPOSITORY) +public final class GetPublicProfileUseCase implements QueryUseCase { + + private final PublicSiteQueryPort port; + private final TransactionPort transactions; + + public GetPublicProfileUseCase(PublicSiteQueryPort port, TransactionPort transactions) { + this.port = Objects.requireNonNull(port, "port"); + this.transactions = Objects.requireNonNull(transactions, "transactions"); + } + + @Override + public ProfileView handle(EmptyQuery input) { + return transactions.inRead( + () -> port.profile().orElseThrow(() -> PublicReadUseCases.notFound("the profile page"))); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/GetPublicProjectUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/GetPublicProjectUseCase.java new file mode 100644 index 0000000..4e28f0e --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/GetPublicProjectUseCase.java @@ -0,0 +1,41 @@ +package dev.caskeleton.application.techlog.publicsite.service; + +import dev.caskeleton.application.capability.Idempotency; +import dev.caskeleton.application.capability.RepositoryAccess; +import dev.caskeleton.application.capability.UseCaseCapability; +import dev.caskeleton.application.techlog.publicsite.model.ProjectDetailView; +import dev.caskeleton.application.techlog.publicsite.port.out.PublicProjectQueryPort; +import dev.caskeleton.application.techlog.publicsite.query.SlugQuery; +import dev.caskeleton.application.transaction.TransactionMode; +import dev.caskeleton.application.transaction.TransactionPort; +import dev.caskeleton.application.usecase.QueryUseCase; +import java.util.Objects; + +/** + * 계약 {@code getPublicProject}. 공개 조회이며 인증이 없다. + * + *

    공개된 것만 보여준다 — 어떤 경로로도 Working Copy 나 검증/미리보기 artifact 에 닿지 않는다 (계약 서문 "이 계약이 반환하지 않는 것"). + */ +@UseCaseCapability( + transactionMode = TransactionMode.READ_ONLY, + idempotency = Idempotency.IDEMPOTENT, + repositoryAccess = RepositoryAccess.READ_REPOSITORY) +public final class GetPublicProjectUseCase implements QueryUseCase { + + private final PublicProjectQueryPort port; + private final TransactionPort transactions; + + public GetPublicProjectUseCase(PublicProjectQueryPort port, TransactionPort transactions) { + this.port = Objects.requireNonNull(port, "port"); + this.transactions = Objects.requireNonNull(transactions, "transactions"); + } + + @Override + public ProjectDetailView handle(SlugQuery input) { + String slug = PublicReadUseCases.requireSlug(input.slug()); + return transactions.inRead( + () -> + port.findBySlug(slug) + .orElseThrow(() -> PublicReadUseCases.notFound("project " + slug))); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/GetPublicQuestionUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/GetPublicQuestionUseCase.java new file mode 100644 index 0000000..4798885 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/GetPublicQuestionUseCase.java @@ -0,0 +1,41 @@ +package dev.caskeleton.application.techlog.publicsite.service; + +import dev.caskeleton.application.capability.Idempotency; +import dev.caskeleton.application.capability.RepositoryAccess; +import dev.caskeleton.application.capability.UseCaseCapability; +import dev.caskeleton.application.techlog.publicsite.model.QuestionDetailView; +import dev.caskeleton.application.techlog.publicsite.port.out.PublicDocumentQueryPort; +import dev.caskeleton.application.techlog.publicsite.query.SlugQuery; +import dev.caskeleton.application.transaction.TransactionMode; +import dev.caskeleton.application.transaction.TransactionPort; +import dev.caskeleton.application.usecase.QueryUseCase; +import java.util.Objects; + +/** + * 계약 {@code getPublicQuestion}. 공개 조회이며 인증이 없다. + * + *

    공개된 것만 보여준다 — 어떤 경로로도 Working Copy 나 검증/미리보기 artifact 에 닿지 않는다 (계약 서문 "이 계약이 반환하지 않는 것"). + */ +@UseCaseCapability( + transactionMode = TransactionMode.READ_ONLY, + idempotency = Idempotency.IDEMPOTENT, + repositoryAccess = RepositoryAccess.READ_REPOSITORY) +public final class GetPublicQuestionUseCase implements QueryUseCase { + + private final PublicDocumentQueryPort port; + private final TransactionPort transactions; + + public GetPublicQuestionUseCase(PublicDocumentQueryPort port, TransactionPort transactions) { + this.port = Objects.requireNonNull(port, "port"); + this.transactions = Objects.requireNonNull(transactions, "transactions"); + } + + @Override + public QuestionDetailView handle(SlugQuery input) { + String slug = PublicReadUseCases.requireSlug(input.slug()); + return transactions.inRead( + () -> + port.findQuestion(slug) + .orElseThrow(() -> PublicReadUseCases.notFound("question " + slug))); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/GetPublicReferenceUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/GetPublicReferenceUseCase.java new file mode 100644 index 0000000..622bdee --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/GetPublicReferenceUseCase.java @@ -0,0 +1,42 @@ +package dev.caskeleton.application.techlog.publicsite.service; + +import dev.caskeleton.application.capability.Idempotency; +import dev.caskeleton.application.capability.RepositoryAccess; +import dev.caskeleton.application.capability.UseCaseCapability; +import dev.caskeleton.application.techlog.publicsite.model.ReferenceDetailView; +import dev.caskeleton.application.techlog.publicsite.port.out.PublicDocumentQueryPort; +import dev.caskeleton.application.techlog.publicsite.query.SlugQuery; +import dev.caskeleton.application.transaction.TransactionMode; +import dev.caskeleton.application.transaction.TransactionPort; +import dev.caskeleton.application.usecase.QueryUseCase; +import java.util.Objects; + +/** + * 계약 {@code getPublicReference}. 공개 조회이며 인증이 없다. + * + *

    공개된 것만 보여준다 — 어떤 경로로도 Working Copy 나 검증/미리보기 artifact 에 닿지 않는다 (계약 서문 "이 계약이 반환하지 않는 것"). + */ +@UseCaseCapability( + transactionMode = TransactionMode.READ_ONLY, + idempotency = Idempotency.IDEMPOTENT, + repositoryAccess = RepositoryAccess.READ_REPOSITORY) +public final class GetPublicReferenceUseCase + implements QueryUseCase { + + private final PublicDocumentQueryPort port; + private final TransactionPort transactions; + + public GetPublicReferenceUseCase(PublicDocumentQueryPort port, TransactionPort transactions) { + this.port = Objects.requireNonNull(port, "port"); + this.transactions = Objects.requireNonNull(transactions, "transactions"); + } + + @Override + public ReferenceDetailView handle(SlugQuery input) { + String slug = PublicReadUseCases.requireSlug(input.slug()); + return transactions.inRead( + () -> + port.findReference(slug) + .orElseThrow(() -> PublicReadUseCases.notFound("reference " + slug))); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/GetPublicReleaseUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/GetPublicReleaseUseCase.java new file mode 100644 index 0000000..e68b30f --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/GetPublicReleaseUseCase.java @@ -0,0 +1,41 @@ +package dev.caskeleton.application.techlog.publicsite.service; + +import dev.caskeleton.application.capability.Idempotency; +import dev.caskeleton.application.capability.RepositoryAccess; +import dev.caskeleton.application.capability.UseCaseCapability; +import dev.caskeleton.application.techlog.publicsite.model.ReleaseDetailView; +import dev.caskeleton.application.techlog.publicsite.port.out.PublicReleaseQueryPort; +import dev.caskeleton.application.techlog.publicsite.query.SlugQuery; +import dev.caskeleton.application.transaction.TransactionMode; +import dev.caskeleton.application.transaction.TransactionPort; +import dev.caskeleton.application.usecase.QueryUseCase; +import java.util.Objects; + +/** + * 계약 {@code getPublicRelease}. 공개 조회이며 인증이 없다. + * + *

    공개된 것만 보여준다 — 어떤 경로로도 Working Copy 나 검증/미리보기 artifact 에 닿지 않는다 (계약 서문 "이 계약이 반환하지 않는 것"). + */ +@UseCaseCapability( + transactionMode = TransactionMode.READ_ONLY, + idempotency = Idempotency.IDEMPOTENT, + repositoryAccess = RepositoryAccess.READ_REPOSITORY) +public final class GetPublicReleaseUseCase implements QueryUseCase { + + private final PublicReleaseQueryPort port; + private final TransactionPort transactions; + + public GetPublicReleaseUseCase(PublicReleaseQueryPort port, TransactionPort transactions) { + this.port = Objects.requireNonNull(port, "port"); + this.transactions = Objects.requireNonNull(transactions, "transactions"); + } + + @Override + public ReleaseDetailView handle(SlugQuery input) { + String version = PublicReadUseCases.requireSlug(input.slug()); + return transactions.inRead( + () -> + port.findByVersion(version) + .orElseThrow(() -> PublicReadUseCases.notFound("release " + version))); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/GetPublicSiteUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/GetPublicSiteUseCase.java new file mode 100644 index 0000000..01a76dd --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/GetPublicSiteUseCase.java @@ -0,0 +1,38 @@ +package dev.caskeleton.application.techlog.publicsite.service; + +import dev.caskeleton.application.capability.Idempotency; +import dev.caskeleton.application.capability.RepositoryAccess; +import dev.caskeleton.application.capability.UseCaseCapability; +import dev.caskeleton.application.techlog.publicsite.model.SiteView; +import dev.caskeleton.application.techlog.publicsite.port.out.PublicSiteQueryPort; +import dev.caskeleton.application.techlog.publicsite.query.EmptyQuery; +import dev.caskeleton.application.transaction.TransactionMode; +import dev.caskeleton.application.transaction.TransactionPort; +import dev.caskeleton.application.usecase.QueryUseCase; +import java.util.Objects; + +/** + * 계약 {@code getPublicSite}. 공개 조회이며 인증이 없다. + * + *

    공개된 것만 보여준다 — 어떤 경로로도 Working Copy 나 검증/미리보기 artifact 에 닿지 않는다 (계약 서문 "이 계약이 반환하지 않는 것"). + */ +@UseCaseCapability( + transactionMode = TransactionMode.READ_ONLY, + idempotency = Idempotency.IDEMPOTENT, + repositoryAccess = RepositoryAccess.READ_REPOSITORY) +public final class GetPublicSiteUseCase implements QueryUseCase { + + private final PublicSiteQueryPort port; + private final TransactionPort transactions; + + public GetPublicSiteUseCase(PublicSiteQueryPort port, TransactionPort transactions) { + this.port = Objects.requireNonNull(port, "port"); + this.transactions = Objects.requireNonNull(transactions, "transactions"); + } + + @Override + public SiteView handle(EmptyQuery input) { + return transactions.inRead( + () -> port.site().orElseThrow(() -> PublicReadUseCases.notFound("the site profile"))); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/GetPublicTopicUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/GetPublicTopicUseCase.java new file mode 100644 index 0000000..800470e --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/GetPublicTopicUseCase.java @@ -0,0 +1,40 @@ +package dev.caskeleton.application.techlog.publicsite.service; + +import dev.caskeleton.application.capability.Idempotency; +import dev.caskeleton.application.capability.RepositoryAccess; +import dev.caskeleton.application.capability.UseCaseCapability; +import dev.caskeleton.application.techlog.publicsite.model.TopicDetailView; +import dev.caskeleton.application.techlog.publicsite.port.out.PublicTopicQueryPort; +import dev.caskeleton.application.techlog.publicsite.query.SlugQuery; +import dev.caskeleton.application.transaction.TransactionMode; +import dev.caskeleton.application.transaction.TransactionPort; +import dev.caskeleton.application.usecase.QueryUseCase; +import java.util.Objects; + +/** + * 계약 {@code getPublicTopic}. 공개 조회이며 인증이 없다. + * + *

    공개된 것만 보여준다 — 어떤 경로로도 Working Copy 나 검증/미리보기 artifact 에 닿지 않는다 (계약 서문 "이 계약이 반환하지 않는 것"). + */ +@UseCaseCapability( + transactionMode = TransactionMode.READ_ONLY, + idempotency = Idempotency.IDEMPOTENT, + repositoryAccess = RepositoryAccess.READ_REPOSITORY) +public final class GetPublicTopicUseCase implements QueryUseCase { + + private final PublicTopicQueryPort port; + private final TransactionPort transactions; + + public GetPublicTopicUseCase(PublicTopicQueryPort port, TransactionPort transactions) { + this.port = Objects.requireNonNull(port, "port"); + this.transactions = Objects.requireNonNull(transactions, "transactions"); + } + + @Override + public TopicDetailView handle(SlugQuery input) { + String slug = PublicReadUseCases.requireSlug(input.slug()); + return transactions.inRead( + () -> + port.findBySlug(slug).orElseThrow(() -> PublicReadUseCases.notFound("topic " + slug))); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/ListPublicProjectActivitiesUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/ListPublicProjectActivitiesUseCase.java new file mode 100644 index 0000000..0ad3fdf --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/ListPublicProjectActivitiesUseCase.java @@ -0,0 +1,43 @@ +package dev.caskeleton.application.techlog.publicsite.service; + +import dev.caskeleton.application.capability.Idempotency; +import dev.caskeleton.application.capability.RepositoryAccess; +import dev.caskeleton.application.capability.UseCaseCapability; +import dev.caskeleton.application.techlog.publicsite.model.ProjectActivityPageView; +import dev.caskeleton.application.techlog.publicsite.port.out.PublicProjectQueryPort; +import dev.caskeleton.application.techlog.publicsite.query.ProjectPageQuery; +import dev.caskeleton.application.transaction.TransactionMode; +import dev.caskeleton.application.transaction.TransactionPort; +import dev.caskeleton.application.usecase.QueryUseCase; +import java.util.Objects; + +/** + * 계약 {@code listPublicProjectActivities}. 공개 조회이며 인증이 없다. + * + *

    공개된 것만 보여준다 — 어떤 경로로도 Working Copy 나 검증/미리보기 artifact 에 닿지 않는다 (계약 서문 "이 계약이 반환하지 않는 것"). + */ +@UseCaseCapability( + transactionMode = TransactionMode.READ_ONLY, + idempotency = Idempotency.IDEMPOTENT, + repositoryAccess = RepositoryAccess.READ_REPOSITORY) +public final class ListPublicProjectActivitiesUseCase + implements QueryUseCase { + + private final PublicProjectQueryPort port; + private final TransactionPort transactions; + + public ListPublicProjectActivitiesUseCase( + PublicProjectQueryPort port, TransactionPort transactions) { + this.port = Objects.requireNonNull(port, "port"); + this.transactions = Objects.requireNonNull(transactions, "transactions"); + } + + @Override + public ProjectActivityPageView handle(ProjectPageQuery input) { + String slug = PublicReadUseCases.requireSlug(input.projectSlug()); + return transactions.inRead( + () -> + port.activities(input) + .orElseThrow(() -> PublicReadUseCases.notFound("project " + slug))); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/ListPublicProjectDecisionsUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/ListPublicProjectDecisionsUseCase.java new file mode 100644 index 0000000..7ae20cd --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/ListPublicProjectDecisionsUseCase.java @@ -0,0 +1,43 @@ +package dev.caskeleton.application.techlog.publicsite.service; + +import dev.caskeleton.application.capability.Idempotency; +import dev.caskeleton.application.capability.RepositoryAccess; +import dev.caskeleton.application.capability.UseCaseCapability; +import dev.caskeleton.application.techlog.publicsite.model.ProjectDecisionPageView; +import dev.caskeleton.application.techlog.publicsite.port.out.PublicProjectQueryPort; +import dev.caskeleton.application.techlog.publicsite.query.ProjectDecisionPageQuery; +import dev.caskeleton.application.transaction.TransactionMode; +import dev.caskeleton.application.transaction.TransactionPort; +import dev.caskeleton.application.usecase.QueryUseCase; +import java.util.Objects; + +/** + * 계약 {@code listPublicProjectDecisions}. 공개 조회이며 인증이 없다. + * + *

    공개된 것만 보여준다 — 어떤 경로로도 Working Copy 나 검증/미리보기 artifact 에 닿지 않는다 (계약 서문 "이 계약이 반환하지 않는 것"). + */ +@UseCaseCapability( + transactionMode = TransactionMode.READ_ONLY, + idempotency = Idempotency.IDEMPOTENT, + repositoryAccess = RepositoryAccess.READ_REPOSITORY) +public final class ListPublicProjectDecisionsUseCase + implements QueryUseCase { + + private final PublicProjectQueryPort port; + private final TransactionPort transactions; + + public ListPublicProjectDecisionsUseCase( + PublicProjectQueryPort port, TransactionPort transactions) { + this.port = Objects.requireNonNull(port, "port"); + this.transactions = Objects.requireNonNull(transactions, "transactions"); + } + + @Override + public ProjectDecisionPageView handle(ProjectDecisionPageQuery input) { + String slug = PublicReadUseCases.requireSlug(input.projectSlug()); + return transactions.inRead( + () -> + port.decisions(input) + .orElseThrow(() -> PublicReadUseCases.notFound("project " + slug))); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/ListPublicProjectRecordsUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/ListPublicProjectRecordsUseCase.java new file mode 100644 index 0000000..a4fc929 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/ListPublicProjectRecordsUseCase.java @@ -0,0 +1,42 @@ +package dev.caskeleton.application.techlog.publicsite.service; + +import dev.caskeleton.application.capability.Idempotency; +import dev.caskeleton.application.capability.RepositoryAccess; +import dev.caskeleton.application.capability.UseCaseCapability; +import dev.caskeleton.application.techlog.publicsite.model.ProjectRecordPageView; +import dev.caskeleton.application.techlog.publicsite.port.out.PublicProjectQueryPort; +import dev.caskeleton.application.techlog.publicsite.query.ProjectRecordPageQuery; +import dev.caskeleton.application.transaction.TransactionMode; +import dev.caskeleton.application.transaction.TransactionPort; +import dev.caskeleton.application.usecase.QueryUseCase; +import java.util.Objects; + +/** + * 계약 {@code listPublicProjectRecords}. 공개 조회이며 인증이 없다. + * + *

    공개된 것만 보여준다 — 어떤 경로로도 Working Copy 나 검증/미리보기 artifact 에 닿지 않는다 (계약 서문 "이 계약이 반환하지 않는 것"). + */ +@UseCaseCapability( + transactionMode = TransactionMode.READ_ONLY, + idempotency = Idempotency.IDEMPOTENT, + repositoryAccess = RepositoryAccess.READ_REPOSITORY) +public final class ListPublicProjectRecordsUseCase + implements QueryUseCase { + + private final PublicProjectQueryPort port; + private final TransactionPort transactions; + + public ListPublicProjectRecordsUseCase( + PublicProjectQueryPort port, TransactionPort transactions) { + this.port = Objects.requireNonNull(port, "port"); + this.transactions = Objects.requireNonNull(transactions, "transactions"); + } + + @Override + public ProjectRecordPageView handle(ProjectRecordPageQuery input) { + String slug = PublicReadUseCases.requireSlug(input.projectSlug()); + return transactions.inRead( + () -> + port.records(input).orElseThrow(() -> PublicReadUseCases.notFound("project " + slug))); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/ListPublicProjectsUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/ListPublicProjectsUseCase.java new file mode 100644 index 0000000..93a1bc7 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/ListPublicProjectsUseCase.java @@ -0,0 +1,39 @@ +package dev.caskeleton.application.techlog.publicsite.service; + +import dev.caskeleton.application.capability.Idempotency; +import dev.caskeleton.application.capability.RepositoryAccess; +import dev.caskeleton.application.capability.UseCaseCapability; +import dev.caskeleton.application.techlog.publicsite.model.ProjectListItemView; +import dev.caskeleton.application.techlog.publicsite.port.out.PublicProjectQueryPort; +import dev.caskeleton.application.techlog.publicsite.query.EmptyQuery; +import dev.caskeleton.application.transaction.TransactionMode; +import dev.caskeleton.application.transaction.TransactionPort; +import dev.caskeleton.application.usecase.QueryUseCase; +import java.util.List; +import java.util.Objects; + +/** + * 계약 {@code listPublicProjects}. 공개 조회이며 인증이 없다. + * + *

    공개된 것만 보여준다 — 어떤 경로로도 Working Copy 나 검증/미리보기 artifact 에 닿지 않는다 (계약 서문 "이 계약이 반환하지 않는 것"). + */ +@UseCaseCapability( + transactionMode = TransactionMode.READ_ONLY, + idempotency = Idempotency.IDEMPOTENT, + repositoryAccess = RepositoryAccess.READ_REPOSITORY) +public final class ListPublicProjectsUseCase + implements QueryUseCase> { + + private final PublicProjectQueryPort port; + private final TransactionPort transactions; + + public ListPublicProjectsUseCase(PublicProjectQueryPort port, TransactionPort transactions) { + this.port = Objects.requireNonNull(port, "port"); + this.transactions = Objects.requireNonNull(transactions, "transactions"); + } + + @Override + public List handle(EmptyQuery input) { + return transactions.inRead(port::list); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/ListPublicReleasesUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/ListPublicReleasesUseCase.java new file mode 100644 index 0000000..cb1540b --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/ListPublicReleasesUseCase.java @@ -0,0 +1,39 @@ +package dev.caskeleton.application.techlog.publicsite.service; + +import dev.caskeleton.application.capability.Idempotency; +import dev.caskeleton.application.capability.RepositoryAccess; +import dev.caskeleton.application.capability.UseCaseCapability; +import dev.caskeleton.application.techlog.publicsite.model.ReleaseListItemView; +import dev.caskeleton.application.techlog.publicsite.port.out.PublicReleaseQueryPort; +import dev.caskeleton.application.techlog.publicsite.query.EmptyQuery; +import dev.caskeleton.application.transaction.TransactionMode; +import dev.caskeleton.application.transaction.TransactionPort; +import dev.caskeleton.application.usecase.QueryUseCase; +import java.util.List; +import java.util.Objects; + +/** + * 계약 {@code listPublicReleases}. 공개 조회이며 인증이 없다. + * + *

    공개된 것만 보여준다 — 어떤 경로로도 Working Copy 나 검증/미리보기 artifact 에 닿지 않는다 (계약 서문 "이 계약이 반환하지 않는 것"). + */ +@UseCaseCapability( + transactionMode = TransactionMode.READ_ONLY, + idempotency = Idempotency.IDEMPOTENT, + repositoryAccess = RepositoryAccess.READ_REPOSITORY) +public final class ListPublicReleasesUseCase + implements QueryUseCase> { + + private final PublicReleaseQueryPort port; + private final TransactionPort transactions; + + public ListPublicReleasesUseCase(PublicReleaseQueryPort port, TransactionPort transactions) { + this.port = Objects.requireNonNull(port, "port"); + this.transactions = Objects.requireNonNull(transactions, "transactions"); + } + + @Override + public List handle(EmptyQuery input) { + return transactions.inRead(port::list); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/ListPublicTopicsUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/ListPublicTopicsUseCase.java new file mode 100644 index 0000000..86ecb97 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/ListPublicTopicsUseCase.java @@ -0,0 +1,39 @@ +package dev.caskeleton.application.techlog.publicsite.service; + +import dev.caskeleton.application.capability.Idempotency; +import dev.caskeleton.application.capability.RepositoryAccess; +import dev.caskeleton.application.capability.UseCaseCapability; +import dev.caskeleton.application.techlog.publicsite.model.TopicListItemView; +import dev.caskeleton.application.techlog.publicsite.port.out.PublicTopicQueryPort; +import dev.caskeleton.application.techlog.publicsite.query.EmptyQuery; +import dev.caskeleton.application.transaction.TransactionMode; +import dev.caskeleton.application.transaction.TransactionPort; +import dev.caskeleton.application.usecase.QueryUseCase; +import java.util.List; +import java.util.Objects; + +/** + * 계약 {@code listPublicTopics}. 공개 조회이며 인증이 없다. + * + *

    공개된 것만 보여준다 — 어떤 경로로도 Working Copy 나 검증/미리보기 artifact 에 닿지 않는다 (계약 서문 "이 계약이 반환하지 않는 것"). + */ +@UseCaseCapability( + transactionMode = TransactionMode.READ_ONLY, + idempotency = Idempotency.IDEMPOTENT, + repositoryAccess = RepositoryAccess.READ_REPOSITORY) +public final class ListPublicTopicsUseCase + implements QueryUseCase> { + + private final PublicTopicQueryPort port; + private final TransactionPort transactions; + + public ListPublicTopicsUseCase(PublicTopicQueryPort port, TransactionPort transactions) { + this.port = Objects.requireNonNull(port, "port"); + this.transactions = Objects.requireNonNull(transactions, "transactions"); + } + + @Override + public List handle(EmptyQuery input) { + return transactions.inRead(port::list); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/PublicReadUseCases.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/PublicReadUseCases.java new file mode 100644 index 0000000..c46c96e --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/PublicReadUseCases.java @@ -0,0 +1,25 @@ +package dev.caskeleton.application.techlog.publicsite.service; + +import dev.caskeleton.application.techlog.publicsite.error.PublicError; +import dev.caskeleton.application.techlog.publicsite.error.PublicException; + +/** + * 공개 조회 use case 들이 공유하는 조각. + * + *

    18개 operation 이 전부 읽기 전용이고 도메인 규칙이 없다. 각자 "없으면 404" 를 따로 쓰면 문구와 코드가 갈라지므로 한 곳에 둔다. + */ +final class PublicReadUseCases { + + private PublicReadUseCases() {} + + static PublicException notFound(String what) { + return PublicException.of(PublicError.PUBLIC_RESOURCE_NOT_FOUND, what + " is not published"); + } + + static String requireSlug(String slug) { + if (slug == null || slug.isBlank()) { + throw PublicException.of(PublicError.PUBLIC_REQUEST_INVALID, "a slug is required"); + } + return slug; + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/SearchPublicResourcesUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/SearchPublicResourcesUseCase.java new file mode 100644 index 0000000..cd5ffd3 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/SearchPublicResourcesUseCase.java @@ -0,0 +1,38 @@ +package dev.caskeleton.application.techlog.publicsite.service; + +import dev.caskeleton.application.capability.Idempotency; +import dev.caskeleton.application.capability.RepositoryAccess; +import dev.caskeleton.application.capability.UseCaseCapability; +import dev.caskeleton.application.techlog.publicsite.model.SearchResultPageView; +import dev.caskeleton.application.techlog.publicsite.port.out.PublicSearchQueryPort; +import dev.caskeleton.application.techlog.publicsite.query.SearchQuery; +import dev.caskeleton.application.transaction.TransactionMode; +import dev.caskeleton.application.transaction.TransactionPort; +import dev.caskeleton.application.usecase.QueryUseCase; +import java.util.Objects; + +/** + * 계약 {@code searchPublicResources}. 공개 조회이며 인증이 없다. + * + *

    공개된 것만 보여준다 — 어떤 경로로도 Working Copy 나 검증/미리보기 artifact 에 닿지 않는다 (계약 서문 "이 계약이 반환하지 않는 것"). + */ +@UseCaseCapability( + transactionMode = TransactionMode.READ_ONLY, + idempotency = Idempotency.IDEMPOTENT, + repositoryAccess = RepositoryAccess.READ_REPOSITORY) +public final class SearchPublicResourcesUseCase + implements QueryUseCase { + + private final PublicSearchQueryPort port; + private final TransactionPort transactions; + + public SearchPublicResourcesUseCase(PublicSearchQueryPort port, TransactionPort transactions) { + this.port = Objects.requireNonNull(port, "port"); + this.transactions = Objects.requireNonNull(transactions, "transactions"); + } + + @Override + public SearchResultPageView handle(SearchQuery input) { + return transactions.inRead(() -> port.search(input)); + } +} diff --git a/src/config/openapi/MANIFEST.sha256 b/src/config/openapi/MANIFEST.sha256 index 9315d6e..f7c25bb 100644 --- a/src/config/openapi/MANIFEST.sha256 +++ b/src/config/openapi/MANIFEST.sha256 @@ -1,2 +1,4 @@ # source: tech-log-design-package contracts/openapi/studio-v1.yaml @ b20d7a2 (feature/response-envelope-adr-006) 6cae9924403d0761f401643a022980b8e04183eea0d890c143c9fbbbbc7431e4 studio-v1.yaml +# source: tech-log-design-package contracts/openapi/public-v1.yaml @ 55a9599 (feature/public-v1-response-envelope) +8ac71425b38658f34641102b4c2e6e21288c811efebdb92c0a46fb9d4790e23e public-v1.yaml diff --git a/src/config/openapi/public-v1.yaml b/src/config/openapi/public-v1.yaml new file mode 100644 index 0000000..9e19c10 --- /dev/null +++ b/src/config/openapi/public-v1.yaml @@ -0,0 +1,2104 @@ +openapi: 3.1.0 +info: + title: Tech Log Public API + version: 2.0.0 + description: | + Tech Log 공개 조회 계약이다. 인증이 필요하지 않다. + + ## 응답 봉투 (ADR-006) + + 모든 응답은 `{success, data|error, meta}` 봉투다. v1.0.0 은 bare payload + + RFC 7807 ProblemDetails 였고, ADR-006 이 그 둘을 함께 쓰지 않기로 정했으므로 + 구현 착수와 함께 studio-v1.yaml 과 같은 방식으로 변환했다. + + 오류 본문은 `application/problem+json` 이 아니라 `application/json` + + `ErrorEnvelope` 다. `error.code` 가 클라이언트가 분기하는 값이며 HTTP status 는 + 그 코드의 부수 정보다. + + ## UI route와 API route는 같을 필요가 없다 + + Frontend의 `/explore/:kind`는 화면 route다. 이 계약의 endpoint와 URL 구조를 + 억지로 일치시키지 않는다. + + | Frontend 화면 route | 호출하는 operation | + |---|---| + | `/explore` | `exploreKnowledge` + `exploreQuestions` | + | `/explore/cases` | `exploreKnowledge` (`type=CASE`) | + | `/explore/references` | `exploreKnowledge` (`type=REFERENCE`) | + | `/explore/questions` | `exploreQuestions` | + + ## 이 계약이 반환하지 않는 것 + + - Working Copy: Studio 계약(`studio-v1.yaml`)만 반환한다. + - Validation / Preview artifact: 인증된 Studio 계약에만 존재한다. + - Publication Event / Snapshot 이력: Studio 계약이 소유한다. + + Public이 노출하는 것은 현재 ACTIVE Public Projection뿐이다. 과거 게시 시점 + 화면은 Studio의 immutable Publication Snapshot이 책임진다. + + ## Asset + + 본문의 Evidence Figure는 managed `assetKey`로 해석된다. object storage URL이 + 콘텐츠 원문에 들어가지 않으며, 응답은 검증된 delivery path만 제공한다. + SVG도 URL 기반 ``로 렌더링하고 원문을 inline하지 않는다. +servers: +- url: /api/v1/public +tags: +- name: Site +- name: Home +- name: Explore +- name: Topic +- name: Case +- name: Reference +- name: Question +- name: Project +- name: Release +- name: Profile +- name: Search +paths: + /site: + get: + operationId: getPublicSite + tags: + - Site + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/SiteResponseEnvelope' + '500': + description: Internal Server Error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + /home: + get: + operationId: getPublicHome + tags: + - Home + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/HomeResponseEnvelope' + '500': + description: Internal Server Error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + /explore/knowledge: + get: + operationId: exploreKnowledge + description: | + Frontend의 `/explore`, `/explore/cases`, `/explore/references` 화면이 + 호출한다. 화면 route별로 별도 endpoint를 만들지 않고 `type` 필터로 구분한다. + + ```text + /explore/cases → type=CASE + /explore/references → type=REFERENCE + /explore → type 생략 + ``` + tags: + - Explore + parameters: + - name: type + in: query + schema: + type: string + enum: + - CASE + - REFERENCE + - name: topic + in: query + schema: + type: string + - name: project + in: query + schema: + type: string + - name: tag + in: query + schema: + type: string + - name: year + in: query + schema: + type: integer + minimum: 2000 + - name: sort + in: query + schema: + type: string + enum: + - PUBLISHED_DESC + - UPDATED_DESC + - VERIFIED_DESC + default: PUBLISHED_DESC + - &id001 + name: page + in: query + schema: + type: integer + minimum: 1 + default: 1 + - &id002 + name: size + in: query + schema: + type: integer + minimum: 1 + maximum: 100 + default: 20 + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/KnowledgePageEnvelope' + '400': + description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + '500': + description: Internal Server Error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + /explore/questions: + get: + operationId: exploreQuestions + description: | + Frontend의 `/explore/questions` 화면이 호출한다. + + 여기의 공개 상태는 `OPEN`/`RESOLVED` 축약 표현이다. Backend Inquiry의 + `INVESTIGATING`/`PAUSED`는 `OPEN`으로 표현되며 `ARCHIVED`는 공개되지 않는다. + Domain lifecycle 자체는 축소되지 않는다. + tags: + - Explore + parameters: + - name: status + in: query + schema: + type: string + enum: + - OPEN + - INVESTIGATING + - PAUSED + - RESOLVED + - name: topic + in: query + schema: + type: string + - name: project + in: query + schema: + type: string + - name: tag + in: query + schema: + type: string + - name: sort + in: query + schema: + type: string + enum: + - UPDATED_DESC + - OPENED_DESC + - RESOLVED_DESC + default: UPDATED_DESC + - *id001 + - *id002 + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/QuestionPageEnvelope' + '400': + description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + '500': + description: Internal Server Error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + /topics: + get: + operationId: listPublicTopics + tags: + - Topic + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/TopicListResponseEnvelope' + '500': + description: Internal Server Error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + /topics/{topicSlug}: + get: + operationId: getPublicTopic + tags: + - Topic + parameters: + - name: topicSlug + in: path + required: true + schema: + type: string + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/TopicDetailResponseEnvelope' + '404': + description: Not Found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + '500': + description: Internal Server Error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + /cases/{slug}: + get: + operationId: getPublicCase + tags: + - Case + parameters: + - name: slug + in: path + required: true + schema: + type: string + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/CaseDetailResponseEnvelope' + '404': + description: Not Found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + '500': + description: Internal Server Error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + /references/{slug}: + get: + operationId: getPublicReference + tags: + - Reference + parameters: + - name: slug + in: path + required: true + schema: + type: string + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/ReferenceDetailResponseEnvelope' + '404': + description: Not Found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + '500': + description: Internal Server Error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + /questions/{slug}: + get: + operationId: getPublicQuestion + tags: + - Question + parameters: + - name: slug + in: path + required: true + schema: + type: string + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/QuestionDetailResponseEnvelope' + '404': + description: Not Found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + '500': + description: Internal Server Error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + /projects: + get: + operationId: listPublicProjects + tags: + - Project + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/ProjectListResponseEnvelope' + '500': + description: Internal Server Error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + /projects/{slug}: + get: + operationId: getPublicProject + tags: + - Project + parameters: + - name: slug + in: path + required: true + schema: + type: string + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/ProjectDetailResponseEnvelope' + '404': + description: Not Found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + '500': + description: Internal Server Error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + /projects/{slug}/decisions: + get: + operationId: listPublicProjectDecisions + tags: + - Project + parameters: + - name: slug + in: path + required: true + schema: + type: string + - name: status + in: query + schema: + type: string + - *id001 + - *id002 + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/ProjectDecisionPageEnvelope' + '400': + description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + '404': + description: Not Found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + '500': + description: Internal Server Error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + /projects/{slug}/records: + get: + operationId: listPublicProjectRecords + tags: + - Project + parameters: + - name: slug + in: path + required: true + schema: + type: string + - name: type + in: query + schema: + type: string + enum: + - CASE + - REFERENCE + - QUESTION + - name: relation + in: query + schema: + type: string + enum: + - PRIMARY + - RELATED + - *id001 + - *id002 + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/ProjectRecordPageEnvelope' + '400': + description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + '404': + description: Not Found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + '500': + description: Internal Server Error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + /projects/{slug}/activities: + get: + operationId: listPublicProjectActivities + tags: + - Project + parameters: + - name: slug + in: path + required: true + schema: + type: string + - *id001 + - *id002 + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/ProjectActivityPageEnvelope' + '400': + description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + '404': + description: Not Found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + '500': + description: Internal Server Error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + /releases: + get: + operationId: listPublicReleases + tags: + - Release + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/ReleaseListResponseEnvelope' + '500': + description: Internal Server Error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + /releases/{version}: + get: + operationId: getPublicRelease + tags: + - Release + parameters: + - name: version + in: path + required: true + schema: + type: string + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/ReleaseDetailResponseEnvelope' + '404': + description: Not Found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + '500': + description: Internal Server Error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + /profile: + get: + operationId: getPublicProfile + tags: + - Profile + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/ProfileResponseEnvelope' + '404': + description: Not Found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + '500': + description: Internal Server Error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + /search: + get: + operationId: searchPublicResources + tags: + - Search + parameters: + - name: q + in: query + required: true + schema: + type: string + minLength: 1 + maxLength: 100 + - name: type + in: query + schema: + type: string + enum: + - CASE + - REFERENCE + - QUESTION + - PROJECT + - RELEASE + - name: topic + in: query + schema: + type: string + - *id001 + - *id002 + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/SearchResultPageEnvelope' + '400': + description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + '500': + description: Internal Server Error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' +components: + schemas: + FieldError: + type: object + required: + - field + - code + - message + properties: + field: + type: string + code: + type: string + message: + type: string + PageMetadata: + type: object + required: + - number + - size + - totalElements + - totalPages + - hasPrevious + - hasNext + properties: + number: + type: integer + minimum: 1 + size: + type: integer + minimum: 1 + maximum: 100 + totalElements: + type: integer + format: int64 + minimum: 0 + totalPages: + type: integer + minimum: 0 + hasPrevious: + type: boolean + hasNext: + type: boolean + TopicSummary: + type: object + required: + - name + - slug + properties: + name: + type: string + slug: + type: string + TagSummary: + type: object + required: + - name + - slug + properties: + name: + type: string + slug: + type: string + ProjectSummary: + type: object + required: + - name + - slug + - path + properties: + name: + type: string + slug: + type: string + path: + type: string + AssetReference: + type: object + required: + - assetId + - url + properties: + assetId: &id004 + type: string + format: uuid + url: + type: string + altText: + type: string + width: + type: integer + height: + type: integer + contentType: + type: string + RelatedEntry: + type: object + required: + - type + - title + - path + properties: + type: + type: string + enum: + - CASE + - REFERENCE + - QUESTION + - PROJECT + - PROJECT_DECISION + - RELEASE + title: + type: string + summary: + type: string + path: + type: string + ContactLink: + type: object + required: + - type + - label + - url + properties: + type: + type: string + label: + type: string + url: + type: string + format: uri + SiteResponse: + type: object + required: + - brand + - operator + - contacts + properties: + brand: + type: object + required: + - title + - identityStatement + properties: + title: + type: string + identityStatement: + type: string + operator: + type: object + required: + - displayName + - profilePath + properties: + displayName: + type: string + shortIdentity: + type: string + avatar: + $ref: '#/components/schemas/AssetReference' + profilePath: + type: string + contacts: + type: array + items: + $ref: '#/components/schemas/ContactLink' + CurrentWorkFocus: + type: object + required: + - projectName + - projectPath + - purpose + - phase + - currentObjective + - updatedAt + properties: + projectName: + type: string + projectPath: + type: string + purpose: + type: string + phase: + type: string + enum: + - RESEARCH + - DESIGN + - IMPLEMENTATION + - VERIFICATION + - MAINTENANCE + - PAUSED + - COMPLETED + currentObjective: + type: string + nextStep: + type: string + updatedAt: &id003 + type: string + format: date-time + OpenQuestionFocus: + type: object + required: + - question + - questionPath + - summary + - knownFacts + - unresolvedPoints + - nextVerification + - updatedAt + properties: + question: + type: string + questionPath: + type: string + summary: + type: string + knownFacts: + type: array + items: + type: string + unresolvedPoints: + type: array + items: + type: string + nextVerification: + type: string + updatedAt: *id003 + RecentDecisionFocus: + type: object + required: + - statement + - decisionPath + - rationale + - consequences + - decidedAt + properties: + statement: + type: string + decisionPath: + type: string + rationale: + type: string + consequences: + type: array + items: + type: string + decidedAt: *id003 + LatestEntry: + type: object + required: + - entryType + - title + - summary + - path + - publishedAt + properties: + entryType: + type: string + enum: + - CASE + - REFERENCE + - PROJECT_ACTIVITY + - RELEASE + title: + type: string + summary: + type: string + path: + type: string + primaryTopic: + $ref: '#/components/schemas/TopicSummary' + primaryProject: + $ref: '#/components/schemas/ProjectSummary' + publishedAt: *id003 + HomeResponse: + type: object + required: + - focus + - latestEntries + properties: + focus: + type: object + required: + - defaultType + properties: + defaultType: + type: string + enum: + - CURRENT_WORK + - OPEN_QUESTION + - RECENT_DECISION + currentWork: + $ref: '#/components/schemas/CurrentWorkFocus' + openQuestion: + $ref: '#/components/schemas/OpenQuestionFocus' + recentDecision: + $ref: '#/components/schemas/RecentDecisionFocus' + latestEntries: + type: array + maxItems: 6 + items: + $ref: '#/components/schemas/LatestEntry' + KnowledgeListItem: + type: object + required: + - type + - title + - path + - primarySummary + - publishedAt + properties: + type: + type: string + enum: + - CASE + - REFERENCE + title: + type: string + path: + type: string + primarySummary: + type: string + secondarySummary: + type: string + primaryTopic: + $ref: '#/components/schemas/TopicSummary' + primaryProject: + $ref: '#/components/schemas/ProjectSummary' + publishedAt: *id003 + lastVerifiedAt: *id003 + freshnessStatus: + type: string + KnowledgePage: + type: object + required: + - items + - page + properties: + items: + type: array + items: + $ref: '#/components/schemas/KnowledgeListItem' + page: + $ref: '#/components/schemas/PageMetadata' + QuestionListItem: + type: object + required: + - question + - path + - status + - summary + - updatedAt + properties: + question: + type: string + path: + type: string + status: + type: string + enum: + - OPEN + - INVESTIGATING + - PAUSED + - RESOLVED + summary: + type: string + currentUnderstanding: + type: string + nextVerification: + type: string + primaryProject: + $ref: '#/components/schemas/ProjectSummary' + updatedAt: *id003 + QuestionPage: + type: object + required: + - items + - page + properties: + items: + type: array + items: + $ref: '#/components/schemas/QuestionListItem' + page: + $ref: '#/components/schemas/PageMetadata' + TopicListItem: + type: object + required: + - name + - slug + - description + properties: + name: + type: string + slug: + type: string + description: + type: string + recordCount: + type: integer + TopicListResponse: + type: object + required: + - items + properties: + items: + type: array + items: + $ref: '#/components/schemas/TopicListItem' + TopicDetailResponse: + type: object + required: + - topic + - featuredCases + - activeQuestions + - relatedProjects + - latestRecords + properties: + topic: + type: object + required: + - name + - slug + - description + properties: + name: + type: string + slug: + type: string + description: + type: string + scope: + type: string + featuredReference: + $ref: '#/components/schemas/RelatedEntry' + featuredCases: + type: array + items: + $ref: '#/components/schemas/RelatedEntry' + activeQuestions: + type: array + items: + $ref: '#/components/schemas/RelatedEntry' + relatedProjects: + type: array + items: + $ref: '#/components/schemas/RelatedEntry' + latestRecords: + type: array + items: + $ref: '#/components/schemas/LatestEntry' + CaseDetailResponse: + type: object + required: + - canonicalPath + - case + - relations + properties: + canonicalPath: + type: string + indexable: + type: boolean + default: true + case: + type: object + required: + - title + - problemSummary + - conclusionSummary + - content + - contentFormat + - contentFormatVersion + - tags + - publishedAt + - updatedAt + properties: + title: + type: string + problemSummary: + type: string + conclusionSummary: + type: string + environmentSummary: + type: array + items: + type: string + content: + type: string + contentFormat: + type: string + enum: + - MARKDOWN + contentFormatVersion: + type: integer + primaryTopic: + $ref: '#/components/schemas/TopicSummary' + tags: + type: array + items: + $ref: '#/components/schemas/TagSummary' + primaryProject: + $ref: '#/components/schemas/ProjectSummary' + coverAsset: + $ref: '#/components/schemas/AssetReference' + publishedAt: *id003 + updatedAt: *id003 + lastVerifiedAt: *id003 + relations: + type: object + required: + - projectDecisions + - derivedReferences + - relatedCases + properties: + originQuestion: + $ref: '#/components/schemas/RelatedEntry' + projectDecisions: + type: array + items: + $ref: '#/components/schemas/RelatedEntry' + derivedReferences: + type: array + items: + $ref: '#/components/schemas/RelatedEntry' + relatedCases: + type: array + items: + $ref: '#/components/schemas/RelatedEntry' + ReferenceDetailResponse: + type: object + required: + - canonicalPath + - reference + - relations + properties: + canonicalPath: + type: string + indexable: + type: boolean + default: true + reference: + type: object + required: + - title + - scopeSummary + - appliesTo + - excludedScope + - freshnessStatus + - content + - contentFormat + - contentFormatVersion + - tags + - publishedAt + - updatedAt + properties: + title: + type: string + scopeSummary: + type: string + appliesTo: + type: array + items: + type: string + excludedScope: + type: array + items: + type: string + freshnessStatus: + type: string + enum: + - CURRENT + - REVIEW_DUE + - HISTORICAL + content: + type: string + contentFormat: + type: string + enum: + - MARKDOWN + contentFormatVersion: + type: integer + primaryTopic: + $ref: '#/components/schemas/TopicSummary' + tags: + type: array + items: + $ref: '#/components/schemas/TagSummary' + primaryProject: + $ref: '#/components/schemas/ProjectSummary' + coverAsset: + $ref: '#/components/schemas/AssetReference' + publishedAt: *id003 + updatedAt: *id003 + lastVerifiedAt: *id003 + relations: + type: object + required: + - supportingCases + - relatedDecisions + - relatedReferences + properties: + supportingCases: + type: array + items: + $ref: '#/components/schemas/RelatedEntry' + relatedDecisions: + type: array + items: + $ref: '#/components/schemas/RelatedEntry' + relatedReferences: + type: array + items: + $ref: '#/components/schemas/RelatedEntry' + QuestionPointGroup: + type: object + required: + - facts + - assumptions + - unknowns + - constraints + properties: + facts: + type: array + items: + type: string + assumptions: + type: array + items: + type: string + unknowns: + type: array + items: + type: string + constraints: + type: array + items: + type: string + QuestionUpdatePublic: + type: object + required: + - type + - title + - bodyMarkdown + - occurredAt + properties: + type: + type: string + title: + type: string + bodyMarkdown: + type: string + occurredAt: *id003 + QuestionDetailResponse: + type: object + required: + - canonicalPath + - indexable + - question + - relations + properties: + canonicalPath: + type: string + indexable: + type: boolean + question: + type: object + required: + - question + - summary + - context + - importance + - status + - points + - updates + - openedAt + - updatedAt + properties: + question: + type: string + summary: + type: string + context: + type: string + importance: + type: string + status: + type: string + enum: + - OPEN + - INVESTIGATING + - PAUSED + - RESOLVED + nextVerification: + type: string + points: + $ref: '#/components/schemas/QuestionPointGroup' + updates: + type: array + items: + $ref: '#/components/schemas/QuestionUpdatePublic' + resolution: + type: object + properties: + type: + type: string + summary: + type: string + resolvedAt: *id003 + openedAt: *id003 + updatedAt: *id003 + relations: + type: object + required: + - derivedReferences + properties: + primaryProject: + $ref: '#/components/schemas/RelatedEntry' + resultCase: + $ref: '#/components/schemas/RelatedEntry' + producedDecision: + $ref: '#/components/schemas/RelatedEntry' + derivedReferences: + type: array + items: + $ref: '#/components/schemas/RelatedEntry' + ProjectListItem: + type: object + required: + - name + - slug + - path + - oneLinePurpose + - phase + - updatedAt + properties: + name: + type: string + slug: + type: string + path: + type: string + oneLinePurpose: + type: string + phase: + type: string + currentObjective: + type: string + nextStep: + type: string + updatedAt: *id003 + ProjectListResponse: + type: object + required: + - items + properties: + items: + type: array + items: + $ref: '#/components/schemas/ProjectListItem' + ProjectDetailResponse: + type: object + required: + - canonicalPath + - project + - selectedRecords + properties: + canonicalPath: + type: string + indexable: + type: boolean + default: true + project: + type: object + required: + - name + - slug + - purpose + - boundary + - phase + - oneLinePurpose + - updatedAt + properties: + name: + type: string + slug: + type: string + oneLinePurpose: + type: string + purpose: + type: string + boundary: + type: string + phase: + type: string + currentObjective: + type: string + nextStep: + type: string + systemOverviewMarkdown: + type: string + technologies: + type: array + items: + type: string + updatedAt: *id003 + featuredDecision: + $ref: '#/components/schemas/RelatedEntry' + activeQuestion: + $ref: '#/components/schemas/RelatedEntry' + selectedRecords: + type: array + items: + $ref: '#/components/schemas/RelatedEntry' + ProjectDecisionItem: + type: object + required: + - id + - statement + - status + - decidedAt + properties: + id: *id004 + statement: + type: string + status: + type: string + rationaleSummary: + type: string + decidedAt: *id003 + sourceQuestion: + $ref: '#/components/schemas/RelatedEntry' + sourceCase: + $ref: '#/components/schemas/RelatedEntry' + ProjectDecisionPage: + type: object + required: + - items + - page + properties: + items: + type: array + items: + $ref: '#/components/schemas/ProjectDecisionItem' + page: + $ref: '#/components/schemas/PageMetadata' + ProjectRecordPage: + type: object + required: + - items + - page + properties: + items: + type: array + items: + $ref: '#/components/schemas/RelatedEntry' + page: + $ref: '#/components/schemas/PageMetadata' + ProjectActivityItem: + type: object + required: + - type + - title + - occurredAt + properties: + type: + type: string + title: + type: string + summary: + type: string + occurredAt: *id003 + relatedPath: + type: string + ProjectActivityPage: + type: object + required: + - items + - page + properties: + items: + type: array + items: + $ref: '#/components/schemas/ProjectActivityItem' + page: + $ref: '#/components/schemas/PageMetadata' + ReleaseListItem: + type: object + required: + - version + - title + - summary + - releasedOn + - changeTypes + - path + properties: + version: + type: string + title: + type: string + summary: + type: string + releasedOn: &id005 + type: string + format: date + changeTypes: + type: array + items: + type: string + path: + type: string + ReleaseListResponse: + type: object + required: + - items + properties: + items: + type: array + items: + $ref: '#/components/schemas/ReleaseListItem' + ReleaseDetailResponse: + type: object + required: + - version + - title + - summary + - releasedOn + - changeTypes + - changesMarkdown + - verificationMarkdown + - relatedRecords + properties: + version: + type: string + title: + type: string + summary: + type: string + releasedOn: *id005 + changeTypes: + type: array + items: + type: string + reasonMarkdown: + type: string + changesMarkdown: + type: string + userImpactMarkdown: + type: string + implementationImpactMarkdown: + type: string + verificationMarkdown: + type: string + knownLimitationsMarkdown: + type: string + relatedRecords: + type: array + items: + $ref: '#/components/schemas/RelatedEntry' + ProfileResponse: + type: object + required: + - position + - workingModel + - territories + - selectedEvidence + - trajectory + - contacts + properties: + position: + type: object + required: + - headline + - description + properties: + headline: + type: string + description: + type: string + workingModel: + type: array + items: + type: object + required: + - name + - description + properties: + name: + type: string + description: + type: string + territories: + type: array + items: + type: object + required: + - name + properties: + name: + type: string + currentQuestion: + type: string + topicPath: + type: string + selectedEvidence: + type: array + items: + $ref: '#/components/schemas/RelatedEntry' + trajectory: + type: array + items: + type: object + required: + - title + - description + properties: + title: + type: string + description: + type: string + contacts: + type: array + items: + $ref: '#/components/schemas/ContactLink' + SearchResultItem: + type: object + required: + - contentType + - title + - path + - snippet + - matchedFields + properties: + contentType: + type: string + title: + type: string + path: + type: string + snippet: + type: string + matchedFields: + type: array + items: + type: string + primaryTopic: + $ref: '#/components/schemas/TopicSummary' + primaryProject: + $ref: '#/components/schemas/ProjectSummary' + publishedAt: *id003 + updatedAt: *id003 + SearchResultPage: + type: object + required: + - query + - items + - page + properties: + query: + type: string + items: + type: array + items: + $ref: '#/components/schemas/SearchResultItem' + page: + $ref: '#/components/schemas/PageMetadata' + ResponseMeta: + type: object + additionalProperties: false + required: + - requestId + - traceId + properties: + requestId: + type: string + minLength: 1 + maxLength: 200 + traceId: + type: string + minLength: 1 + maxLength: 200 + correlationId: + type: + - string + - 'null' + maxLength: 200 + page: + type: + - object + - 'null' + additionalProperties: true + description: offset 페이지네이션 정보는 각 페이지 payload 의 `page` 필드가 소유한다. 이 필드는 백엔드 템플릿의 ResponseMeta record 가 직렬화하는 자리이며 공개 조회에서는 항상 null 이다. + ValidationErrorDetails: + type: object + additionalProperties: false + required: + - fieldErrors + properties: + fieldErrors: + type: array + maxItems: 200 + items: + $ref: '#/components/schemas/FieldError' + ApiError: + type: object + additionalProperties: false + required: + - code + - category + - message + - retryable + properties: + code: + type: string + enum: + - PUBLIC_REQUEST_INVALID + - PUBLIC_RESOURCE_NOT_FOUND + - INTERNAL_ERROR + description: '`INTERNAL_ERROR` 는 이 기능이 아니라 스켈레톤의 공통 처리기가 내는 코드다. 계약이 그것까지 열거해야 500 응답이 계약을 벗어나지 않는다.' + category: + type: string + enum: + - VALIDATION + - AUTH + - AUTHZ + - NOT_FOUND + - CONFLICT + - RATE_LIMIT + - TRANSIENT_DEPENDENCY + - PERMANENT_DEPENDENCY + - DATA_INTEGRITY + - INTERNAL + message: + type: string + minLength: 1 + maxLength: 5000 + retryable: + type: boolean + details: + oneOf: + - $ref: '#/components/schemas/ValidationErrorDetails' + - type: 'null' + ErrorEnvelope: + type: object + additionalProperties: false + required: + - success + - error + - meta + properties: + success: + type: boolean + const: false + error: + $ref: '#/components/schemas/ApiError' + meta: + $ref: '#/components/schemas/ResponseMeta' + SiteResponseEnvelope: + type: object + additionalProperties: false + required: + - success + - data + - meta + properties: + success: + type: boolean + const: true + data: + $ref: '#/components/schemas/SiteResponse' + meta: + $ref: '#/components/schemas/ResponseMeta' + HomeResponseEnvelope: + type: object + additionalProperties: false + required: + - success + - data + - meta + properties: + success: + type: boolean + const: true + data: + $ref: '#/components/schemas/HomeResponse' + meta: + $ref: '#/components/schemas/ResponseMeta' + KnowledgePageEnvelope: + type: object + additionalProperties: false + required: + - success + - data + - meta + properties: + success: + type: boolean + const: true + data: + $ref: '#/components/schemas/KnowledgePage' + meta: + $ref: '#/components/schemas/ResponseMeta' + QuestionPageEnvelope: + type: object + additionalProperties: false + required: + - success + - data + - meta + properties: + success: + type: boolean + const: true + data: + $ref: '#/components/schemas/QuestionPage' + meta: + $ref: '#/components/schemas/ResponseMeta' + TopicListResponseEnvelope: + type: object + additionalProperties: false + required: + - success + - data + - meta + properties: + success: + type: boolean + const: true + data: + $ref: '#/components/schemas/TopicListResponse' + meta: + $ref: '#/components/schemas/ResponseMeta' + TopicDetailResponseEnvelope: + type: object + additionalProperties: false + required: + - success + - data + - meta + properties: + success: + type: boolean + const: true + data: + $ref: '#/components/schemas/TopicDetailResponse' + meta: + $ref: '#/components/schemas/ResponseMeta' + CaseDetailResponseEnvelope: + type: object + additionalProperties: false + required: + - success + - data + - meta + properties: + success: + type: boolean + const: true + data: + $ref: '#/components/schemas/CaseDetailResponse' + meta: + $ref: '#/components/schemas/ResponseMeta' + ReferenceDetailResponseEnvelope: + type: object + additionalProperties: false + required: + - success + - data + - meta + properties: + success: + type: boolean + const: true + data: + $ref: '#/components/schemas/ReferenceDetailResponse' + meta: + $ref: '#/components/schemas/ResponseMeta' + QuestionDetailResponseEnvelope: + type: object + additionalProperties: false + required: + - success + - data + - meta + properties: + success: + type: boolean + const: true + data: + $ref: '#/components/schemas/QuestionDetailResponse' + meta: + $ref: '#/components/schemas/ResponseMeta' + ProjectListResponseEnvelope: + type: object + additionalProperties: false + required: + - success + - data + - meta + properties: + success: + type: boolean + const: true + data: + $ref: '#/components/schemas/ProjectListResponse' + meta: + $ref: '#/components/schemas/ResponseMeta' + ProjectDetailResponseEnvelope: + type: object + additionalProperties: false + required: + - success + - data + - meta + properties: + success: + type: boolean + const: true + data: + $ref: '#/components/schemas/ProjectDetailResponse' + meta: + $ref: '#/components/schemas/ResponseMeta' + ProjectDecisionPageEnvelope: + type: object + additionalProperties: false + required: + - success + - data + - meta + properties: + success: + type: boolean + const: true + data: + $ref: '#/components/schemas/ProjectDecisionPage' + meta: + $ref: '#/components/schemas/ResponseMeta' + ProjectRecordPageEnvelope: + type: object + additionalProperties: false + required: + - success + - data + - meta + properties: + success: + type: boolean + const: true + data: + $ref: '#/components/schemas/ProjectRecordPage' + meta: + $ref: '#/components/schemas/ResponseMeta' + ProjectActivityPageEnvelope: + type: object + additionalProperties: false + required: + - success + - data + - meta + properties: + success: + type: boolean + const: true + data: + $ref: '#/components/schemas/ProjectActivityPage' + meta: + $ref: '#/components/schemas/ResponseMeta' + ReleaseListResponseEnvelope: + type: object + additionalProperties: false + required: + - success + - data + - meta + properties: + success: + type: boolean + const: true + data: + $ref: '#/components/schemas/ReleaseListResponse' + meta: + $ref: '#/components/schemas/ResponseMeta' + ReleaseDetailResponseEnvelope: + type: object + additionalProperties: false + required: + - success + - data + - meta + properties: + success: + type: boolean + const: true + data: + $ref: '#/components/schemas/ReleaseDetailResponse' + meta: + $ref: '#/components/schemas/ResponseMeta' + ProfileResponseEnvelope: + type: object + additionalProperties: false + required: + - success + - data + - meta + properties: + success: + type: boolean + const: true + data: + $ref: '#/components/schemas/ProfileResponse' + meta: + $ref: '#/components/schemas/ResponseMeta' + SearchResultPageEnvelope: + type: object + additionalProperties: false + required: + - success + - data + - meta + properties: + success: + type: boolean + const: true + data: + $ref: '#/components/schemas/SearchResultPage' + meta: + $ref: '#/components/schemas/ResponseMeta'