feat: implement release authoring, so the changelog can be written

The public site has a Releases page and a footer link to the latest release,
and both were empty — the read side has existed since the public surface
landed, but nothing could ever create a row. The seven release operations were
in the contract with no implementation, so the changelog was a page that could
only ever be blank.

The release model is not a blob of prose. It splits into six markdown sections
because a release note answers fixed questions — why, what, what changes for a
reader, what it leaves in the code, how it was verified, what is still missing
— and a single text column cannot say which of those went unanswered.

Publishing is the only thing that makes a release public: the public query
filters on `workflow_status = 'PUBLISHED'` and nothing else. So publish is
where the contract's required fields are actually enforced. Saving stays
permissive — a draft you cannot save until it is complete is a draft you cannot
write — and the two demands are deliberately different.

`version_label` is NOT NULL UNIQUE but a draft has no version yet, so creation
writes a placeholder derived from the row id and publication refuses to ship
one. Relaxing the column instead would open a window where a published release
is publicly visible with no version at all.

A published release cannot be deleted, only archived: a public changelog entry
that vanishes leaves everyone who linked it with no way to learn what happened.

Also registers `adapter-outbound-objectstorage` as an app-bootstrap runtime
member. It was added as a dependency when asset upload was fixed but never
registered, and `verifyRuntimeModuleMembership` had not been run since.
This commit is contained in:
DongHyeonka
2026-08-21 02:47:15 +09:00
parent 48517b9e65
commit 386f360122
39 changed files with 1391 additions and 200 deletions
+39
View File
@@ -1363,4 +1363,43 @@ errors:
log_level: INFO log_level: INFO
runbook_link: null runbook_link: null
compatibility_impact: additive compatibility_impact: additive
required_test: ManagementErrorRegistryTest
# source: studio-management-v1.yaml ApiError.code — RELEASE_NOT_FOUND (ManagementError.RELEASE_NOT_FOUND)
- code: RELEASE_NOT_FOUND
category: NOT_FOUND
http_status: 404
retryable: false
retry_after_seconds: null
owner_branch: feature-techlog-management-v1
owner_layer: application
client_safe_message: "요청한 릴리즈를 찾을 수 없습니다"
log_level: INFO
runbook_link: null
compatibility_impact: additive
required_test: ManagementErrorRegistryTest
# source: studio-management-v1.yaml ApiError.code — RELEASE_VERSION_TAKEN (ManagementError.RELEASE_VERSION_TAKEN)
- code: RELEASE_VERSION_TAKEN
category: CONFLICT
http_status: 409
retryable: false
retry_after_seconds: null
owner_branch: feature-techlog-management-v1
owner_layer: application
client_safe_message: "같은 버전의 릴리즈가 이미 있습니다"
log_level: INFO
runbook_link: null
compatibility_impact: additive
required_test: ManagementErrorRegistryTest
# source: studio-management-v1.yaml ApiError.code — RELEASE_NOT_PUBLISHABLE (ManagementError.RELEASE_NOT_PUBLISHABLE)
- code: RELEASE_NOT_PUBLISHABLE
category: CONFLICT
http_status: 409
retryable: false
retry_after_seconds: null
owner_branch: feature-techlog-management-v1
owner_layer: application
client_safe_message: "지금 상태에서는 발행할 수 없습니다"
log_level: INFO
runbook_link: null
compatibility_impact: additive
required_test: ManagementErrorRegistryTest required_test: ManagementErrorRegistryTest
@@ -3,8 +3,8 @@ package dev.caskeleton.adapter.inbound.web.techlog.management;
import dev.caskeleton.application.techlog.error.ManagementError; import dev.caskeleton.application.techlog.error.ManagementError;
/** /**
* code 별 고정 문구. 예외의 원문 메시지는 진단용이라 그대로 내보내지 않는다 — 저장소 제약 이름이나 * code 별 고정 문구. 예외의 원문 메시지는 진단용이라 그대로 내보내지 않는다 — 저장소 제약 이름이나 SQL 조각이 새어 나갈 수 있고, 그건 클라이언트가 분기할 값도
* SQL 조각이 새어 나갈 수 있고, 그건 클라이언트가 분기할 값도 아니다. * 아니다.
*/ */
public final class ManagementClientSafeMessages { public final class ManagementClientSafeMessages {
@@ -23,6 +23,9 @@ public final class ManagementClientSafeMessages {
case PROJECT_NOT_FOUND -> "프로젝트를 찾을 수 없습니다"; case PROJECT_NOT_FOUND -> "프로젝트를 찾을 수 없습니다";
case PROJECT_SLUG_TAKEN -> "같은 slug 의 프로젝트가 이미 있습니다"; case PROJECT_SLUG_TAKEN -> "같은 slug 의 프로젝트가 이미 있습니다";
case PROJECT_IN_USE -> "이 프로젝트에 연결된 기록이 있어 삭제할 수 없습니다"; case PROJECT_IN_USE -> "이 프로젝트에 연결된 기록이 있어 삭제할 수 없습니다";
case RELEASE_NOT_FOUND -> "요청한 릴리즈를 찾을 수 없습니다";
case RELEASE_VERSION_TAKEN -> "같은 버전의 릴리즈가 이미 있습니다";
case RELEASE_NOT_PUBLISHABLE -> "지금 상태에서는 발행할 수 없습니다";
}; };
} }
} }
@@ -18,9 +18,8 @@ import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
/** /**
* 관리 표면의 실패를 봉투로 옮긴다. 스코프를 {@code ...web.techlog.management} 로 좁히는 이유는 형제 표면들과 * 관리 표면의 실패를 봉투로 옮긴다. 스코프를 {@code ...web.techlog.management} 로 좁히는 이유는 형제 표면들과 같다 — 각 계약이 자기 {@code
* 같다 — 각 계약이 자기 {@code ApiError.code} 집합만 열거하고 있어서, 다른 표면의 코드가 새어 들어가면 그 * ApiError.code} 집합만 열거하고 있어서, 다른 표면의 코드가 새어 들어가면 그 계약이 거짓이 된다.
* 계약이 거짓이 된다.
*/ */
@Order(Ordered.HIGHEST_PRECEDENCE) @Order(Ordered.HIGHEST_PRECEDENCE)
@RestControllerAdvice(basePackages = "dev.caskeleton.adapter.inbound.web.techlog.management") @RestControllerAdvice(basePackages = "dev.caskeleton.adapter.inbound.web.techlog.management")
@@ -41,9 +40,8 @@ public class ManagementExceptionHandler {
} }
/** /**
* 본문을 못 읽는 경우(빈 본문, 깨진 JSON, enum 값 불일치). 그냥 두면 부모 처리기가 RFC 7807 을 만들고 * 본문을 못 읽는 경우(빈 본문, 깨진 JSON, enum 값 불일치). 그냥 두면 부모 처리기가 RFC 7807 을 만들고 {@code EnvelopeBodyAdvice}
* {@code EnvelopeBodyAdvice} 의 미디어타입 검사에 걸려 봉투가 안 씌워진다 — ADR-006 이 쓰지 않기로 한 * 의 미디어타입 검사에 걸려 봉투가 안 씌워진다 — ADR-006 이 쓰지 않기로 한 모양이 그대로 나간다.
* 모양이 그대로 나간다.
*/ */
@ExceptionHandler(HttpMessageNotReadableException.class) @ExceptionHandler(HttpMessageNotReadableException.class)
public ResponseEntity<Envelope<Void>> handleUnreadableBody(HttpMessageNotReadableException ex) { public ResponseEntity<Envelope<Void>> handleUnreadableBody(HttpMessageNotReadableException ex) {
@@ -63,8 +61,7 @@ public class ManagementExceptionHandler {
} }
/** 계약의 {@code ValidationErrorDetails} — {@code field/code/message} 셋 다 required 다. */ /** 계약의 {@code ValidationErrorDetails} — {@code field/code/message} 셋 다 required 다. */
private static ResponseEntity<Envelope<Void>> invalid( private static ResponseEntity<Envelope<Void>> invalid(String field, String code, String message) {
String field, String code, String message) {
Map<String, Object> fieldError = Map.of("field", field, "code", code, "message", message); Map<String, Object> fieldError = Map.of("field", field, "code", code, "message", message);
return ErrorResponseFactory.envelope( return ErrorResponseFactory.envelope(
ManagementError.REQUEST_VALIDATION_FAILED, ManagementError.REQUEST_VALIDATION_FAILED,
@@ -5,8 +5,8 @@ import dev.caskeleton.application.techlog.error.ManagementError;
import dev.caskeleton.application.techlog.error.ManagementException; import dev.caskeleton.application.techlog.error.ManagementException;
/** /**
* 감사 컬럼에 남길 주체. {@code StudioPrincipals} 와 같은 일을 하되 이 표면의 error code 로 던진다 — * 감사 컬럼에 남길 주체. {@code StudioPrincipals} 와 같은 일을 하되 이 표면의 error code 로 던진다 — 계약이 각자 code 집합을 열거하므로
* 계약이 각자 code 집합을 열거하므로 예외까지 공유하면 한쪽 계약이 거짓이 된다. * 예외까지 공유하면 한쪽 계약이 거짓이 된다.
*/ */
public final class ManagementPrincipals { public final class ManagementPrincipals {
@@ -0,0 +1,145 @@
package dev.caskeleton.adapter.inbound.web.techlog.management.controller;
import dev.caskeleton.adapter.inbound.web.auth.AuthenticatedPrincipal;
import dev.caskeleton.adapter.inbound.web.techlog.management.ManagementPrincipals;
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.CreateDraftRequest;
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.CreateDraftResponse;
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.ExpectedVersionRequest;
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.PublishResponse;
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.ReleaseEditResponse;
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.ReleaseIndexPage;
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.ReleaseUpdateRequest;
import dev.caskeleton.adapter.inbound.web.techlog.management.mapper.ManagementResponseMapper;
import dev.caskeleton.application.techlog.management.command.CreateReleaseCommand;
import dev.caskeleton.application.techlog.management.command.ReleaseLifecycleCommand;
import dev.caskeleton.application.techlog.management.command.UpdateReleaseCommand;
import dev.caskeleton.application.techlog.management.service.ArchiveReleaseUseCase;
import dev.caskeleton.application.techlog.management.service.CreateReleaseUseCase;
import dev.caskeleton.application.techlog.management.service.DeleteReleaseUseCase;
import dev.caskeleton.application.techlog.management.service.GetReleaseForEditUseCase;
import dev.caskeleton.application.techlog.management.service.ListStudioReleasesUseCase;
import dev.caskeleton.application.techlog.management.service.PublishReleaseUseCase;
import dev.caskeleton.application.techlog.management.service.UpdateReleaseUseCase;
import java.util.List;
import java.util.UUID;
import org.springframework.http.HttpStatus;
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.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
/** 릴리즈 관리. 계약의 release 7개. */
@RestController
public class ManagementReleaseController {
private final ListStudioReleasesUseCase listReleases;
private final GetReleaseForEditUseCase getRelease;
private final CreateReleaseUseCase createRelease;
private final UpdateReleaseUseCase updateRelease;
private final DeleteReleaseUseCase deleteRelease;
private final PublishReleaseUseCase publishRelease;
private final ArchiveReleaseUseCase archiveRelease;
public ManagementReleaseController(
ListStudioReleasesUseCase listReleases,
GetReleaseForEditUseCase getRelease,
CreateReleaseUseCase createRelease,
UpdateReleaseUseCase updateRelease,
DeleteReleaseUseCase deleteRelease,
PublishReleaseUseCase publishRelease,
ArchiveReleaseUseCase archiveRelease) {
this.listReleases = listReleases;
this.getRelease = getRelease;
this.createRelease = createRelease;
this.updateRelease = updateRelease;
this.deleteRelease = deleteRelease;
this.publishRelease = publishRelease;
this.archiveRelease = archiveRelease;
}
@GetMapping("/v1/studio/releases")
public ReleaseIndexPage listStudioReleases(
@RequestParam(name = "page", defaultValue = "0") int page,
@RequestParam(name = "size", defaultValue = "20") int size) {
return ManagementResponseMapper.releases(listReleases.handle(page, size));
}
@GetMapping("/v1/studio/releases/{id}")
public ReleaseEditResponse getReleaseForEdit(@PathVariable("id") UUID id) {
return ManagementResponseMapper.release(getRelease.handle(id));
}
@PostMapping("/v1/studio/releases")
@ResponseStatus(HttpStatus.CREATED)
public CreateDraftResponse createRelease(
@AuthenticationPrincipal AuthenticatedPrincipal principal,
@RequestBody CreateDraftRequest body) {
return ManagementResponseMapper.releaseDraft(
createRelease.handle(
new CreateReleaseCommand(body.getTitle(), ManagementPrincipals.require(principal))));
}
@PutMapping("/v1/studio/releases/{id}")
public ReleaseEditResponse updateRelease(
@AuthenticationPrincipal AuthenticatedPrincipal principal,
@PathVariable("id") UUID id,
@RequestBody ReleaseUpdateRequest body) {
List<String> changeTypes =
body.getChangeTypes() == null ? List.of() : List.copyOf(body.getChangeTypes());
return ManagementResponseMapper.release(
updateRelease.handle(
new UpdateReleaseCommand(
id,
body.getExpectedVersion(),
body.getVersionLabel(),
body.getTitle(),
body.getSummary(),
body.getReleasedOn(),
changeTypes,
body.getReasonMarkdown(),
body.getChangesMarkdown(),
body.getUserImpactMarkdown(),
body.getImplementationImpactMarkdown(),
body.getVerificationMarkdown(),
body.getKnownLimitationsMarkdown(),
ManagementPrincipals.require(principal))));
}
@DeleteMapping("/v1/studio/releases/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void deleteRelease(
@AuthenticationPrincipal AuthenticatedPrincipal principal,
@PathVariable("id") UUID id,
@RequestBody ExpectedVersionRequest body) {
deleteRelease.handle(lifecycle(id, body, principal));
}
@PostMapping("/v1/studio/releases/{id}/publish")
public PublishResponse publishRelease(
@AuthenticationPrincipal AuthenticatedPrincipal principal,
@PathVariable("id") UUID id,
@RequestBody ExpectedVersionRequest body) {
return ManagementResponseMapper.releasePublication(
publishRelease.handle(lifecycle(id, body, principal)));
}
@PostMapping("/v1/studio/releases/{id}/archive")
public ReleaseEditResponse archiveRelease(
@AuthenticationPrincipal AuthenticatedPrincipal principal,
@PathVariable("id") UUID id,
@RequestBody ExpectedVersionRequest body) {
return ManagementResponseMapper.release(archiveRelease.handle(lifecycle(id, body, principal)));
}
private static ReleaseLifecycleCommand lifecycle(
UUID id, ExpectedVersionRequest body, AuthenticatedPrincipal principal) {
return new ReleaseLifecycleCommand(
id, body.getExpectedVersion(), ManagementPrincipals.require(principal));
}
}
@@ -24,11 +24,10 @@ import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
/** /**
* 주제 관리. 계약 {@code listStudioTopics}/{@code createTopic}/{@code updateTopic}/{@code * 주제 관리. 계약 {@code listStudioTopics}/{@code createTopic}/{@code updateTopic}/{@code deleteTopic}.
* deleteTopic}.
* *
* <p>쓰기 응답의 {@code id}/{@code version} 은 서버가 소유한다. 요청 본문에 실려 와도 무시하고 경로와 * <p>쓰기 응답의 {@code id}/{@code version} 은 서버가 소유한다. 요청 본문에 실려 와도 무시하고 경로와 저장소가 정한 값을 쓴다 — 그러지 않으면
* 저장소가 정한 값을 쓴다 — 그러지 않으면 클라이언트가 남의 행을 덮어쓸 수 있다. * 클라이언트가 남의 행을 덮어쓸 수 있다.
*/ */
@RestController @RestController
public class ManagementTopicController { public class ManagementTopicController {
@@ -6,11 +6,18 @@ import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.ProjectEd
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.ProjectIndexItem; import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.ProjectIndexItem;
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.ProjectIndexPage; import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.ProjectIndexPage;
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.PublicationStatus; import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.PublicationStatus;
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.PublishResponse;
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.ReleaseEditResponse;
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.ReleaseIndexItem;
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.ReleaseIndexPage;
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.TopicEdit; import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.TopicEdit;
import dev.caskeleton.application.techlog.management.model.ProjectEditView; import dev.caskeleton.application.techlog.management.model.ProjectEditView;
import dev.caskeleton.application.techlog.management.model.ProjectIndexItemView; import dev.caskeleton.application.techlog.management.model.ProjectIndexItemView;
import dev.caskeleton.application.techlog.management.model.ReleaseEditView;
import dev.caskeleton.application.techlog.management.model.ReleaseIndexItemView;
import dev.caskeleton.application.techlog.management.model.TopicEditView; import dev.caskeleton.application.techlog.management.model.TopicEditView;
import dev.caskeleton.application.techlog.management.service.ListStudioProjectsUseCase; import dev.caskeleton.application.techlog.management.service.ListStudioProjectsUseCase;
import dev.caskeleton.application.techlog.management.service.ListStudioReleasesUseCase;
import java.time.Instant; import java.time.Instant;
import java.time.OffsetDateTime; import java.time.OffsetDateTime;
import java.time.ZoneOffset; import java.time.ZoneOffset;
@@ -43,18 +50,16 @@ public final class ManagementResponseMapper {
return views.stream().map(ManagementResponseMapper::topic).toList(); return views.stream().map(ManagementResponseMapper::topic).toList();
} }
/** /** 발행 상태는 두 타임스탬프에서 파생한다 — 테이블에 상태 컬럼이 따로 없고, 그 둘이 사실의 출처이기 때문이다. */
* 발행 상태는 두 타임스탬프에서 파생한다 — 테이블에 상태 컬럼이 따로 없고, 그 둘이 사실의
* 출처이기 때문이다.
*/
private static PublicationStatus publication(Instant first, Instant last) { private static PublicationStatus publication(Instant first, Instant last) {
// 상태 컬럼이 따로 없으므로 두 타임스탬프에서 파생한다. WITHDRAWN 은 발행 이력이 있는데 // 상태 컬럼이 따로 없으므로 두 타임스탬프에서 파생한다. WITHDRAWN 은 발행 이력이 있는데
// 지금은 내려간 상태인데, 그 구분은 unpublish 를 구현할 때 생긴다 — 지금은 그 경로가 // 지금은 내려간 상태인데, 그 구분은 unpublish 를 구현할 때 생긴다 — 지금은 그 경로가
// 없으므로 발행된 적이 있으면 ACTIVE 다. // 없으므로 발행된 적이 있으면 ACTIVE 다.
PublicationStatus status = PublicationStatus status =
new PublicationStatus( new PublicationStatus(
first == null ? PublicationStatus.StateEnum.NEVER_PUBLISHED first == null
: PublicationStatus.StateEnum.ACTIVE, ? PublicationStatus.StateEnum.NEVER_PUBLISHED
: PublicationStatus.StateEnum.ACTIVE,
false); false);
status.setPublishedAt(at(last)); status.setPublishedAt(at(last));
return status; return status;
@@ -107,15 +112,83 @@ public final class ManagementResponseMapper {
return item; return item;
} }
private static PageMetadata pageMetadata(
int number, int size, int totalElements, int totalPages) {
return new PageMetadata(
number, size, (long) totalElements, totalPages, number > 0, number + 1 < totalPages);
}
public static ProjectIndexPage projects(ListStudioProjectsUseCase.Page page) { public static ProjectIndexPage projects(ListStudioProjectsUseCase.Page page) {
PageMetadata meta = return new ProjectIndexPage(
new PageMetadata( page.items().stream().map(ManagementResponseMapper::indexItem).toList(),
page.number(), pageMetadata(page.number(), page.size(), page.totalElements(), page.totalPages()));
page.size(), }
(long) page.totalElements(),
page.totalPages(), /**
page.number() > 0, * 릴리즈 응답은 {@code ReleaseUpdateRequest} 를 allOf 로 물고 있어 {@code expectedVersion} 을 함께 싣는다. 현재
page.number() + 1 < page.totalPages()); * version 을 그대로 넣는다 — 그래야 편집 화면이 받은 응답을 그대로 다음 저장 요청으로 되돌려보낼 수 있고, 두 값이 갈라질 이유가 없다.
return new ProjectIndexPage(page.items().stream().map(ManagementResponseMapper::indexItem).toList(), meta); */
public static ReleaseEditResponse release(ReleaseEditView view) {
ReleaseEditResponse model =
new ReleaseEditResponse(
view.version(),
view.versionLabel(),
view.title(),
nullToEmpty(view.summary()),
List.copyOf(view.changeTypes()),
nullToEmpty(view.changesMarkdown()),
nullToEmpty(view.verificationMarkdown()),
view.id(),
view.workflowStatus(),
view.version(),
publication(view.firstPublishedAt(), view.lastPublishedAt()));
model.setReleasedOn(view.releasedOn());
model.setReasonMarkdown(view.reasonMarkdown());
model.setUserImpactMarkdown(view.userImpactMarkdown());
model.setImplementationImpactMarkdown(view.implementationImpactMarkdown());
model.setKnownLimitationsMarkdown(view.knownLimitationsMarkdown());
// 관련 자료 링크는 별도 테이블이 소유하고 그 편집 화면이 아직 없다.
model.setRelatedResources(List.of());
return model;
}
public static CreateDraftResponse releaseDraft(ReleaseEditView view) {
return new CreateDraftResponse(
view.id(), CreateDraftResponse.StatusEnum.DRAFT, view.version(), at(view.updatedAt()));
}
/** 릴리즈에는 자체 공개 경로가 있다 — 공개 화면이 {@code version_label} 로 조회하므로 canonical path 도 그것으로 만든다. */
public static PublishResponse releasePublication(ReleaseEditView view) {
return new PublishResponse(
view.id(),
PublishResponse.StatusEnum.PUBLISHED,
PublishResponse.VisibilityEnum.PUBLIC,
"/releases/" + view.versionLabel(),
at(view.lastPublishedAt()),
view.version());
}
private static ReleaseIndexItem releaseIndexItem(ReleaseIndexItemView view) {
ReleaseIndexItem item =
new ReleaseIndexItem(
view.id(),
view.versionLabel(),
view.title(),
view.workflowStatus(),
at(view.updatedAt()),
view.version(),
publication(view.firstPublishedAt(), view.lastPublishedAt()));
item.setReleasedOn(view.releasedOn());
return item;
}
public static ReleaseIndexPage releases(ListStudioReleasesUseCase.Page page) {
return new ReleaseIndexPage(
page.items().stream().map(ManagementResponseMapper::releaseIndexItem).toList(),
pageMetadata(page.number(), page.size(), page.totalElements(), page.totalPages()));
}
private static String nullToEmpty(String value) {
return value == null ? "" : value;
} }
} }
@@ -14,15 +14,15 @@ import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.UUID; import java.util.UUID;
import org.springframework.jdbc.core.simple.JdbcClient; import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Repository;
import tools.jackson.databind.JsonNode; import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper; import tools.jackson.databind.ObjectMapper;
import org.springframework.stereotype.Repository;
/** /**
* Project 편집 저장소. * Project 편집 저장소.
* *
* <p>{@code technology_labels} 는 jsonb 다. 문자열 배열을 그대로 넘기면 드라이버가 Postgres 배열로 * <p>{@code technology_labels} 는 jsonb 다. 문자열 배열을 그대로 넘기면 드라이버가 Postgres 배열로 보내 타입이 어긋나므로, JSON
* 보내 타입이 어긋나므로, JSON 문자열로 직렬화해 {@code ::jsonb} 로 캐스팅한다. * 문자열로 직렬화해 {@code ::jsonb} 로 캐스팅한다.
*/ */
@Repository @Repository
public class JdbcProjectRepositoryAdapter implements ProjectRepositoryPort { public class JdbcProjectRepositoryAdapter implements ProjectRepositoryPort {
@@ -51,12 +51,18 @@ public class JdbcProjectRepositoryAdapter implements ProjectRepositoryPort {
} }
private List<String> labels(String json) { private List<String> labels(String json) {
if (json == null || json.isBlank()) return List.of(); if (json == null || json.isBlank()) {
return List.of();
}
try { try {
JsonNode node = objectMapper.readTree(json); JsonNode node = objectMapper.readTree(json);
if (!node.isArray()) return List.of(); if (!node.isArray()) {
return List.of();
}
List<String> out = new ArrayList<>(); List<String> out = new ArrayList<>();
for (JsonNode item : node) out.add(item.asString("")); for (JsonNode item : node) {
out.add(item.asString(""));
}
return List.copyOf(out); return List.copyOf(out);
} catch (RuntimeException malformed) { } catch (RuntimeException malformed) {
// 열이 jsonb 배열로 제약돼 있으므로 여기 오면 데이터가 아니라 스키마가 어긋난 것이다. // 열이 jsonb 배열로 제약돼 있으므로 여기 오면 데이터가 아니라 스키마가 어긋난 것이다.
@@ -110,7 +116,9 @@ public class JdbcProjectRepositoryAdapter implements ProjectRepositoryPort {
public List<ProjectIndexItemView> listAll(int limit, int offset) { public List<ProjectIndexItemView> listAll(int limit, int offset) {
return jdbcClient return jdbcClient
.sql( .sql(
"SELECT " + INDEX_COLUMNS + " FROM project ORDER BY updated_at DESC, id" "SELECT "
+ INDEX_COLUMNS
+ " FROM project ORDER BY updated_at DESC, id"
+ " LIMIT :limit OFFSET :offset") + " LIMIT :limit OFFSET :offset")
.param("limit", limit) .param("limit", limit)
.param("offset", offset) .param("offset", offset)
@@ -0,0 +1,258 @@
package dev.caskeleton.adapter.outbound.persistence.techlog.management;
import dev.caskeleton.application.techlog.management.command.CreateReleaseCommand;
import dev.caskeleton.application.techlog.management.command.UpdateReleaseCommand;
import dev.caskeleton.application.techlog.management.model.ReleaseEditView;
import dev.caskeleton.application.techlog.management.model.ReleaseIndexItemView;
import dev.caskeleton.application.techlog.management.port.out.ReleaseRepositoryPort;
import dev.caskeleton.application.techlog.management.service.ReleaseDrafts;
import java.sql.Date;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Timestamp;
import java.time.Instant;
import java.time.LocalDate;
import java.util.ArrayList;
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.JsonNode;
import tools.jackson.databind.ObjectMapper;
/**
* Release 편집 저장소.
*
* <p>{@code change_types} 는 jsonb 다 — 문자열 배열을 그대로 넘기면 드라이버가 Postgres 배열로 보내 타입이 어긋나므로, JSON 으로 직렬화해
* {@code ::jsonb} 로 캐스팅한다.
*/
@Repository
public class JdbcReleaseRepositoryAdapter implements ReleaseRepositoryPort {
private static final String EDIT_COLUMNS =
"id, version, version_label, title, summary, released_on, change_types, reason_markdown,"
+ " changes_markdown, user_impact_markdown, implementation_impact_markdown,"
+ " verification_markdown, known_limitations_markdown, workflow_status,"
+ " first_published_at, last_published_at, updated_at";
private static final String INDEX_COLUMNS =
"id, version_label, title, released_on, workflow_status, updated_at, version,"
+ " first_published_at, last_published_at";
private final JdbcClient jdbcClient;
private final ObjectMapper objectMapper;
public JdbcReleaseRepositoryAdapter(JdbcClient jdbcClient, ObjectMapper objectMapper) {
this.jdbcClient = jdbcClient;
this.objectMapper = objectMapper;
}
private static Instant instant(ResultSet rs, String column) throws SQLException {
Timestamp t = rs.getTimestamp(column);
return t == null ? null : t.toInstant();
}
private static LocalDate date(ResultSet rs, String column) throws SQLException {
Date d = rs.getDate(column);
return d == null ? null : d.toLocalDate();
}
private List<String> changeTypes(String json) {
if (json == null || json.isBlank()) {
return List.of();
}
try {
JsonNode node = objectMapper.readTree(json);
if (!node.isArray()) {
return List.of();
}
List<String> out = new ArrayList<>();
for (JsonNode item : node) {
out.add(item.asString(""));
}
return List.copyOf(out);
} catch (RuntimeException malformed) {
// 열이 jsonb 배열로 제약돼 있으므로 여기 오면 데이터가 아니라 스키마가 어긋난 것이다.
// 편집 화면 전체를 막는 대신 빈 목록으로 두고 나머지 필드를 보여준다.
return List.of();
}
}
private String changeTypesJson(List<String> values) {
return objectMapper.writeValueAsString(values == null ? List.of() : values);
}
private ReleaseEditView mapEdit(ResultSet rs, int rowNum) throws SQLException {
return new ReleaseEditView(
rs.getObject("id", UUID.class),
rs.getLong("version"),
rs.getString("version_label"),
rs.getString("title"),
rs.getString("summary"),
date(rs, "released_on"),
changeTypes(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"),
rs.getString("workflow_status"),
instant(rs, "first_published_at"),
instant(rs, "last_published_at"),
instant(rs, "updated_at"));
}
private static ReleaseIndexItemView mapIndex(ResultSet rs, int rowNum) throws SQLException {
return new ReleaseIndexItemView(
rs.getObject("id", UUID.class),
rs.getString("version_label"),
rs.getString("title"),
date(rs, "released_on"),
rs.getString("workflow_status"),
instant(rs, "updated_at"),
rs.getLong("version"),
instant(rs, "first_published_at"),
instant(rs, "last_published_at"));
}
@Override
public List<ReleaseIndexItemView> listAll(int limit, int offset) {
// 발행된 것이 위로, 그다음 최근 수정 순. 변경 기록은 버전 순서가 의미를 갖지만
// version_label 은 자유 문자열이라 정렬 키로 쓸 수 없다.
return jdbcClient
.sql(
"SELECT "
+ INDEX_COLUMNS
+ " FROM release"
+ " ORDER BY released_on DESC NULLS LAST, updated_at DESC, id"
+ " LIMIT :limit OFFSET :offset")
.param("limit", limit)
.param("offset", offset)
.query(JdbcReleaseRepositoryAdapter::mapIndex)
.list();
}
@Override
public int countAll() {
return Optional.ofNullable(
jdbcClient.sql("SELECT COUNT(*) FROM release").query(Integer.class).single())
.orElse(0);
}
@Override
public Optional<ReleaseEditView> find(UUID id) {
return jdbcClient
.sql("SELECT " + EDIT_COLUMNS + " FROM release WHERE id = :id")
.param("id", id)
.query(this::mapEdit)
.optional();
}
@Override
public ReleaseEditView create(CreateReleaseCommand command) {
UUID id = UUID.randomUUID();
// version_label 은 NOT NULL UNIQUE 이고 초안에는 아직 버전이 없다. id 에서 파생한 자리표시자를
// 넣어 유일성을 만족시키고, 발행이 그것이 실제 버전으로 바뀌었는지 확인한다.
String placeholder = ReleaseDrafts.PLACEHOLDER_PREFIX + id.toString().substring(0, 8);
jdbcClient
.sql(
"INSERT INTO release (id, version_label, title, created_by, updated_by)"
+ " VALUES (:id, :label, :title, :actor, :actor)")
.param("id", id)
.param("label", placeholder)
.param("title", command.title().trim())
.param("actor", command.actor())
.update();
return find(id).orElseThrow();
}
@Override
public Optional<ReleaseEditView> update(UpdateReleaseCommand command) {
int updated =
jdbcClient
.sql(
"UPDATE release SET version_label = :label, title = :title, summary = :summary,"
+ " released_on = :releasedOn,"
+ " change_types = CAST(:changeTypes AS jsonb),"
+ " reason_markdown = :reasonMd, changes_markdown = :changesMd,"
+ " user_impact_markdown = :userImpactMd,"
+ " implementation_impact_markdown = :implImpactMd,"
+ " verification_markdown = :verificationMd,"
+ " known_limitations_markdown = :limitationsMd,"
+ " version = version + 1, updated_at = now(), updated_by = :actor"
+ " WHERE id = :id AND version = :expected")
.param("id", command.id())
.param("expected", command.expectedVersion())
.param("label", command.versionLabel().trim())
.param("title", command.title().trim())
.param("summary", nullToEmpty(command.summary()))
.param(
"releasedOn",
command.releasedOn() == null ? null : Date.valueOf(command.releasedOn()))
.param("changeTypes", changeTypesJson(command.changeTypes()))
.param("reasonMd", nullToEmpty(command.reasonMarkdown()))
.param("changesMd", nullToEmpty(command.changesMarkdown()))
.param("userImpactMd", nullToEmpty(command.userImpactMarkdown()))
.param("implImpactMd", nullToEmpty(command.implementationImpactMarkdown()))
.param("verificationMd", nullToEmpty(command.verificationMarkdown()))
.param("limitationsMd", nullToEmpty(command.knownLimitationsMarkdown()))
.param("actor", command.actor())
.update();
return updated == 0 ? Optional.empty() : find(command.id());
}
private static String nullToEmpty(String value) {
return value == null ? "" : value;
}
@Override
public int delete(UUID id, long expectedVersion) {
return jdbcClient
.sql("DELETE FROM release WHERE id = :id AND version = :expected")
.param("id", id)
.param("expected", expectedVersion)
.update();
}
@Override
public boolean versionLabelTaken(String versionLabel, UUID exceptId) {
return Boolean.TRUE.equals(
jdbcClient
.sql(
"SELECT EXISTS (SELECT 1 FROM release WHERE version_label = :label"
+ " AND (CAST(:except AS uuid) IS NULL OR id <> CAST(:except AS uuid)))")
.param("label", versionLabel)
.param("except", exceptId)
.query(Boolean.class)
.single());
}
@Override
public Optional<ReleaseEditView> transition(
UUID id,
long expectedVersion,
String workflowStatus,
boolean stampPublication,
String actor) {
// COALESCE 가 최초 발행 시각을 지킨다 — 재발행은 last_published_at 만 옮긴다.
String publicationClause =
stampPublication
? " first_published_at = COALESCE(first_published_at, now()), last_published_at = now(),"
: "";
int updated =
jdbcClient
.sql(
"UPDATE release SET workflow_status = :status,"
+ publicationClause
+ " version = version + 1, updated_at = now(), updated_by = :actor"
+ " WHERE id = :id AND version = :expected")
.param("id", id)
.param("expected", expectedVersion)
.param("status", workflowStatus)
.param("actor", actor)
.update();
return updated == 0 ? Optional.empty() : find(id);
}
}
@@ -15,15 +15,13 @@ import org.springframework.stereotype.Repository;
/** /**
* Topic 편집 저장소. * Topic 편집 저장소.
* *
* <p>정규화된 이름은 애플리케이션이 아니라 여기서 계산해 컬럼에 넣는다. {@code * <p>정규화된 이름은 애플리케이션이 아니라 여기서 계산해 컬럼에 넣는다. {@code uq_topic_normalized_name} 이 그 컬럼 위에 있으므로, 계산이 한
* uq_topic_normalized_name} 이 그 컬럼 위에 있으므로, 계산이 한 곳에만 있어야 사전 확인과 제약이 * 곳에만 있어야 사전 확인과 제약이 같은 값을 본다.
* 같은 값을 본다.
*/ */
@Repository @Repository
public class JdbcTopicRepositoryAdapter implements TopicRepositoryPort { public class JdbcTopicRepositoryAdapter implements TopicRepositoryPort {
private static final String COLUMNS = private static final String COLUMNS = "id, name, slug, description, scope, status, version";
"id, name, slug, description, scope, status, version";
private final JdbcClient jdbcClient; private final JdbcClient jdbcClient;
@@ -118,10 +116,7 @@ public class JdbcTopicRepositoryAdapter implements TopicRepositoryPort {
.update(); .update();
} }
/** /** 참조 확인. 주제를 가리키는 곳이 늘어나면 여기도 늘어야 한다 — 빠뜨리면 외래키가 대신 막고 500 이 나간다. */
* 참조 확인. 주제를 가리키는 곳이 늘어나면 여기도 늘어야 한다 — 빠뜨리면 외래키가 대신 막고
* 500 이 나간다.
*/
@Override @Override
public boolean isReferenced(UUID id) { public boolean isReferenced(UUID id) {
return Boolean.TRUE.equals( return Boolean.TRUE.equals(
@@ -1,22 +1,30 @@
package dev.caskeleton.bootstrap.techlog; package dev.caskeleton.bootstrap.techlog;
import dev.caskeleton.application.techlog.management.port.out.ProjectRepositoryPort; import dev.caskeleton.application.techlog.management.port.out.ProjectRepositoryPort;
import dev.caskeleton.application.techlog.management.port.out.ReleaseRepositoryPort;
import dev.caskeleton.application.techlog.management.port.out.TopicRepositoryPort; import dev.caskeleton.application.techlog.management.port.out.TopicRepositoryPort;
import dev.caskeleton.application.techlog.management.service.ArchiveReleaseUseCase;
import dev.caskeleton.application.techlog.management.service.CreateProjectUseCase; import dev.caskeleton.application.techlog.management.service.CreateProjectUseCase;
import dev.caskeleton.application.techlog.management.service.CreateReleaseUseCase;
import dev.caskeleton.application.techlog.management.service.DeleteProjectUseCase; import dev.caskeleton.application.techlog.management.service.DeleteProjectUseCase;
import dev.caskeleton.application.techlog.management.service.DeleteReleaseUseCase;
import dev.caskeleton.application.techlog.management.service.DeleteTopicUseCase; import dev.caskeleton.application.techlog.management.service.DeleteTopicUseCase;
import dev.caskeleton.application.techlog.management.service.GetProjectForEditUseCase; import dev.caskeleton.application.techlog.management.service.GetProjectForEditUseCase;
import dev.caskeleton.application.techlog.management.service.GetReleaseForEditUseCase;
import dev.caskeleton.application.techlog.management.service.ListStudioProjectsUseCase; import dev.caskeleton.application.techlog.management.service.ListStudioProjectsUseCase;
import dev.caskeleton.application.techlog.management.service.ListStudioReleasesUseCase;
import dev.caskeleton.application.techlog.management.service.ListStudioTopicsUseCase; import dev.caskeleton.application.techlog.management.service.ListStudioTopicsUseCase;
import dev.caskeleton.application.techlog.management.service.PublishReleaseUseCase;
import dev.caskeleton.application.techlog.management.service.SaveTopicUseCase; import dev.caskeleton.application.techlog.management.service.SaveTopicUseCase;
import dev.caskeleton.application.techlog.management.service.UpdateProjectUseCase; import dev.caskeleton.application.techlog.management.service.UpdateProjectUseCase;
import dev.caskeleton.application.techlog.management.service.UpdateReleaseUseCase;
import dev.caskeleton.application.transaction.TransactionPort; import dev.caskeleton.application.transaction.TransactionPort;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
/** /**
* 관리 표면(`studio-management-v1.yaml`)의 use case 배선. 지금은 topics 4개와 projects 5개만 있다 — * 관리 표면(`studio-management-v1.yaml`)의 use case 배선. 지금은 topics 4개와 projects 5개만 있다 — 그 둘이 문서 작성을 막고
* 그 둘이 문서 작성을 막고 있던 선행 조건이기 때문이다. * 있던 선행 조건이기 때문이다.
*/ */
@Configuration(proxyBeanMethods = false) @Configuration(proxyBeanMethods = false)
public class TechLogManagementConfig { public class TechLogManagementConfig {
@@ -62,4 +70,41 @@ public class TechLogManagementConfig {
DeleteProjectUseCase deleteProjectUseCase(ProjectRepositoryPort projects, TransactionPort tx) { DeleteProjectUseCase deleteProjectUseCase(ProjectRepositoryPort projects, TransactionPort tx) {
return new DeleteProjectUseCase(projects, tx); return new DeleteProjectUseCase(projects, tx);
} }
@Bean
ListStudioReleasesUseCase listStudioReleasesUseCase(
ReleaseRepositoryPort releases, TransactionPort tx) {
return new ListStudioReleasesUseCase(releases, tx);
}
@Bean
GetReleaseForEditUseCase getReleaseForEditUseCase(
ReleaseRepositoryPort releases, TransactionPort tx) {
return new GetReleaseForEditUseCase(releases, tx);
}
@Bean
CreateReleaseUseCase createReleaseUseCase(ReleaseRepositoryPort releases, TransactionPort tx) {
return new CreateReleaseUseCase(releases, tx);
}
@Bean
UpdateReleaseUseCase updateReleaseUseCase(ReleaseRepositoryPort releases, TransactionPort tx) {
return new UpdateReleaseUseCase(releases, tx);
}
@Bean
DeleteReleaseUseCase deleteReleaseUseCase(ReleaseRepositoryPort releases, TransactionPort tx) {
return new DeleteReleaseUseCase(releases, tx);
}
@Bean
PublishReleaseUseCase publishReleaseUseCase(ReleaseRepositoryPort releases, TransactionPort tx) {
return new PublishReleaseUseCase(releases, tx);
}
@Bean
ArchiveReleaseUseCase archiveReleaseUseCase(ReleaseRepositoryPort releases, TransactionPort tx) {
return new ArchiveReleaseUseCase(releases, tx);
}
} }
@@ -21,12 +21,12 @@ import org.junit.jupiter.api.Test;
import org.yaml.snakeyaml.Yaml; import org.yaml.snakeyaml.Yaml;
/** /**
* {@code PublicErrorRegistryTest} 가 {@code PublicError} 에 대해 하는 일을 {@link ManagementError} 에 * {@code PublicErrorRegistryTest} 가 {@code PublicError} 에 대해 하는 일을 {@link ManagementError} 에 대해 한다
* 대해 한다 — row 존재 / 값 드리프트 / client-safe 문구, 그리고 계약 code 집합 대조. * — row 존재 / 값 드리프트 / client-safe 문구, 그리고 계약 code 집합 대조.
* *
* <p>계약은 12종을 열거하는데 enum 은 11종이다. 나머지 {@code INTERNAL_ERROR} 는 스켈레톤 공통 처리기가 * <p>계약은 12종을 열거하는데 enum 은 11종이다. 나머지 {@code INTERNAL_ERROR} 는 스켈레톤 공통 처리기가 소유하며({@link
* 소유하며({@link OperationalError#INTERNAL_ERROR}) 여기서 재선언하지 않는다 — 그쪽은 {@code * OperationalError#INTERNAL_ERROR}) 여기서 재선언하지 않는다 — 그쪽은 {@code retryable=true} 라 같은 code 를 두 곳에서
* retryable=true} 라 같은 code 를 두 곳에서 선언하면 레지스트리가 어느 값을 따라야 할지 알 수 없다. * 선언하면 레지스트리가 어느 값을 따라야 할지 알 수 없다.
*/ */
class ManagementErrorRegistryTest { class ManagementErrorRegistryTest {
@@ -75,11 +75,14 @@ class ManagementErrorRegistryTest {
for (ManagementError error : ManagementError.values()) { for (ManagementError error : ManagementError.values()) {
Map<String, Object> row = registryRowsByCode.get(error.code()); Map<String, Object> row = registryRowsByCode.get(error.code());
assertThat(row).as("registry row for %s", error.code()).isNotNull(); assertThat(row).as("registry row for %s", error.code()).isNotNull();
assertThat(row.get("category")).as("category of %s", error.code()) assertThat(row.get("category"))
.as("category of %s", error.code())
.isEqualTo(error.category().name()); .isEqualTo(error.category().name());
assertThat(((Number) row.get("http_status")).intValue()).as("http_status of %s", error.code()) assertThat(((Number) row.get("http_status")).intValue())
.as("http_status of %s", error.code())
.isEqualTo(error.httpStatus()); .isEqualTo(error.httpStatus());
assertThat(row.get("retryable")).as("retryable of %s", error.code()) assertThat(row.get("retryable"))
.as("retryable of %s", error.code())
.isEqualTo(error.retryable()); .isEqualTo(error.retryable());
} }
} }
@@ -7,13 +7,13 @@ import dev.caskeleton.shared.error.Category;
* 관리 계약(`studio-management-v1.yaml`)의 `ApiError.code` enum. 계약과 1:1이며 여기서 코드를 늘리거나 줄이면 계약과 * 관리 계약(`studio-management-v1.yaml`)의 `ApiError.code` enum. 계약과 1:1이며 여기서 코드를 늘리거나 줄이면 계약과
* `docs/registries/error-codes.yaml`을 함께 고쳐야 한다. * `docs/registries/error-codes.yaml`을 함께 고쳐야 한다.
* *
* <p>{@link StudioError} 와 합치지 않는다. 두 계약이 각자의 code 집합을 열거하고 있고, 한쪽에만 있는 코드를 다른 쪽 응답으로 * <p>{@link StudioError} 와 합치지 않는다. 두 계약이 각자의 code 집합을 열거하고 있고, 한쪽에만 있는 코드를 다른 쪽 응답으로 낼 수 있게 되면 그
* 낼 수 있게 되면 그 순간 두 계약 모두 거짓이 된다. * 순간 두 계약 모두 거짓이 된다.
* *
* <p>계약의 {@code ApiError.code} 는 12종인데 여기는 11종이다. 나머지 하나 {@code INTERNAL_ERROR} 는 이 기능이 * <p>계약의 {@code ApiError.code} 는 15종인데 여기는 14종이다. 나머지 하나 {@code INTERNAL_ERROR} 는 이 기능이 아니라 스켈레톤 공통
* 아니라 스켈레톤 공통 처리기가 내는 코드({@code OperationalError.INTERNAL_ERROR}) 이고, 같은 code 를 두 enum 이 * 처리기가 내는 코드({@code OperationalError.INTERNAL_ERROR}) 이고, 같은 code 를 두 enum 이 각자 status 와 retryable
* 각자 status 와 retryable 을 달고 선언하면 레지스트리가 어느 쪽을 따라야 할지 알 수 없다 — 실제로 그쪽은 * 을 달고 선언하면 레지스트리가 어느 쪽을 따라야 할지 알 수 없다 — 실제로 그쪽은 {@code retryable=true} 다. {@code PublicError} 가 같은
* {@code retryable=true} 다. {@code PublicError} 가 같은 이유로 같은 선택을 했다. * 이유로 같은 선택을 했다.
*/ */
public enum ManagementError implements ApiErrorCode { public enum ManagementError implements ApiErrorCode {
AUTHENTICATION_REQUIRED(Category.AUTH, 401, false), AUTHENTICATION_REQUIRED(Category.AUTH, 401, false),
@@ -26,7 +26,10 @@ public enum ManagementError implements ApiErrorCode {
TOPIC_IN_USE(Category.CONFLICT, 409, false), TOPIC_IN_USE(Category.CONFLICT, 409, false),
PROJECT_NOT_FOUND(Category.NOT_FOUND, 404, false), PROJECT_NOT_FOUND(Category.NOT_FOUND, 404, false),
PROJECT_SLUG_TAKEN(Category.CONFLICT, 409, false), PROJECT_SLUG_TAKEN(Category.CONFLICT, 409, false),
PROJECT_IN_USE(Category.CONFLICT, 409, false); PROJECT_IN_USE(Category.CONFLICT, 409, false),
RELEASE_NOT_FOUND(Category.NOT_FOUND, 404, false),
RELEASE_VERSION_TAKEN(Category.CONFLICT, 409, false),
RELEASE_NOT_PUBLISHABLE(Category.CONFLICT, 409, false);
private final Category category; private final Category category;
private final int httpStatus; private final int httpStatus;
@@ -4,8 +4,8 @@ import dev.caskeleton.shared.error.ApiErrorCarrier;
import dev.caskeleton.shared.error.ApiErrorCode; import dev.caskeleton.shared.error.ApiErrorCode;
/** /**
* 관리 use case 가 던지는 유일한 실패 표현. {@link StudioException} 과 같은 모양이되 code 집합만 다르다 — 전송 계층은 * 관리 use case 가 던지는 유일한 실패 표현. {@link StudioException} 과 같은 모양이되 code 집합만 다르다 — 전송 계층은 {@link
* {@link ApiErrorCarrier} 만 보므로 두 예외를 따로 처리할 필요가 없다. * ApiErrorCarrier} 만 보므로 두 예외를 따로 처리할 필요가 없다.
*/ */
public final class ManagementException extends RuntimeException implements ApiErrorCarrier { public final class ManagementException extends RuntimeException implements ApiErrorCarrier {
@@ -1,6 +1,4 @@
package dev.caskeleton.application.techlog.management.command; package dev.caskeleton.application.techlog.management.command;
/** /** 계약 {@code CreateDraftRequest} — 제목 하나로 초안을 연다. 나머지 필드는 열린 뒤 편집으로 채운다. */
* 계약 {@code CreateDraftRequest} — 제목 하나로 초안을 연다. 나머지 필드는 열린 뒤 편집으로 채운다.
*/
public record CreateProjectCommand(String title, String actor) {} public record CreateProjectCommand(String title, String actor) {}
@@ -0,0 +1,4 @@
package dev.caskeleton.application.techlog.management.command;
/** 계약 {@code CreateDraftRequest}. 제목 하나로 초안을 연다. */
public record CreateReleaseCommand(String title, String actor) {}
@@ -0,0 +1,9 @@
package dev.caskeleton.application.techlog.management.command;
import java.util.UUID;
/**
* 계약 {@code ExpectedVersionRequest}. delete / publish / archive 가 같은 모양이라 하나로 둔다 — 셋 다 "이 버전의 이
* 릴리즈에 상태 전이를 건다"는 같은 요청이고, 세 개의 동일한 record 를 두면 셋이 갈라졌을 때 어느 것이 옳은지 알 수 없다.
*/
public record ReleaseLifecycleCommand(UUID id, long expectedVersion, String actor) {}
@@ -3,12 +3,11 @@ package dev.caskeleton.application.techlog.management.command;
import java.util.UUID; import java.util.UUID;
/** /**
* 생성과 수정이 같은 명령을 쓴다. 계약이 두 경우 모두 {@code TopicEdit} 를 본문으로 받기 때문이고, * 생성과 수정이 같은 명령을 쓴다. 계약이 두 경우 모두 {@code TopicEdit} 를 본문으로 받기 때문이고, 구분은 {@code id} 의 유무다 — {@code
* 구분은 {@code id} 의 유무다 — {@code null} 이면 생성이다. * null} 이면 생성이다.
* *
* <p>{@code expectedVersion} 은 수정에서만 의미가 있다. 생성에 값이 와도 무시하는 대신 거절하지 * <p>{@code expectedVersion} 은 수정에서만 의미가 있다. 생성에 값이 와도 무시하는 대신 거절하지 않는 이유는, 계약이 그 필드를 optional 로 두고
* 않는 이유는, 계약이 그 필드를 optional 로 두고 있어 클라이언트가 보내는 것이 위반이 아니기 * 있어 클라이언트가 보내는 것이 위반이 아니기 때문이다.
* 때문이다.
*/ */
public record SaveTopicCommand( public record SaveTopicCommand(
UUID id, UUID id,
@@ -0,0 +1,27 @@
package dev.caskeleton.application.techlog.management.command;
import java.time.LocalDate;
import java.util.List;
import java.util.UUID;
/** 계약 {@code ReleaseUpdateRequest}. */
public record UpdateReleaseCommand(
UUID id,
long expectedVersion,
String versionLabel,
String title,
String summary,
LocalDate releasedOn,
List<String> changeTypes,
String reasonMarkdown,
String changesMarkdown,
String userImpactMarkdown,
String implementationImpactMarkdown,
String verificationMarkdown,
String knownLimitationsMarkdown,
String actor) {
public UpdateReleaseCommand {
changeTypes = changeTypes == null ? List.of() : List.copyOf(changeTypes);
}
}
@@ -7,8 +7,8 @@ import java.util.UUID;
/** /**
* 계약 {@code ProjectEditResponse}. * 계약 {@code ProjectEditResponse}.
* *
* <p>{@code topicIds}/{@code documentLinks}/{@code questionLinks} 는 링크 테이블이 소유한다. (가) * <p>{@code topicIds}/{@code documentLinks}/{@code questionLinks} 는 링크 테이블이 소유한다. (가) 범위에서는 그 편집
* 범위에서는 그 편집 화면이 없으므로 항상 비어 있고, 링크를 다루는 화면이 생길 때 같은 뷰에 채운다. * 화면이 없으므로 항상 비어 있고, 링크를 다루는 화면이 생길 때 같은 뷰에 채운다.
*/ */
public record ProjectEditView( public record ProjectEditView(
UUID id, UUID id,
@@ -0,0 +1,36 @@
package dev.caskeleton.application.techlog.management.model;
import java.time.Instant;
import java.time.LocalDate;
import java.util.List;
import java.util.UUID;
/**
* 계약 {@code ReleaseEditResponse}.
*
* <p>본문이 마크다운 여섯 구획으로 나뉘어 있는 것은 릴리즈 노트가 하나의 산문이 아니라 정해진 질문에 답하는 기록이기 때문이다 — 왜 바꿨나, 무엇을 바꿨나, 사용자에게
* 무엇이 달라지나, 구현에 무엇이 남나, 어떻게 검증했나, 무엇을 아직 못 했나. 한 덩어리 text 였다면 그중 무엇이 빠졌는지 아무도 알 수 없다.
*/
public record ReleaseEditView(
UUID id,
long version,
String versionLabel,
String title,
String summary,
LocalDate releasedOn,
List<String> changeTypes,
String reasonMarkdown,
String changesMarkdown,
String userImpactMarkdown,
String implementationImpactMarkdown,
String verificationMarkdown,
String knownLimitationsMarkdown,
String workflowStatus,
Instant firstPublishedAt,
Instant lastPublishedAt,
Instant updatedAt) {
public ReleaseEditView {
changeTypes = changeTypes == null ? List.of() : List.copyOf(changeTypes);
}
}
@@ -0,0 +1,17 @@
package dev.caskeleton.application.techlog.management.model;
import java.time.Instant;
import java.time.LocalDate;
import java.util.UUID;
/** 계약 {@code ReleaseIndexItem}. 목록 행은 본문 마크다운을 싣지 않는다. */
public record ReleaseIndexItemView(
UUID id,
String versionLabel,
String title,
LocalDate releasedOn,
String workflowStatus,
Instant updatedAt,
long version,
Instant firstPublishedAt,
Instant lastPublishedAt) {}
@@ -6,9 +6,8 @@ import java.util.UUID;
/** /**
* 계약 {@code TopicEdit}. * 계약 {@code TopicEdit}.
* *
* <p>{@code featuredReferenceId}/{@code featuredCaseIds} 는 Reference/Case 가 존재해야 채워지는 * <p>{@code featuredReferenceId}/{@code featuredCaseIds} 는 Reference/Case 가 존재해야 채워지는 큐레이션 필드다. 그
* 큐레이션 필드다. 그 관리 화면이 아직 없으므로 지금은 항상 비어 있고, 계약이 요구하지 않으므로 * 관리 화면이 아직 없으므로 지금은 항상 비어 있고, 계약이 요구하지 않으므로 비어 있는 것이 정상이다.
* 비어 있는 것이 정상이다.
*/ */
public record TopicEditView( public record TopicEditView(
UUID id, UUID id,
@@ -0,0 +1,34 @@
package dev.caskeleton.application.techlog.management.port.out;
import dev.caskeleton.application.techlog.management.command.CreateReleaseCommand;
import dev.caskeleton.application.techlog.management.command.UpdateReleaseCommand;
import dev.caskeleton.application.techlog.management.model.ReleaseEditView;
import dev.caskeleton.application.techlog.management.model.ReleaseIndexItemView;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
/** Release 의 편집용 읽기/쓰기. 공개 조회는 {@code publicsite} 쪽 포트가 따로 소유한다. */
public interface ReleaseRepositoryPort {
List<ReleaseIndexItemView> listAll(int limit, int offset);
int countAll();
Optional<ReleaseEditView> find(UUID id);
ReleaseEditView create(CreateReleaseCommand command);
Optional<ReleaseEditView> update(UpdateReleaseCommand command);
int delete(UUID id, long expectedVersion);
boolean versionLabelTaken(String versionLabel, UUID exceptId);
/**
* {@code workflow_status} 를 옮기고 발행 타임스탬프를 갱신한다. {@code firstPublishedAt} 은 비어 있을 때만 채운다 — 재발행이 최초
* 발행 시각을 덮으면 "언제부터 공개된 기록인가"를 잃는다.
*/
Optional<ReleaseEditView> transition(
UUID id, long expectedVersion, String workflowStatus, boolean stampPublication, String actor);
}
@@ -0,0 +1,59 @@
package dev.caskeleton.application.techlog.management.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.ManagementError;
import dev.caskeleton.application.techlog.error.ManagementException;
import dev.caskeleton.application.techlog.management.command.ReleaseLifecycleCommand;
import dev.caskeleton.application.techlog.management.model.ReleaseEditView;
import dev.caskeleton.application.techlog.management.port.out.ReleaseRepositoryPort;
import dev.caskeleton.application.techlog.studio.service.StudioPermissions;
import dev.caskeleton.application.transaction.TransactionMode;
import dev.caskeleton.application.transaction.TransactionPort;
import java.util.Objects;
/** {@code archiveRelease}. 공개에서 내린다. 발행 타임스탬프는 건드리지 않는다 — 언제 공개됐던 기록인지는 내린 뒤에도 사실로 남는다. */
@RequiresPermission(StudioPermissions.WRITE)
@UseCaseCapability(
transactionMode = TransactionMode.WRITE,
idempotency = Idempotency.NOT_IDEMPOTENT,
repositoryAccess = RepositoryAccess.WRITE_REPOSITORY)
public class ArchiveReleaseUseCase {
private final ReleaseRepositoryPort releases;
private final TransactionPort transactions;
public ArchiveReleaseUseCase(ReleaseRepositoryPort releases, TransactionPort transactions) {
this.releases = Objects.requireNonNull(releases, "releases");
this.transactions = Objects.requireNonNull(transactions, "transactions");
}
public ReleaseEditView handle(ReleaseLifecycleCommand command) {
Objects.requireNonNull(command, "command");
return transactions.inWrite(
() -> {
ReleaseEditView current =
releases
.find(command.id())
.orElseThrow(
() ->
ManagementException.of(
ManagementError.RELEASE_NOT_FOUND, "no such release"));
if ("ARCHIVED".equals(current.workflowStatus())) {
throw ManagementException.of(
ManagementError.RELEASE_NOT_PUBLISHABLE, "the release is already archived");
}
return releases
.transition(
command.id(), command.expectedVersion(), "ARCHIVED", false, command.actor())
.orElseThrow(
() ->
ManagementException.withDetails(
ManagementError.VERSION_CONFLICT,
"the release changed since it was loaded",
new SaveTopicUseCase.VersionConflict(current.version())));
});
}
}
@@ -15,8 +15,8 @@ import dev.caskeleton.application.transaction.TransactionPort;
import java.util.Objects; import java.util.Objects;
/** /**
* {@code createProject}. 제목 하나로 초안을 연다 — slug 는 비워 둔다. 테이블이 slug 를 nullable 로 두고 * {@code createProject}. 제목 하나로 초안을 연다 — slug 는 비워 둔다. 테이블이 slug 를 nullable 로 두고 UNIQUE 만 걸어 두었기
* UNIQUE 만 걸어 두었기 때문에 빈 초안 여러 개가 공존할 수 있고, 발행 시점에 slug 가 요구된다. * 때문에 빈 초안 여러 개가 공존할 수 있고, 발행 시점에 slug 가 요구된다.
*/ */
@RequiresPermission(StudioPermissions.WRITE) @RequiresPermission(StudioPermissions.WRITE)
@UseCaseCapability( @UseCaseCapability(
@@ -0,0 +1,47 @@
package dev.caskeleton.application.techlog.management.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.ManagementError;
import dev.caskeleton.application.techlog.error.ManagementException;
import dev.caskeleton.application.techlog.management.command.CreateReleaseCommand;
import dev.caskeleton.application.techlog.management.model.ReleaseEditView;
import dev.caskeleton.application.techlog.management.port.out.ReleaseRepositoryPort;
import dev.caskeleton.application.techlog.studio.service.StudioPermissions;
import dev.caskeleton.application.transaction.TransactionMode;
import dev.caskeleton.application.transaction.TransactionPort;
import java.util.Objects;
/**
* {@code createRelease}. 제목 하나로 초안을 연다.
*
* <p>{@code version_label} 은 NOT NULL UNIQUE 인데 초안에는 아직 버전이 없다. 어댑터가 자리표시자를 넣고, 발행이 그것을 실제 버전으로 바꿔
* 놓았는지 확인한다 ({@link PublishReleaseUseCase}) — 열을 nullable 로 푸는 대신 이렇게 하는 이유는, 그러면 발행된 릴리즈가 버전 없이
* 공개될 수 있는 창이 열리기 때문이다.
*/
@RequiresPermission(StudioPermissions.WRITE)
@UseCaseCapability(
transactionMode = TransactionMode.WRITE,
idempotency = Idempotency.NOT_IDEMPOTENT,
repositoryAccess = RepositoryAccess.WRITE_REPOSITORY)
public class CreateReleaseUseCase {
private final ReleaseRepositoryPort releases;
private final TransactionPort transactions;
public CreateReleaseUseCase(ReleaseRepositoryPort releases, TransactionPort transactions) {
this.releases = Objects.requireNonNull(releases, "releases");
this.transactions = Objects.requireNonNull(transactions, "transactions");
}
public ReleaseEditView handle(CreateReleaseCommand command) {
Objects.requireNonNull(command, "command");
if (command.title() == null || command.title().isBlank()) {
throw ManagementException.of(
ManagementError.REQUEST_VALIDATION_FAILED, "title must not be blank");
}
return transactions.inWrite(() -> releases.create(command));
}
}
@@ -0,0 +1,61 @@
package dev.caskeleton.application.techlog.management.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.ManagementError;
import dev.caskeleton.application.techlog.error.ManagementException;
import dev.caskeleton.application.techlog.management.command.ReleaseLifecycleCommand;
import dev.caskeleton.application.techlog.management.model.ReleaseEditView;
import dev.caskeleton.application.techlog.management.port.out.ReleaseRepositoryPort;
import dev.caskeleton.application.techlog.studio.service.StudioPermissions;
import dev.caskeleton.application.transaction.TransactionMode;
import dev.caskeleton.application.transaction.TransactionPort;
import java.util.Objects;
/**
* {@code deleteRelease}. 발행된 릴리즈는 지우지 않는다 — 공개된 변경 기록이 조용히 사라지면 그것을 읽고 링크한 쪽에서 무슨 일이 있었는지 알 방법이 없다.
* 내리려면 archive 다.
*/
@RequiresPermission(StudioPermissions.WRITE)
@UseCaseCapability(
transactionMode = TransactionMode.WRITE,
idempotency = Idempotency.NOT_IDEMPOTENT,
repositoryAccess = RepositoryAccess.WRITE_REPOSITORY)
public class DeleteReleaseUseCase {
private final ReleaseRepositoryPort releases;
private final TransactionPort transactions;
public DeleteReleaseUseCase(ReleaseRepositoryPort releases, TransactionPort transactions) {
this.releases = Objects.requireNonNull(releases, "releases");
this.transactions = Objects.requireNonNull(transactions, "transactions");
}
public void handle(ReleaseLifecycleCommand command) {
Objects.requireNonNull(command, "command");
transactions.inWrite(
() -> {
ReleaseEditView current =
releases
.find(command.id())
.orElseThrow(
() ->
ManagementException.of(
ManagementError.RELEASE_NOT_FOUND, "no such release"));
if ("PUBLISHED".equals(current.workflowStatus())) {
throw ManagementException.of(
ManagementError.RELEASE_NOT_PUBLISHABLE,
"a published release cannot be deleted; archive it instead");
}
if (releases.delete(command.id(), command.expectedVersion()) == 0) {
throw ManagementException.withDetails(
ManagementError.VERSION_CONFLICT,
"the release changed since it was loaded",
new SaveTopicUseCase.VersionConflict(current.version()));
}
return null;
});
}
}
@@ -17,9 +17,8 @@ import java.util.Objects;
/** /**
* {@code deleteTopic}. * {@code deleteTopic}.
* *
* <p>참조가 있으면 지우지 않고 {@code TOPIC_IN_USE} 로 거절한다. 외래키를 CASCADE 로 두지 않는 이유는, * <p>참조가 있으면 지우지 않고 {@code TOPIC_IN_USE} 로 거절한다. 외래키를 CASCADE 로 두지 않는 이유는, 주제를 지웠다는 이유로 그 주제를 쓰던
* 주제를 지웠다는 이유로 그 주제를 쓰던 문서의 분류가 조용히 사라지면 안 되기 때문이다 — 지우려면 * 문서의 분류가 조용히 사라지면 안 되기 때문이다 — 지우려면 먼저 그 문서들을 옮기라는 뜻이다.
* 먼저 그 문서들을 옮기라는 뜻이다.
*/ */
@RequiresPermission(StudioPermissions.WRITE) @RequiresPermission(StudioPermissions.WRITE)
@UseCaseCapability( @UseCaseCapability(
@@ -0,0 +1,42 @@
package dev.caskeleton.application.techlog.management.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.ManagementError;
import dev.caskeleton.application.techlog.error.ManagementException;
import dev.caskeleton.application.techlog.management.model.ReleaseEditView;
import dev.caskeleton.application.techlog.management.port.out.ReleaseRepositoryPort;
import dev.caskeleton.application.techlog.studio.service.StudioPermissions;
import dev.caskeleton.application.transaction.TransactionMode;
import dev.caskeleton.application.transaction.TransactionPort;
import java.util.Objects;
import java.util.UUID;
@RequiresPermission(StudioPermissions.READ)
@UseCaseCapability(
transactionMode = TransactionMode.READ_ONLY,
idempotency = Idempotency.IDEMPOTENT,
repositoryAccess = RepositoryAccess.READ_REPOSITORY)
public class GetReleaseForEditUseCase {
private final ReleaseRepositoryPort releases;
private final TransactionPort transactions;
public GetReleaseForEditUseCase(ReleaseRepositoryPort releases, TransactionPort transactions) {
this.releases = Objects.requireNonNull(releases, "releases");
this.transactions = Objects.requireNonNull(transactions, "transactions");
}
public ReleaseEditView handle(UUID id) {
return transactions.inRead(
() ->
releases
.find(id)
.orElseThrow(
() ->
ManagementException.of(
ManagementError.RELEASE_NOT_FOUND, "no such release")));
}
}
@@ -22,8 +22,7 @@ public class ListStudioProjectsUseCase {
private final ProjectRepositoryPort projects; private final ProjectRepositoryPort projects;
private final TransactionPort transactions; private final TransactionPort transactions;
public ListStudioProjectsUseCase( public ListStudioProjectsUseCase(ProjectRepositoryPort projects, TransactionPort transactions) {
ProjectRepositoryPort projects, TransactionPort transactions) {
this.projects = Objects.requireNonNull(projects, "projects"); this.projects = Objects.requireNonNull(projects, "projects");
this.transactions = Objects.requireNonNull(transactions, "transactions"); this.transactions = Objects.requireNonNull(transactions, "transactions");
} }
@@ -42,9 +41,5 @@ public class ListStudioProjectsUseCase {
} }
public record Page( public record Page(
List<ProjectIndexItemView> items, List<ProjectIndexItemView> items, int number, int size, int totalElements, int totalPages) {}
int number,
int size,
int totalElements,
int totalPages) {}
} }
@@ -0,0 +1,44 @@
package dev.caskeleton.application.techlog.management.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.management.model.ReleaseIndexItemView;
import dev.caskeleton.application.techlog.management.port.out.ReleaseRepositoryPort;
import dev.caskeleton.application.techlog.studio.service.StudioPermissions;
import dev.caskeleton.application.transaction.TransactionMode;
import dev.caskeleton.application.transaction.TransactionPort;
import java.util.List;
import java.util.Objects;
@RequiresPermission(StudioPermissions.READ)
@UseCaseCapability(
transactionMode = TransactionMode.READ_ONLY,
idempotency = Idempotency.IDEMPOTENT,
repositoryAccess = RepositoryAccess.READ_REPOSITORY)
public class ListStudioReleasesUseCase {
private final ReleaseRepositoryPort releases;
private final TransactionPort transactions;
public ListStudioReleasesUseCase(ReleaseRepositoryPort releases, TransactionPort transactions) {
this.releases = Objects.requireNonNull(releases, "releases");
this.transactions = Objects.requireNonNull(transactions, "transactions");
}
public Page handle(int page, int size) {
int safeSize = size <= 0 ? 20 : Math.min(size, 100);
int safePage = Math.max(page, 0);
return transactions.inRead(
() -> {
int total = releases.countAll();
List<ReleaseIndexItemView> items = releases.listAll(safeSize, safePage * safeSize);
int totalPages = safeSize == 0 ? 0 : (total + safeSize - 1) / safeSize;
return new Page(items, safePage, safeSize, total, totalPages);
});
}
public record Page(
List<ReleaseIndexItemView> items, int number, int size, int totalElements, int totalPages) {}
}
@@ -0,0 +1,100 @@
package dev.caskeleton.application.techlog.management.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.ManagementError;
import dev.caskeleton.application.techlog.error.ManagementException;
import dev.caskeleton.application.techlog.management.command.ReleaseLifecycleCommand;
import dev.caskeleton.application.techlog.management.model.ReleaseEditView;
import dev.caskeleton.application.techlog.management.port.out.ReleaseRepositoryPort;
import dev.caskeleton.application.techlog.studio.service.StudioPermissions;
import dev.caskeleton.application.transaction.TransactionMode;
import dev.caskeleton.application.transaction.TransactionPort;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
/**
* {@code publishRelease}. {@code workflow_status = 'PUBLISHED'} 가 공개 조회의 유일한 조건이므로 (공개 어댑터의 WHERE
* 절), 이 전이가 곧 공개다.
*
* <p>그래서 발행 전에 계약이 필수로 선언한 것들이 실제로 채워져 있는지 여기서 확인한다. 저장은 초안을 비워 둔 채로도 허용해야 하고 — 아니면 한 번에 다 쓰지 않으면
* 저장을 못 한다 — 공개는 그럴 수 없다. 두 시점의 요구가 다르다.
*/
@RequiresPermission(StudioPermissions.WRITE)
@UseCaseCapability(
transactionMode = TransactionMode.WRITE,
idempotency = Idempotency.NOT_IDEMPOTENT,
repositoryAccess = RepositoryAccess.WRITE_REPOSITORY)
public class PublishReleaseUseCase {
private final ReleaseRepositoryPort releases;
private final TransactionPort transactions;
public PublishReleaseUseCase(ReleaseRepositoryPort releases, TransactionPort transactions) {
this.releases = Objects.requireNonNull(releases, "releases");
this.transactions = Objects.requireNonNull(transactions, "transactions");
}
public ReleaseEditView handle(ReleaseLifecycleCommand command) {
Objects.requireNonNull(command, "command");
return transactions.inWrite(
() -> {
ReleaseEditView current =
releases
.find(command.id())
.orElseThrow(
() ->
ManagementException.of(
ManagementError.RELEASE_NOT_FOUND, "no such release"));
List<String> missing = missingForPublication(current);
if (!missing.isEmpty()) {
throw ManagementException.of(
ManagementError.RELEASE_NOT_PUBLISHABLE,
"the release is not ready to publish; fill in " + missing);
}
return releases
.transition(
command.id(), command.expectedVersion(), "PUBLISHED", true, command.actor())
.orElseThrow(
() ->
ManagementException.withDetails(
ManagementError.VERSION_CONFLICT,
"the release changed since it was loaded",
new SaveTopicUseCase.VersionConflict(current.version())));
});
}
/** 계약 {@code ReleaseUpdateRequest} 의 required 목록과 같다. */
private static List<String> missingForPublication(ReleaseEditView release) {
List<String> missing = new ArrayList<>();
if (ReleaseDrafts.isPlaceholder(release.versionLabel())) {
missing.add("versionLabel");
}
if (isBlank(release.title())) {
missing.add("title");
}
if (isBlank(release.summary())) {
missing.add("summary");
}
if (release.changeTypes().isEmpty()) {
missing.add("changeTypes");
}
if (isBlank(release.changesMarkdown())) {
missing.add("changesMarkdown");
}
if (isBlank(release.verificationMarkdown())) {
missing.add("verificationMarkdown");
}
if (release.releasedOn() == null) {
missing.add("releasedOn");
}
return List.copyOf(missing);
}
private static boolean isBlank(String value) {
return value == null || value.isBlank();
}
}
@@ -0,0 +1,18 @@
package dev.caskeleton.application.techlog.management.service;
/**
* 초안 릴리즈의 자리표시자 버전 라벨.
*
* <p>{@code release.version_label} 은 NOT NULL UNIQUE 이고 초안에는 아직 버전이 없다. 어댑터는 이 접두사로 시작하는 라벨을 넣고,
* 발행은 그것이 실제 버전으로 바뀌었는지 확인한다. 두 곳이 같은 문자열을 각자 적어 두면 한쪽만 고쳐졌을 때 자리표시자가 그대로 공개된다.
*/
public final class ReleaseDrafts {
public static final String PLACEHOLDER_PREFIX = "draft-";
private ReleaseDrafts() {}
public static boolean isPlaceholder(String versionLabel) {
return versionLabel == null || versionLabel.startsWith(PLACEHOLDER_PREFIX);
}
}
@@ -18,13 +18,12 @@ import java.util.Objects;
/** /**
* {@code createTopic} / {@code updateTopic}. * {@code createTopic} / {@code updateTopic}.
* *
* <p>이름과 slug 의 중복은 DB 제약이 이미 막고 있다. 그래도 여기서 먼저 확인하는 이유는 계약이 * <p>이름과 slug 의 중복은 DB 제약이 이미 막고 있다. 그래도 여기서 먼저 확인하는 이유는 계약이 {@code TOPIC_NAME_TAKEN}/{@code
* {@code TOPIC_NAME_TAKEN}/{@code TOPIC_SLUG_TAKEN} 을 구분해서 요구하기 때문이다 — 제약 위반을 * TOPIC_SLUG_TAKEN} 을 구분해서 요구하기 때문이다 — 제약 위반을 잡아 코드로 되돌리면 어느 제약이었는지는 드라이버 메시지 문자열에서 읽어야 하고, 그건 벤더가
* 잡아 코드로 되돌리면 어느 제약이었는지는 드라이버 메시지 문자열에서 읽어야 하고, 그건 벤더가
* 바뀌면 조용히 깨진다. * 바뀌면 조용히 깨진다.
* *
* <p>이름 비교는 정규화(소문자·공백 정리) 후에 한다. 테이블의 {@code uq_topic_normalized_name} 이 * <p>이름 비교는 정규화(소문자·공백 정리) 후에 한다. 테이블의 {@code uq_topic_normalized_name} 이 같은 규칙이므로, 여기서만 다르게 정규화하면
* 같은 규칙이므로, 여기서만 다르게 정규화하면 사전 확인을 통과한 요청이 제약에서 터진다. * 사전 확인을 통과한 요청이 제약에서 터진다.
*/ */
@RequiresPermission(StudioPermissions.WRITE) @RequiresPermission(StudioPermissions.WRITE)
@UseCaseCapability( @UseCaseCapability(
@@ -73,8 +72,7 @@ public class SaveTopicUseCase {
.find(command.id()) .find(command.id())
.orElseThrow( .orElseThrow(
() -> () ->
ManagementException.of( ManagementException.of(ManagementError.TOPIC_NOT_FOUND, "no such topic"));
ManagementError.TOPIC_NOT_FOUND, "no such topic"));
return topics return topics
.update(command) .update(command)
.orElseThrow( .orElseThrow(
@@ -26,7 +26,12 @@ public class UpdateProjectUseCase {
/** 테이블의 CHECK 제약과 같은 집합. 여기서 먼저 거절해야 422 로 나가고, 아니면 DB 오류가 500 이 된다. */ /** 테이블의 CHECK 제약과 같은 집합. 여기서 먼저 거절해야 422 로 나가고, 아니면 DB 오류가 500 이 된다. */
private static final Set<String> PHASES = private static final Set<String> PHASES =
Set.of( Set.of(
"RESEARCH", "DESIGN", "IMPLEMENTATION", "VERIFICATION", "MAINTENANCE", "PAUSED", "RESEARCH",
"DESIGN",
"IMPLEMENTATION",
"VERIFICATION",
"MAINTENANCE",
"PAUSED",
"COMPLETED"); "COMPLETED");
private static final Set<String> VISIBILITIES = Set.of("PRIVATE", "UNLISTED", "PUBLIC"); private static final Set<String> VISIBILITIES = Set.of("PRIVATE", "UNLISTED", "PUBLIC");
@@ -0,0 +1,82 @@
package dev.caskeleton.application.techlog.management.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.ManagementError;
import dev.caskeleton.application.techlog.error.ManagementException;
import dev.caskeleton.application.techlog.management.command.UpdateReleaseCommand;
import dev.caskeleton.application.techlog.management.model.ReleaseEditView;
import dev.caskeleton.application.techlog.management.port.out.ReleaseRepositoryPort;
import dev.caskeleton.application.techlog.studio.service.StudioPermissions;
import dev.caskeleton.application.transaction.TransactionMode;
import dev.caskeleton.application.transaction.TransactionPort;
import java.util.Objects;
import java.util.Set;
/** {@code updateRelease}. */
@RequiresPermission(StudioPermissions.WRITE)
@UseCaseCapability(
transactionMode = TransactionMode.WRITE,
idempotency = Idempotency.NOT_IDEMPOTENT,
repositoryAccess = RepositoryAccess.WRITE_REPOSITORY)
public class UpdateReleaseUseCase {
/** 변경 유형. 계약은 자유 문자열이지만 목록 화면이 이 값으로 묶어 보여주므로, 열린 집합으로 두면 같은 뜻의 라벨이 여러 개 생겨 묶임이 무의미해진다. */
private static final Set<String> CHANGE_TYPES =
Set.of("FEATURE", "FIX", "REFACTOR", "DOCS", "INFRA", "BREAKING");
private final ReleaseRepositoryPort releases;
private final TransactionPort transactions;
public UpdateReleaseUseCase(ReleaseRepositoryPort releases, TransactionPort transactions) {
this.releases = Objects.requireNonNull(releases, "releases");
this.transactions = Objects.requireNonNull(transactions, "transactions");
}
public ReleaseEditView handle(UpdateReleaseCommand command) {
Objects.requireNonNull(command, "command");
requireText(command.versionLabel(), "versionLabel");
requireText(command.title(), "title");
for (String changeType : command.changeTypes()) {
if (!CHANGE_TYPES.contains(changeType)) {
throw ManagementException.of(
ManagementError.REQUEST_VALIDATION_FAILED,
"changeTypes must each be one of " + CHANGE_TYPES.stream().sorted().toList());
}
}
return transactions.inWrite(
() -> {
ReleaseEditView current =
releases
.find(command.id())
.orElseThrow(
() ->
ManagementException.of(
ManagementError.RELEASE_NOT_FOUND, "no such release"));
String label = command.versionLabel().trim();
if (releases.versionLabelTaken(label, command.id())) {
throw ManagementException.of(
ManagementError.RELEASE_VERSION_TAKEN,
"another release already uses this version label");
}
return releases
.update(command)
.orElseThrow(
() ->
ManagementException.withDetails(
ManagementError.VERSION_CONFLICT,
"the release changed since it was loaded",
new SaveTopicUseCase.VersionConflict(current.version())));
});
}
private static void requireText(String value, String field) {
if (value == null || value.isBlank()) {
throw ManagementException.of(
ManagementError.REQUEST_VALIDATION_FAILED, field + " must not be blank");
}
}
}
+3 -1
View File
@@ -101,7 +101,9 @@
"application-core", "application-core",
"shared-contract" "shared-contract"
], ],
"runtime_memberships": [] "runtime_memberships": [
"app-bootstrap"
]
}, },
{ {
"id": "adapter-outbound-cache-redis", "id": "adapter-outbound-cache-redis",
+148 -100
View File
@@ -5,11 +5,11 @@ info:
description: |- description: |-
⚠ 봉투 결정(ADR-006) 부분 반영 — 이 파일에는 두 모양이 공존한다. ⚠ 봉투 결정(ADR-006) 부분 반영 — 이 파일에는 두 모양이 공존한다.
topics 4개 projects 5개 studio-v1.yaml과 같은 방식으로 변환했다 topics 4개, projects 5개, releases 7개를 studio-v1.yaml과 같은 방식으로
(`application/json` + ErrorEnvelope / <Payload>Envelope). 그 9개가 첫 구현 변환했다 (`application/json` + ErrorEnvelope / <Payload>Envelope). 그 16개가
대상이자 소비자이기 때문이다. 구현된 것이자 소비자가 있는 것이기 때문이다.
나머지 70개는 아직 bare payload + `application/problem+json` + ProblemDetails 나머지 63개는 아직 bare payload + `application/problem+json` + ProblemDetails
다. 구현에 착수할 때 같은 방식으로 따라온다 — 소비자가 없는 operation을 미리 다. 구현에 착수할 때 같은 방식으로 따라온다 — 소비자가 없는 operation을 미리
변환해 두면 검증되지 않은 모양이 계약에 고정된다. 변환해 두면 검증되지 않은 모양이 계약에 고정된다.
@@ -3630,49 +3630,49 @@ paths:
content: content:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/CreateDraftResponse' $ref: '#/components/schemas/CreateDraftResponseEnvelope'
'400': '400':
description: Bad Request description: Bad Request
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
'401': '401':
description: Unauthorized description: Unauthorized
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
'403': '403':
description: Forbidden description: Forbidden
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
'404': '404':
description: Not Found description: Not Found
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
'409': '409':
description: Conflict description: Conflict
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
'422': '422':
description: Unprocessable Content description: Unprocessable Content
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
'500': '500':
description: Internal Server Error description: Internal Server Error
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
requestBody: requestBody:
required: true required: true
content: content:
@@ -3705,37 +3705,37 @@ paths:
content: content:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/ReleaseIndexPage' $ref: '#/components/schemas/ReleaseIndexPageEnvelope'
'400': '400':
description: Bad Request description: Bad Request
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
'401': '401':
description: Unauthorized description: Unauthorized
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
'403': '403':
description: Forbidden description: Forbidden
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
'404': '404':
description: Not Found description: Not Found
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
'500': '500':
description: Internal Server Error description: Internal Server Error
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
security: security:
- sessionCookie: [] - sessionCookie: []
/api/v1/studio/releases/{id}: /api/v1/studio/releases/{id}:
@@ -3756,37 +3756,37 @@ paths:
content: content:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/ReleaseEditResponse' $ref: '#/components/schemas/ReleaseEditResponseEnvelope'
'400': '400':
description: Bad Request description: Bad Request
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
'401': '401':
description: Unauthorized description: Unauthorized
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
'403': '403':
description: Forbidden description: Forbidden
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
'404': '404':
description: Not Found description: Not Found
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
'500': '500':
description: Internal Server Error description: Internal Server Error
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
security: security:
- sessionCookie: [] - sessionCookie: []
put: put:
@@ -3807,49 +3807,49 @@ paths:
content: content:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/ReleaseEditResponse' $ref: '#/components/schemas/ReleaseEditResponseEnvelope'
'400': '400':
description: Bad Request description: Bad Request
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
'401': '401':
description: Unauthorized description: Unauthorized
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
'403': '403':
description: Forbidden description: Forbidden
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
'404': '404':
description: Not Found description: Not Found
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
'409': '409':
description: Conflict description: Conflict
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
'422': '422':
description: Unprocessable Content description: Unprocessable Content
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
'500': '500':
description: Internal Server Error description: Internal Server Error
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
requestBody: requestBody:
required: true required: true
content: content:
@@ -3876,45 +3876,45 @@ paths:
'400': '400':
description: Bad Request description: Bad Request
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
'401': '401':
description: Unauthorized description: Unauthorized
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
'403': '403':
description: Forbidden description: Forbidden
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
'404': '404':
description: Not Found description: Not Found
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
'409': '409':
description: Conflict description: Conflict
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
'422': '422':
description: Unprocessable Content description: Unprocessable Content
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
'500': '500':
description: Internal Server Error description: Internal Server Error
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
requestBody: requestBody:
required: true required: true
content: content:
@@ -3942,49 +3942,49 @@ paths:
content: content:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/PublishResponse' $ref: '#/components/schemas/PublishResponseEnvelope'
'400': '400':
description: Bad Request description: Bad Request
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
'401': '401':
description: Unauthorized description: Unauthorized
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
'403': '403':
description: Forbidden description: Forbidden
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
'404': '404':
description: Not Found description: Not Found
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
'409': '409':
description: Conflict description: Conflict
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
'422': '422':
description: Unprocessable Content description: Unprocessable Content
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
'500': '500':
description: Internal Server Error description: Internal Server Error
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
requestBody: requestBody:
required: true required: true
content: content:
@@ -4012,49 +4012,49 @@ paths:
content: content:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/ReleaseEditResponse' $ref: '#/components/schemas/ReleaseEditResponseEnvelope'
'400': '400':
description: Bad Request description: Bad Request
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
'401': '401':
description: Unauthorized description: Unauthorized
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
'403': '403':
description: Forbidden description: Forbidden
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
'404': '404':
description: Not Found description: Not Found
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
'409': '409':
description: Conflict description: Conflict
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
'422': '422':
description: Unprocessable Content description: Unprocessable Content
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
'500': '500':
description: Internal Server Error description: Internal Server Error
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
requestBody: requestBody:
required: true required: true
content: content:
@@ -5267,6 +5267,9 @@ components:
- PROJECT_NOT_FOUND - PROJECT_NOT_FOUND
- PROJECT_SLUG_TAKEN - PROJECT_SLUG_TAKEN
- PROJECT_IN_USE - PROJECT_IN_USE
- RELEASE_NOT_FOUND
- RELEASE_VERSION_TAKEN
- RELEASE_NOT_PUBLISHABLE
- INTERNAL_ERROR - INTERNAL_ERROR
description: '`INTERNAL_ERROR` 는 이 기능이 아니라 스켈레톤의 공통 처리기가 내는 코드다. 계약이 그것까지 열거해야 500 응답이 계약을 벗어나지 않는다.' description: '`INTERNAL_ERROR` 는 이 기능이 아니라 스켈레톤의 공통 처리기가 내는 코드다. 계약이 그것까지 열거해야 500 응답이 계약을 벗어나지 않는다.'
category: category:
@@ -5392,6 +5395,51 @@ components:
$ref: '#/components/schemas/ProjectIndexPage' $ref: '#/components/schemas/ProjectIndexPage'
meta: meta:
$ref: '#/components/schemas/ResponseMeta' $ref: '#/components/schemas/ResponseMeta'
ReleaseIndexPageEnvelope:
type: object
additionalProperties: false
required:
- success
- data
- meta
properties:
success:
type: boolean
const: true
data:
$ref: '#/components/schemas/ReleaseIndexPage'
meta:
$ref: '#/components/schemas/ResponseMeta'
ReleaseEditResponseEnvelope:
type: object
additionalProperties: false
required:
- success
- data
- meta
properties:
success:
type: boolean
const: true
data:
$ref: '#/components/schemas/ReleaseEditResponse'
meta:
$ref: '#/components/schemas/ResponseMeta'
PublishResponseEnvelope:
type: object
additionalProperties: false
required:
- success
- data
- meta
properties:
success:
type: boolean
const: true
data:
$ref: '#/components/schemas/PublishResponse'
meta:
$ref: '#/components/schemas/ResponseMeta'
ProjectEditResponseEnvelope: ProjectEditResponseEnvelope:
type: object type: object
additionalProperties: false additionalProperties: false