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:
co-authored by
Claude Opus 5
parent
697fc740e6
commit
91e6d99654
+64
@@ -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;
|
||||
}
|
||||
}
|
||||
+44
@@ -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;
|
||||
}
|
||||
}
|
||||
+11
@@ -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);
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package dev.caskeleton.application.techlog.studio.query;
|
||||
|
||||
/** 계약 `CatalogEntryType`과 1:1. API enum이 그대로 application 용어다. */
|
||||
public enum CatalogEntryType {
|
||||
TOPIC,
|
||||
PROJECT,
|
||||
RELATION,
|
||||
EVIDENCE
|
||||
}
|
||||
+14
@@ -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) {}
|
||||
+10
@@ -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);
|
||||
}
|
||||
}
|
||||
+6
@@ -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 {}
|
||||
+86
@@ -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()));
|
||||
}
|
||||
}
|
||||
+48
@@ -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();
|
||||
}
|
||||
}
|
||||
+138
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user