feat: Tech Log Studio 백엔드 기반 — 계약 배선, 오류 코드, 경계 규칙, 스키마, 엔드포인트 2종

설계 패키지의 studio-v1.yaml(v3.0.0, 응답 봉투)을 이 저장소에 배선하고
슬라이스 1의 기반을 세운다. 19개 operation 중 getStudioSession과
listStudioCatalog를 구현했다.

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-08-19 15:14:52 +09:00
co-authored by Claude Opus 5
parent 697fc740e6
commit 91e6d99654
48 changed files with 9495 additions and 220 deletions
@@ -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;
}
}
@@ -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를 알 필요가 없다.
*
* <p>{@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;
}
}
@@ -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);
}
@@ -0,0 +1,9 @@
package dev.caskeleton.application.techlog.studio.query;
/** 계약 `CatalogEntryType`과 1:1. API enum이 그대로 application 용어다. */
public enum CatalogEntryType {
TOPIC,
PROJECT,
RELATION,
EVIDENCE
}
@@ -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) {}
@@ -0,0 +1,10 @@
package dev.caskeleton.application.techlog.studio.query;
import java.util.List;
public record CatalogPageView(List<CatalogEntryView> items, String nextCursor) {
public CatalogPageView {
items = List.copyOf(items);
}
}
@@ -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 {}
@@ -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다.
*
* <p>브리프 원안은 {@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<ListCatalogQuery, CatalogPageView> {
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()));
}
}
@@ -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();
}
}
@@ -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> T inWrite(Supplier<T> action) {
return action.get();
}
@Override
public <T> T inRootWrite(Supplier<T> action) {
return action.get();
}
@Override
public <T> T inRead(Supplier<T> action) {
return action.get();
}
@Override
public <T> T inNew(Supplier<T> action) {
return action.get();
}
}
}