feat: Studio 응답 봉투를 전송 경계에서 언랩한다
studio-v1.yaml v3.0.0(ADR-006)에 맞춰 계약을 재생성하고, 성공은
{success,data,meta}, 실패는 {success,error,meta} 봉투를 전송 경계에서
언랩하는 envelopeData/envelopeError validator를 도입한다. 앱·도메인
계층은 기존과 같은 payload/ProblemDetails 모양을 계속 받고,
StudioGateway 포트 시그니처는 무변경이다.
- tech-log-studio-contract-contribution.ts: envelopeData/envelopeError
도입, 18개 operation의 outputValidator를 passthrough에서 envelopeData로
교체
- studio-error-mapping.ts: 봉투 오류의 status(항상 0)를
outcome.metadata.status로 덮는다. SafeResponseMetadata.status가
실제 필드명이며(httpStatus 아님) PROBLEM outcome에서 필수 필드다
- contract.ts: 삭제된 ProblemDetails 생성 스키마를 손으로 유지 — 앱
계층·mock 게이트웨이가 그 모양을 계속 소비한다
- asset-upload-transport.ts: multipart 업로드는 일반 계약 런타임을
거치지 않는 별도 seam이지만 같은 wire 봉투를 쓴다 — envelopeData/
envelopeError를 재사용해 이 경로도 언랩한다 (브리프 파일 목록 밖의
발견, report에 기록)
- 테스트: 신규 studio-envelope-unwrap.test.ts(TDD) + 봉투 뼈대를 직접
만드는 기존 테스트(asset-upload-transport, studio-csrf-composition,
contract-generation)를 봉투 형태로 갱신
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
d84b57bb3f
commit
25a6b63d27
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"packageId": "@tech-log/studio-contract",
|
||||
"version": "2.0.0",
|
||||
"digest": "sha256:99f54f56ea0c582eafdbdf9be5653e3384bef0a1b08bff67f3147ee0292019ea",
|
||||
"sourceRevision": "ce2e748",
|
||||
"version": "3.0.0",
|
||||
"digest": "sha256:25ed2e9f5b76bfeacf66e5a84ea8daf05979faa0b54e4980781f02115bbf0627",
|
||||
"sourceRevision": "3e8a164",
|
||||
"operationIds": [
|
||||
"getStudioSession",
|
||||
"getStudioDashboard",
|
||||
|
||||
@@ -23,7 +23,35 @@ export type PublicationListItem = Schemas["PublicationListItem"];
|
||||
export type PublicationPage = Schemas["PublicationPage"];
|
||||
export type PublicationSnapshot = Schemas["PublicationSnapshot"];
|
||||
export type CatalogPage = Schemas["CatalogPage"];
|
||||
export type ProblemDetails = Schemas["ProblemDetails"];
|
||||
/**
|
||||
* ADR-006으로 canonical 계약의 오류가 봉투(`ErrorEnvelope`/`ApiError`)로
|
||||
* 바뀌면서 `ProblemDetails` 스키마 자체는 canonical에서 삭제됐다. 앱 계층
|
||||
* (`StudioGatewayError`, mock 게이트웨이들, `asset-upload-transport.ts`)은
|
||||
* 여전히 이 평평한(flat) 모양을 소비한다 — 전송 경계
|
||||
* (`tech-log-studio-contract-contribution.ts`의 `envelopeError`)가 `ApiError`를
|
||||
* 여기로 옮기고, mock은 이 모양을 직접 구성한다. 그래서 더 이상 생성된
|
||||
* 스키마에서 뽑지 않고 여기서 손으로 유지한다.
|
||||
*/
|
||||
export type ProblemDetails = {
|
||||
/** Format: uri-reference */
|
||||
type: string;
|
||||
title: string;
|
||||
status: number;
|
||||
detail: string;
|
||||
code: Schemas["ApiError"]["code"];
|
||||
category?: Schemas["ApiError"]["category"];
|
||||
retryable?: boolean;
|
||||
details?: unknown;
|
||||
/** Format: uri-reference */
|
||||
instance?: string;
|
||||
traceId?: string;
|
||||
fieldErrors?: Schemas["FieldError"][];
|
||||
latestDocument?: Schemas["WorkingCopyDetail"];
|
||||
latestPublication?: Schemas["PublicationAggregate"];
|
||||
conflictingFields?: string[];
|
||||
} & {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
export type PublicRenderModel = Schemas["PublicRenderModel"];
|
||||
export type Asset = Schemas["Asset"];
|
||||
export type AssetDetail = Schemas["AssetDetail"];
|
||||
|
||||
@@ -89,8 +89,8 @@ export interface paths {
|
||||
* @description 편집 가능한 content field만 저장한다. lifecycle 전이는 명시적인 Domain
|
||||
* Action이 담당한다. 저장은 Public Projection을 변경하지 않는다.
|
||||
*
|
||||
* `expectedVersion` 불일치는 `VERSION_CONFLICT`이며 응답 `ProblemDetails`의
|
||||
* `latestDocument`로 현재 상태를 함께 제공한다.
|
||||
* `expectedVersion` 불일치는 `VERSION_CONFLICT`이며 응답 `error.details`
|
||||
* (`VersionConflictDetails`)의 `latestDocument`로 현재 상태를 함께 제공한다.
|
||||
*
|
||||
*/
|
||||
put: operations["saveStudioDocument"];
|
||||
@@ -382,6 +382,128 @@ export interface paths {
|
||||
export type webhooks = Record<string, never>;
|
||||
export interface components {
|
||||
schemas: {
|
||||
ResponseMeta: {
|
||||
requestId: string;
|
||||
traceId: string;
|
||||
correlationId?: string | null;
|
||||
/** @description Studio는 body 안 cursor 페이지네이션을 쓰므로 항상 null이다 */
|
||||
page?: null;
|
||||
};
|
||||
ApiError: {
|
||||
/** @enum {string} */
|
||||
code: "AUTHENTICATION_REQUIRED" | "STUDIO_ACCESS_DENIED" | "DOCUMENT_NOT_FOUND" | "VERSION_CONFLICT" | "REQUEST_VALIDATION_FAILED" | "VALIDATION_FAILED" | "VALIDATION_STALE" | "PREVIEW_NOT_FOUND" | "PREVIEW_STALE" | "PREVIEW_EXPIRED" | "PUBLICATION_NOT_FOUND" | "PUBLICATION_CONFLICT" | "PUBLICATION_EVENT_NOT_FOUND" | "PUBLICATION_SNAPSHOT_NOT_FOUND" | "WARNING_ACKNOWLEDGEMENT_REQUIRED" | "IDEMPOTENCY_KEY_REUSED" | "ASSET_NOT_FOUND" | "ASSET_NOT_READY" | "ASSET_IN_USE" | "ASSET_QUARANTINED" | "PAYLOAD_TOO_LARGE" | "UNSUPPORTED_MEDIA_TYPE" | "STUDIO_UNAVAILABLE";
|
||||
/** @enum {string} */
|
||||
category: "VALIDATION" | "AUTH" | "AUTHZ" | "NOT_FOUND" | "CONFLICT" | "RATE_LIMIT" | "TRANSIENT_DEPENDENCY" | "PERMANENT_DEPENDENCY" | "DATA_INTEGRITY" | "INTERNAL";
|
||||
message: string;
|
||||
retryable: boolean;
|
||||
details?: components["schemas"]["ValidationErrorDetails"] | components["schemas"]["VersionConflictDetails"] | components["schemas"]["PublicationConflictDetails"] | null;
|
||||
};
|
||||
ErrorEnvelope: {
|
||||
/** @constant */
|
||||
success: false;
|
||||
error: components["schemas"]["ApiError"];
|
||||
meta: components["schemas"]["ResponseMeta"];
|
||||
};
|
||||
ValidationErrorDetails: {
|
||||
fieldErrors: components["schemas"]["FieldError"][];
|
||||
};
|
||||
VersionConflictDetails: {
|
||||
latestDocument: components["schemas"]["WorkingCopyDetail"];
|
||||
conflictingFields?: string[];
|
||||
};
|
||||
PublicationConflictDetails: {
|
||||
latestPublication: components["schemas"]["PublicationAggregate"];
|
||||
};
|
||||
StudioSessionEnvelope: {
|
||||
/** @constant */
|
||||
success: true;
|
||||
data: components["schemas"]["StudioSession"];
|
||||
meta: components["schemas"]["ResponseMeta"];
|
||||
};
|
||||
StudioDashboardEnvelope: {
|
||||
/** @constant */
|
||||
success: true;
|
||||
data: components["schemas"]["StudioDashboard"];
|
||||
meta: components["schemas"]["ResponseMeta"];
|
||||
};
|
||||
DocumentPageEnvelope: {
|
||||
/** @constant */
|
||||
success: true;
|
||||
data: components["schemas"]["DocumentPage"];
|
||||
meta: components["schemas"]["ResponseMeta"];
|
||||
};
|
||||
WorkingCopyDetailEnvelope: {
|
||||
/** @constant */
|
||||
success: true;
|
||||
data: components["schemas"]["WorkingCopyDetail"];
|
||||
meta: components["schemas"]["ResponseMeta"];
|
||||
};
|
||||
WorkingCopyEnvelope: {
|
||||
/** @constant */
|
||||
success: true;
|
||||
data: components["schemas"]["WorkingCopy"];
|
||||
meta: components["schemas"]["ResponseMeta"];
|
||||
};
|
||||
ValidationReportEnvelope: {
|
||||
/** @constant */
|
||||
success: true;
|
||||
data: components["schemas"]["ValidationReport"];
|
||||
meta: components["schemas"]["ResponseMeta"];
|
||||
};
|
||||
PreviewDetailEnvelope: {
|
||||
/** @constant */
|
||||
success: true;
|
||||
data: components["schemas"]["PreviewDetail"];
|
||||
meta: components["schemas"]["ResponseMeta"];
|
||||
};
|
||||
PublicPreviewEnvelope: {
|
||||
/** @constant */
|
||||
success: true;
|
||||
data: components["schemas"]["PublicPreview"];
|
||||
meta: components["schemas"]["ResponseMeta"];
|
||||
};
|
||||
PublishResultEnvelope: {
|
||||
/** @constant */
|
||||
success: true;
|
||||
data: components["schemas"]["PublishResult"];
|
||||
meta: components["schemas"]["ResponseMeta"];
|
||||
};
|
||||
PublicationPageEnvelope: {
|
||||
/** @constant */
|
||||
success: true;
|
||||
data: components["schemas"]["PublicationPage"];
|
||||
meta: components["schemas"]["ResponseMeta"];
|
||||
};
|
||||
PublicationSnapshotEnvelope: {
|
||||
/** @constant */
|
||||
success: true;
|
||||
data: components["schemas"]["PublicationSnapshot"];
|
||||
meta: components["schemas"]["ResponseMeta"];
|
||||
};
|
||||
CatalogPageEnvelope: {
|
||||
/** @constant */
|
||||
success: true;
|
||||
data: components["schemas"]["CatalogPage"];
|
||||
meta: components["schemas"]["ResponseMeta"];
|
||||
};
|
||||
AssetPageEnvelope: {
|
||||
/** @constant */
|
||||
success: true;
|
||||
data: components["schemas"]["AssetPage"];
|
||||
meta: components["schemas"]["ResponseMeta"];
|
||||
};
|
||||
AssetDetailEnvelope: {
|
||||
/** @constant */
|
||||
success: true;
|
||||
data: components["schemas"]["AssetDetail"];
|
||||
meta: components["schemas"]["ResponseMeta"];
|
||||
};
|
||||
AssetEnvelope: {
|
||||
/** @constant */
|
||||
success: true;
|
||||
data: components["schemas"]["Asset"];
|
||||
meta: components["schemas"]["ResponseMeta"];
|
||||
};
|
||||
StudioSession: {
|
||||
authenticated: boolean;
|
||||
displayName: string;
|
||||
@@ -1246,25 +1368,6 @@ export interface components {
|
||||
path: string;
|
||||
message: string;
|
||||
};
|
||||
ProblemDetails: {
|
||||
/** Format: uri-reference */
|
||||
type: string;
|
||||
title: string;
|
||||
status: number;
|
||||
detail: string;
|
||||
/** @enum {string} */
|
||||
code: "AUTHENTICATION_REQUIRED" | "STUDIO_ACCESS_DENIED" | "DOCUMENT_NOT_FOUND" | "VERSION_CONFLICT" | "REQUEST_VALIDATION_FAILED" | "VALIDATION_FAILED" | "VALIDATION_STALE" | "PREVIEW_NOT_FOUND" | "PREVIEW_STALE" | "PREVIEW_EXPIRED" | "PUBLICATION_NOT_FOUND" | "PUBLICATION_CONFLICT" | "PUBLICATION_EVENT_NOT_FOUND" | "PUBLICATION_SNAPSHOT_NOT_FOUND" | "WARNING_ACKNOWLEDGEMENT_REQUIRED" | "IDEMPOTENCY_KEY_REUSED" | "ASSET_NOT_FOUND" | "ASSET_NOT_READY" | "ASSET_IN_USE" | "ASSET_QUARANTINED" | "PAYLOAD_TOO_LARGE" | "UNSUPPORTED_MEDIA_TYPE" | "STUDIO_UNAVAILABLE";
|
||||
/** Format: uri-reference */
|
||||
instance?: string;
|
||||
traceId?: string;
|
||||
fieldErrors?: components["schemas"]["FieldError"][];
|
||||
latestDocument?: components["schemas"]["WorkingCopyDetail"];
|
||||
latestPublication?: components["schemas"]["PublicationAggregate"];
|
||||
conflictingFields?: string[];
|
||||
retryable?: boolean;
|
||||
} & {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Malformed request */
|
||||
@@ -1273,7 +1376,7 @@ export interface components {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Authentication required */
|
||||
@@ -1282,7 +1385,7 @@ export interface components {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Studio access denied */
|
||||
@@ -1291,7 +1394,7 @@ export interface components {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Document not found */
|
||||
@@ -1300,7 +1403,7 @@ export interface components {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Document or preview not found */
|
||||
@@ -1309,7 +1412,7 @@ export interface components {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Publication not found */
|
||||
@@ -1318,7 +1421,7 @@ export interface components {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Publication event or snapshot not found */
|
||||
@@ -1327,7 +1430,7 @@ export interface components {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Asset not found */
|
||||
@@ -1336,7 +1439,7 @@ export interface components {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Command conflicts with current state, freshness, or idempotency.
|
||||
@@ -1348,7 +1451,7 @@ export interface components {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Request validation failed */
|
||||
@@ -1357,7 +1460,7 @@ export interface components {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Preview 생성이 도메인 규칙으로 거절되었다 */
|
||||
@@ -1366,7 +1469,7 @@ export interface components {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Publication validation이 실패했다.
|
||||
@@ -1379,7 +1482,7 @@ export interface components {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Asset metadata 변경이 거절되었다 */
|
||||
@@ -1388,7 +1491,7 @@ export interface components {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Upload exceeds the configured size limit */
|
||||
@@ -1397,7 +1500,7 @@ export interface components {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Unsupported media type */
|
||||
@@ -1406,7 +1509,7 @@ export interface components {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Studio unavailable */
|
||||
@@ -1415,7 +1518,7 @@ export interface components {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
};
|
||||
@@ -1470,7 +1573,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["StudioSession"];
|
||||
"application/json": components["schemas"]["StudioSessionEnvelope"];
|
||||
};
|
||||
};
|
||||
401: components["responses"]["AuthenticationRequired"];
|
||||
@@ -1493,7 +1596,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["StudioDashboard"];
|
||||
"application/json": components["schemas"]["StudioDashboardEnvelope"];
|
||||
};
|
||||
};
|
||||
401: components["responses"]["AuthenticationRequired"];
|
||||
@@ -1527,7 +1630,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["DocumentPage"];
|
||||
"application/json": components["schemas"]["DocumentPageEnvelope"];
|
||||
};
|
||||
};
|
||||
400: components["responses"]["MalformedRequest"];
|
||||
@@ -1565,7 +1668,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["WorkingCopy"];
|
||||
"application/json": components["schemas"]["WorkingCopyEnvelope"];
|
||||
};
|
||||
};
|
||||
400: components["responses"]["MalformedRequest"];
|
||||
@@ -1593,7 +1696,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["WorkingCopyDetail"];
|
||||
"application/json": components["schemas"]["WorkingCopyDetailEnvelope"];
|
||||
};
|
||||
};
|
||||
401: components["responses"]["AuthenticationRequired"];
|
||||
@@ -1632,7 +1735,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["WorkingCopyDetail"];
|
||||
"application/json": components["schemas"]["WorkingCopyDetailEnvelope"];
|
||||
};
|
||||
};
|
||||
400: components["responses"]["MalformedRequest"];
|
||||
@@ -1674,7 +1777,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ValidationReport"];
|
||||
"application/json": components["schemas"]["ValidationReportEnvelope"];
|
||||
};
|
||||
};
|
||||
400: components["responses"]["MalformedRequest"];
|
||||
@@ -1703,7 +1806,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["PreviewDetail"];
|
||||
"application/json": components["schemas"]["PreviewDetailEnvelope"];
|
||||
};
|
||||
};
|
||||
401: components["responses"]["AuthenticationRequired"];
|
||||
@@ -1742,7 +1845,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["PublicPreview"];
|
||||
"application/json": components["schemas"]["PublicPreviewEnvelope"];
|
||||
};
|
||||
};
|
||||
400: components["responses"]["MalformedRequest"];
|
||||
@@ -1784,7 +1887,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["PublishResult"];
|
||||
"application/json": components["schemas"]["PublishResultEnvelope"];
|
||||
};
|
||||
};
|
||||
400: components["responses"]["MalformedRequest"];
|
||||
@@ -1818,7 +1921,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["PublicationPage"];
|
||||
"application/json": components["schemas"]["PublicationPageEnvelope"];
|
||||
};
|
||||
};
|
||||
400: components["responses"]["MalformedRequest"];
|
||||
@@ -1858,7 +1961,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["PublishResult"];
|
||||
"application/json": components["schemas"]["PublishResultEnvelope"];
|
||||
};
|
||||
};
|
||||
400: components["responses"]["MalformedRequest"];
|
||||
@@ -1887,7 +1990,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["PublicationSnapshot"];
|
||||
"application/json": components["schemas"]["PublicationSnapshotEnvelope"];
|
||||
};
|
||||
};
|
||||
401: components["responses"]["AuthenticationRequired"];
|
||||
@@ -1918,7 +2021,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["CatalogPage"];
|
||||
"application/json": components["schemas"]["CatalogPageEnvelope"];
|
||||
};
|
||||
};
|
||||
400: components["responses"]["MalformedRequest"];
|
||||
@@ -1951,7 +2054,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["AssetPage"];
|
||||
"application/json": components["schemas"]["AssetPageEnvelope"];
|
||||
};
|
||||
};
|
||||
400: components["responses"]["MalformedRequest"];
|
||||
@@ -1989,7 +2092,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Asset"];
|
||||
"application/json": components["schemas"]["AssetEnvelope"];
|
||||
};
|
||||
};
|
||||
400: components["responses"]["MalformedRequest"];
|
||||
@@ -2019,7 +2122,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["AssetDetail"];
|
||||
"application/json": components["schemas"]["AssetDetailEnvelope"];
|
||||
};
|
||||
};
|
||||
401: components["responses"]["AuthenticationRequired"];
|
||||
@@ -2058,7 +2161,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Asset"];
|
||||
"application/json": components["schemas"]["AssetEnvelope"];
|
||||
};
|
||||
};
|
||||
400: components["responses"]["MalformedRequest"];
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: Tech Log Studio API
|
||||
version: 2.0.0
|
||||
version: 3.0.0
|
||||
description: |
|
||||
Tech Log Studio orchestration 계약이다.
|
||||
|
||||
@@ -109,7 +109,7 @@ paths:
|
||||
tags: [Session]
|
||||
summary: 현재 Studio 세션과 CSRF 토큰을 조회한다
|
||||
responses:
|
||||
"200": { description: 인증된 Studio 세션, content: { application/json: { schema: { $ref: "#/components/schemas/StudioSession" } } } }
|
||||
"200": { description: 인증된 Studio 세션, content: { application/json: { schema: { $ref: "#/components/schemas/StudioSessionEnvelope" } } } }
|
||||
"401": { $ref: "#/components/responses/AuthenticationRequired" }
|
||||
"403": { $ref: "#/components/responses/AccessDenied" }
|
||||
"503": { $ref: "#/components/responses/StudioUnavailable" }
|
||||
@@ -123,7 +123,7 @@ paths:
|
||||
`nextAction`을 포함한 모든 workflow 상태는 서버가 계산한다.
|
||||
Frontend는 여러 endpoint를 조합해 workflow 상태를 재추론하지 않는다.
|
||||
responses:
|
||||
"200": { description: Dashboard lists and totals, content: { application/json: { schema: { $ref: "#/components/schemas/StudioDashboard" } } } }
|
||||
"200": { description: Dashboard lists and totals, content: { application/json: { schema: { $ref: "#/components/schemas/StudioDashboardEnvelope" } } } }
|
||||
"401": { $ref: "#/components/responses/AuthenticationRequired" }
|
||||
"403": { $ref: "#/components/responses/AccessDenied" }
|
||||
"503": { $ref: "#/components/responses/StudioUnavailable" }
|
||||
@@ -150,7 +150,7 @@ paths:
|
||||
- { $ref: "#/components/parameters/Cursor" }
|
||||
- { $ref: "#/components/parameters/Limit" }
|
||||
responses:
|
||||
"200": { description: Working-copy cursor page, content: { application/json: { schema: { $ref: "#/components/schemas/DocumentPage" } } } }
|
||||
"200": { description: Working-copy cursor page, content: { application/json: { schema: { $ref: "#/components/schemas/DocumentPageEnvelope" } } } }
|
||||
"400": { $ref: "#/components/responses/MalformedRequest" }
|
||||
"401": { $ref: "#/components/responses/AuthenticationRequired" }
|
||||
"403": { $ref: "#/components/responses/AccessDenied" }
|
||||
@@ -169,7 +169,7 @@ paths:
|
||||
"201":
|
||||
description: Created working copy
|
||||
headers: { Idempotency-Replayed: { $ref: "#/components/headers/IdempotencyReplayed" } }
|
||||
content: { application/json: { schema: { $ref: "#/components/schemas/WorkingCopy" } } }
|
||||
content: { application/json: { schema: { $ref: "#/components/schemas/WorkingCopyEnvelope" } } }
|
||||
"400": { $ref: "#/components/responses/MalformedRequest" }
|
||||
"401": { $ref: "#/components/responses/AuthenticationRequired" }
|
||||
"403": { $ref: "#/components/responses/AccessDenied" }
|
||||
@@ -184,7 +184,7 @@ paths:
|
||||
tags: [Documents]
|
||||
summary: Get a working copy and its current state
|
||||
responses:
|
||||
"200": { description: Working-copy detail, content: { application/json: { schema: { $ref: "#/components/schemas/WorkingCopyDetail" } } } }
|
||||
"200": { description: Working-copy detail, content: { application/json: { schema: { $ref: "#/components/schemas/WorkingCopyDetailEnvelope" } } } }
|
||||
"401": { $ref: "#/components/responses/AuthenticationRequired" }
|
||||
"403": { $ref: "#/components/responses/AccessDenied" }
|
||||
"404": { $ref: "#/components/responses/DocumentNotFound" }
|
||||
@@ -197,8 +197,8 @@ paths:
|
||||
편집 가능한 content field만 저장한다. lifecycle 전이는 명시적인 Domain
|
||||
Action이 담당한다. 저장은 Public Projection을 변경하지 않는다.
|
||||
|
||||
`expectedVersion` 불일치는 `VERSION_CONFLICT`이며 응답 `ProblemDetails`의
|
||||
`latestDocument`로 현재 상태를 함께 제공한다.
|
||||
`expectedVersion` 불일치는 `VERSION_CONFLICT`이며 응답 `error.details`
|
||||
(`VersionConflictDetails`)의 `latestDocument`로 현재 상태를 함께 제공한다.
|
||||
parameters:
|
||||
- { $ref: "#/components/parameters/IdempotencyKey" }
|
||||
- { $ref: "#/components/parameters/CsrfToken" }
|
||||
@@ -207,7 +207,7 @@ paths:
|
||||
"200":
|
||||
description: Saved working-copy detail
|
||||
headers: { Idempotency-Replayed: { $ref: "#/components/headers/IdempotencyReplayed" } }
|
||||
content: { application/json: { schema: { $ref: "#/components/schemas/WorkingCopyDetail" } } }
|
||||
content: { application/json: { schema: { $ref: "#/components/schemas/WorkingCopyDetailEnvelope" } } }
|
||||
"400": { $ref: "#/components/responses/MalformedRequest" }
|
||||
"401": { $ref: "#/components/responses/AuthenticationRequired" }
|
||||
"403": { $ref: "#/components/responses/AccessDenied" }
|
||||
@@ -244,7 +244,7 @@ paths:
|
||||
"200":
|
||||
description: Validation report
|
||||
headers: { Idempotency-Replayed: { $ref: "#/components/headers/IdempotencyReplayed" } }
|
||||
content: { application/json: { schema: { $ref: "#/components/schemas/ValidationReport" } } }
|
||||
content: { application/json: { schema: { $ref: "#/components/schemas/ValidationReportEnvelope" } } }
|
||||
"400": { $ref: "#/components/responses/MalformedRequest" }
|
||||
"401": { $ref: "#/components/responses/AuthenticationRequired" }
|
||||
"403": { $ref: "#/components/responses/AccessDenied" }
|
||||
@@ -264,7 +264,7 @@ paths:
|
||||
anonymous `/preview/{token}` 계약은 폐기되었다. Preview는 인증된
|
||||
Studio API로만 조회한다.
|
||||
responses:
|
||||
"200": { description: Preview detail, content: { application/json: { schema: { $ref: "#/components/schemas/PreviewDetail" } } } }
|
||||
"200": { description: Preview detail, content: { application/json: { schema: { $ref: "#/components/schemas/PreviewDetailEnvelope" } } } }
|
||||
"401": { $ref: "#/components/responses/AuthenticationRequired" }
|
||||
"403": { $ref: "#/components/responses/AccessDenied" }
|
||||
"404": { $ref: "#/components/responses/PreviewNotFound" }
|
||||
@@ -285,7 +285,7 @@ paths:
|
||||
"201":
|
||||
description: Created preview
|
||||
headers: { Idempotency-Replayed: { $ref: "#/components/headers/IdempotencyReplayed" } }
|
||||
content: { application/json: { schema: { $ref: "#/components/schemas/PublicPreview" } } }
|
||||
content: { application/json: { schema: { $ref: "#/components/schemas/PublicPreviewEnvelope" } } }
|
||||
"400": { $ref: "#/components/responses/MalformedRequest" }
|
||||
"401": { $ref: "#/components/responses/AuthenticationRequired" }
|
||||
"403": { $ref: "#/components/responses/AccessDenied" }
|
||||
@@ -329,7 +329,7 @@ paths:
|
||||
"200":
|
||||
description: Publication aggregate and immutable event
|
||||
headers: { Idempotency-Replayed: { $ref: "#/components/headers/IdempotencyReplayed" } }
|
||||
content: { application/json: { schema: { $ref: "#/components/schemas/PublishResult" } } }
|
||||
content: { application/json: { schema: { $ref: "#/components/schemas/PublishResultEnvelope" } } }
|
||||
"400": { $ref: "#/components/responses/MalformedRequest" }
|
||||
"401": { $ref: "#/components/responses/AuthenticationRequired" }
|
||||
"403": { $ref: "#/components/responses/AccessDenied" }
|
||||
@@ -350,7 +350,7 @@ paths:
|
||||
- { $ref: "#/components/parameters/Cursor" }
|
||||
- { $ref: "#/components/parameters/Limit" }
|
||||
responses:
|
||||
"200": { description: Publication cursor page, content: { application/json: { schema: { $ref: "#/components/schemas/PublicationPage" } } } }
|
||||
"200": { description: Publication cursor page, content: { application/json: { schema: { $ref: "#/components/schemas/PublicationPageEnvelope" } } } }
|
||||
"400": { $ref: "#/components/responses/MalformedRequest" }
|
||||
"401": { $ref: "#/components/responses/AuthenticationRequired" }
|
||||
"403": { $ref: "#/components/responses/AccessDenied" }
|
||||
@@ -379,7 +379,7 @@ paths:
|
||||
"200":
|
||||
description: Updated publication aggregate and event
|
||||
headers: { Idempotency-Replayed: { $ref: "#/components/headers/IdempotencyReplayed" } }
|
||||
content: { application/json: { schema: { $ref: "#/components/schemas/PublishResult" } } }
|
||||
content: { application/json: { schema: { $ref: "#/components/schemas/PublishResultEnvelope" } } }
|
||||
"400": { $ref: "#/components/responses/MalformedRequest" }
|
||||
"401": { $ref: "#/components/responses/AuthenticationRequired" }
|
||||
"403": { $ref: "#/components/responses/AccessDenied" }
|
||||
@@ -401,7 +401,7 @@ paths:
|
||||
`UNPUBLISHED` Event는 자체 snapshot을 갖지 않는다. 이 경우
|
||||
`sourcePublishedEventId`가 가리키는 마지막 공개 Snapshot을 사용한다.
|
||||
responses:
|
||||
"200": { description: Publication snapshot, content: { application/json: { schema: { $ref: "#/components/schemas/PublicationSnapshot" } } } }
|
||||
"200": { description: Publication snapshot, content: { application/json: { schema: { $ref: "#/components/schemas/PublicationSnapshotEnvelope" } } } }
|
||||
"401": { $ref: "#/components/responses/AuthenticationRequired" }
|
||||
"403": { $ref: "#/components/responses/AccessDenied" }
|
||||
"404": { $ref: "#/components/responses/PublicationSnapshotNotFound" }
|
||||
@@ -430,7 +430,7 @@ paths:
|
||||
- { $ref: "#/components/parameters/Cursor" }
|
||||
- { $ref: "#/components/parameters/Limit" }
|
||||
responses:
|
||||
"200": { description: Catalog cursor page, content: { application/json: { schema: { $ref: "#/components/schemas/CatalogPage" } } } }
|
||||
"200": { description: Catalog cursor page, content: { application/json: { schema: { $ref: "#/components/schemas/CatalogPageEnvelope" } } } }
|
||||
"400": { $ref: "#/components/responses/MalformedRequest" }
|
||||
"401": { $ref: "#/components/responses/AuthenticationRequired" }
|
||||
"403": { $ref: "#/components/responses/AccessDenied" }
|
||||
@@ -449,7 +449,7 @@ paths:
|
||||
- { $ref: "#/components/parameters/Cursor" }
|
||||
- { $ref: "#/components/parameters/Limit" }
|
||||
responses:
|
||||
"200": { description: Asset cursor page, content: { application/json: { schema: { $ref: "#/components/schemas/AssetPage" } } } }
|
||||
"200": { description: Asset cursor page, content: { application/json: { schema: { $ref: "#/components/schemas/AssetPageEnvelope" } } } }
|
||||
"400": { $ref: "#/components/responses/MalformedRequest" }
|
||||
"401": { $ref: "#/components/responses/AuthenticationRequired" }
|
||||
"403": { $ref: "#/components/responses/AccessDenied" }
|
||||
@@ -483,7 +483,7 @@ paths:
|
||||
"201":
|
||||
description: Stored asset
|
||||
headers: { Idempotency-Replayed: { $ref: "#/components/headers/IdempotencyReplayed" } }
|
||||
content: { application/json: { schema: { $ref: "#/components/schemas/Asset" } } }
|
||||
content: { application/json: { schema: { $ref: "#/components/schemas/AssetEnvelope" } } }
|
||||
"400": { $ref: "#/components/responses/MalformedRequest" }
|
||||
"401": { $ref: "#/components/responses/AuthenticationRequired" }
|
||||
"403": { $ref: "#/components/responses/AccessDenied" }
|
||||
@@ -500,7 +500,7 @@ paths:
|
||||
tags: [Assets]
|
||||
summary: Get an asset with its usage
|
||||
responses:
|
||||
"200": { description: Asset detail, content: { application/json: { schema: { $ref: "#/components/schemas/AssetDetail" } } } }
|
||||
"200": { description: Asset detail, content: { application/json: { schema: { $ref: "#/components/schemas/AssetDetailEnvelope" } } } }
|
||||
"401": { $ref: "#/components/responses/AuthenticationRequired" }
|
||||
"403": { $ref: "#/components/responses/AccessDenied" }
|
||||
"404": { $ref: "#/components/responses/AssetNotFound" }
|
||||
@@ -520,7 +520,7 @@ paths:
|
||||
"200":
|
||||
description: Updated asset
|
||||
headers: { Idempotency-Replayed: { $ref: "#/components/headers/IdempotencyReplayed" } }
|
||||
content: { application/json: { schema: { $ref: "#/components/schemas/Asset" } } }
|
||||
content: { application/json: { schema: { $ref: "#/components/schemas/AssetEnvelope" } } }
|
||||
"400": { $ref: "#/components/responses/MalformedRequest" }
|
||||
"401": { $ref: "#/components/responses/AuthenticationRequired" }
|
||||
"403": { $ref: "#/components/responses/AccessDenied" }
|
||||
@@ -593,26 +593,26 @@ components:
|
||||
IdempotencyReplayed: { description: True when the original result was replayed, schema: { type: boolean } }
|
||||
|
||||
responses:
|
||||
MalformedRequest: { description: Malformed request, x-error-codes: [REQUEST_VALIDATION_FAILED], content: { application/problem+json: { schema: { $ref: "#/components/schemas/ProblemDetails" } } } }
|
||||
AuthenticationRequired: { description: Authentication required, x-error-codes: [AUTHENTICATION_REQUIRED], content: { application/problem+json: { schema: { $ref: "#/components/schemas/ProblemDetails" } } } }
|
||||
AccessDenied: { description: Studio access denied, x-error-codes: [STUDIO_ACCESS_DENIED], content: { application/problem+json: { schema: { $ref: "#/components/schemas/ProblemDetails" } } } }
|
||||
DocumentNotFound: { description: Document not found, x-error-codes: [DOCUMENT_NOT_FOUND], content: { application/problem+json: { schema: { $ref: "#/components/schemas/ProblemDetails" } } } }
|
||||
PreviewNotFound: { description: Document or preview not found, x-error-codes: [DOCUMENT_NOT_FOUND, PREVIEW_NOT_FOUND], content: { application/problem+json: { schema: { $ref: "#/components/schemas/ProblemDetails" } } } }
|
||||
PublicationNotFound: { description: Publication not found, x-error-codes: [PUBLICATION_NOT_FOUND], content: { application/problem+json: { schema: { $ref: "#/components/schemas/ProblemDetails" } } } }
|
||||
PublicationSnapshotNotFound: { description: Publication event or snapshot not found, x-error-codes: [PUBLICATION_EVENT_NOT_FOUND, PUBLICATION_SNAPSHOT_NOT_FOUND], content: { application/problem+json: { schema: { $ref: "#/components/schemas/ProblemDetails" } } } }
|
||||
AssetNotFound: { description: Asset not found, x-error-codes: [ASSET_NOT_FOUND], content: { application/problem+json: { schema: { $ref: "#/components/schemas/ProblemDetails" } } } }
|
||||
MalformedRequest: { description: Malformed request, x-error-codes: [REQUEST_VALIDATION_FAILED], content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } }
|
||||
AuthenticationRequired: { description: Authentication required, x-error-codes: [AUTHENTICATION_REQUIRED], content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } }
|
||||
AccessDenied: { description: Studio access denied, x-error-codes: [STUDIO_ACCESS_DENIED], content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } }
|
||||
DocumentNotFound: { description: Document not found, x-error-codes: [DOCUMENT_NOT_FOUND], content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } }
|
||||
PreviewNotFound: { description: Document or preview not found, x-error-codes: [DOCUMENT_NOT_FOUND, PREVIEW_NOT_FOUND], content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } }
|
||||
PublicationNotFound: { description: Publication not found, x-error-codes: [PUBLICATION_NOT_FOUND], content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } }
|
||||
PublicationSnapshotNotFound: { description: Publication event or snapshot not found, x-error-codes: [PUBLICATION_EVENT_NOT_FOUND, PUBLICATION_SNAPSHOT_NOT_FOUND], content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } }
|
||||
AssetNotFound: { description: Asset not found, x-error-codes: [ASSET_NOT_FOUND], content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } }
|
||||
CommandConflict:
|
||||
description: |
|
||||
Command conflicts with current state, freshness, or idempotency.
|
||||
|
||||
`ASSET_IN_USE`는 사용 중이거나 공개 이력이 있는 Asset의 hard delete 시도다.
|
||||
x-error-codes: [VERSION_CONFLICT, PUBLICATION_CONFLICT, VALIDATION_STALE, PREVIEW_STALE, PREVIEW_EXPIRED, IDEMPOTENCY_KEY_REUSED, ASSET_IN_USE]
|
||||
content: { application/problem+json: { schema: { $ref: "#/components/schemas/ProblemDetails" } } }
|
||||
RequestValidationFailed: { description: Request validation failed, x-error-codes: [REQUEST_VALIDATION_FAILED], content: { application/problem+json: { schema: { $ref: "#/components/schemas/ProblemDetails" } } } }
|
||||
content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } }
|
||||
RequestValidationFailed: { description: Request validation failed, x-error-codes: [REQUEST_VALIDATION_FAILED], content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } }
|
||||
PreviewRejected:
|
||||
description: Preview 생성이 도메인 규칙으로 거절되었다
|
||||
x-error-codes: [REQUEST_VALIDATION_FAILED, VALIDATION_FAILED, ASSET_NOT_READY, ASSET_QUARANTINED]
|
||||
content: { application/problem+json: { schema: { $ref: "#/components/schemas/ProblemDetails" } } }
|
||||
content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } }
|
||||
PublishRejected:
|
||||
description: |
|
||||
Publication validation이 실패했다.
|
||||
@@ -620,16 +620,205 @@ components:
|
||||
`WARNING_ACKNOWLEDGEMENT_REQUIRED`는 `acknowledgedWarningCodes`가
|
||||
현재 Validation의 WARNING 집합을 덮지 못한 경우다.
|
||||
x-error-codes: [REQUEST_VALIDATION_FAILED, VALIDATION_FAILED, WARNING_ACKNOWLEDGEMENT_REQUIRED, ASSET_NOT_READY, ASSET_QUARANTINED]
|
||||
content: { application/problem+json: { schema: { $ref: "#/components/schemas/ProblemDetails" } } }
|
||||
content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } }
|
||||
AssetRejected:
|
||||
description: Asset metadata 변경이 거절되었다
|
||||
x-error-codes: [REQUEST_VALIDATION_FAILED, ASSET_NOT_READY, ASSET_QUARANTINED]
|
||||
content: { application/problem+json: { schema: { $ref: "#/components/schemas/ProblemDetails" } } }
|
||||
PayloadTooLarge: { description: Upload exceeds the configured size limit, x-error-codes: [PAYLOAD_TOO_LARGE], content: { application/problem+json: { schema: { $ref: "#/components/schemas/ProblemDetails" } } } }
|
||||
UnsupportedMediaType: { description: Unsupported media type, x-error-codes: [UNSUPPORTED_MEDIA_TYPE], content: { application/problem+json: { schema: { $ref: "#/components/schemas/ProblemDetails" } } } }
|
||||
StudioUnavailable: { description: Studio unavailable, x-error-codes: [STUDIO_UNAVAILABLE], content: { application/problem+json: { schema: { $ref: "#/components/schemas/ProblemDetails" } } } }
|
||||
content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } }
|
||||
PayloadTooLarge: { description: Upload exceeds the configured size limit, x-error-codes: [PAYLOAD_TOO_LARGE], content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } }
|
||||
UnsupportedMediaType: { description: Unsupported media type, x-error-codes: [UNSUPPORTED_MEDIA_TYPE], content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } }
|
||||
StudioUnavailable: { description: Studio unavailable, x-error-codes: [STUDIO_UNAVAILABLE], content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } }
|
||||
|
||||
schemas:
|
||||
# ------------------------------------------------------------- envelope
|
||||
# wire format은 봉투다 (ADR-006). payload 스키마는 그대로 두고
|
||||
# 응답만 <Payload>Envelope으로 감싼다.
|
||||
ResponseMeta:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [requestId, traceId]
|
||||
properties:
|
||||
requestId: { type: string, minLength: 1, maxLength: 200 }
|
||||
traceId: { type: string, minLength: 1, maxLength: 200 }
|
||||
correlationId: { type: [string, "null"], maxLength: 200 }
|
||||
page: { type: "null", description: Studio는 body 안 cursor 페이지네이션을 쓰므로 항상 null이다 }
|
||||
ApiError:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [code, category, message, retryable]
|
||||
properties:
|
||||
code:
|
||||
type: string
|
||||
enum: [AUTHENTICATION_REQUIRED, STUDIO_ACCESS_DENIED, DOCUMENT_NOT_FOUND, VERSION_CONFLICT,
|
||||
REQUEST_VALIDATION_FAILED, VALIDATION_FAILED, VALIDATION_STALE, PREVIEW_NOT_FOUND,
|
||||
PREVIEW_STALE, PREVIEW_EXPIRED, PUBLICATION_NOT_FOUND, PUBLICATION_CONFLICT,
|
||||
PUBLICATION_EVENT_NOT_FOUND, PUBLICATION_SNAPSHOT_NOT_FOUND,
|
||||
WARNING_ACKNOWLEDGEMENT_REQUIRED, IDEMPOTENCY_KEY_REUSED, ASSET_NOT_FOUND,
|
||||
ASSET_NOT_READY, ASSET_IN_USE, ASSET_QUARANTINED, PAYLOAD_TOO_LARGE,
|
||||
UNSUPPORTED_MEDIA_TYPE, STUDIO_UNAVAILABLE]
|
||||
category:
|
||||
type: string
|
||||
enum: [VALIDATION, AUTH, AUTHZ, NOT_FOUND, CONFLICT, RATE_LIMIT,
|
||||
TRANSIENT_DEPENDENCY, PERMANENT_DEPENDENCY, DATA_INTEGRITY, INTERNAL]
|
||||
message: { type: string, minLength: 1, maxLength: 5000 }
|
||||
retryable: { type: boolean }
|
||||
details:
|
||||
oneOf:
|
||||
- $ref: "#/components/schemas/ValidationErrorDetails"
|
||||
- $ref: "#/components/schemas/VersionConflictDetails"
|
||||
- $ref: "#/components/schemas/PublicationConflictDetails"
|
||||
- type: "null"
|
||||
ErrorEnvelope:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [success, error, meta]
|
||||
properties:
|
||||
success: { type: boolean, const: false }
|
||||
error: { $ref: "#/components/schemas/ApiError" }
|
||||
meta: { $ref: "#/components/schemas/ResponseMeta" }
|
||||
ValidationErrorDetails:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [fieldErrors]
|
||||
properties:
|
||||
fieldErrors: { type: array, maxItems: 200, items: { $ref: "#/components/schemas/FieldError" } }
|
||||
VersionConflictDetails:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [latestDocument]
|
||||
properties:
|
||||
latestDocument: { $ref: "#/components/schemas/WorkingCopyDetail" }
|
||||
conflictingFields:
|
||||
type: array
|
||||
uniqueItems: true
|
||||
maxItems: 200
|
||||
items: { type: string, pattern: "^(?:/(?:[^~/]|~0|~1)*)*$" }
|
||||
PublicationConflictDetails:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [latestPublication]
|
||||
properties:
|
||||
latestPublication: { $ref: "#/components/schemas/PublicationAggregate" }
|
||||
StudioSessionEnvelope:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [success, data, meta]
|
||||
properties:
|
||||
success: { type: boolean, const: true }
|
||||
data: { $ref: "#/components/schemas/StudioSession" }
|
||||
meta: { $ref: "#/components/schemas/ResponseMeta" }
|
||||
StudioDashboardEnvelope:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [success, data, meta]
|
||||
properties:
|
||||
success: { type: boolean, const: true }
|
||||
data: { $ref: "#/components/schemas/StudioDashboard" }
|
||||
meta: { $ref: "#/components/schemas/ResponseMeta" }
|
||||
DocumentPageEnvelope:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [success, data, meta]
|
||||
properties:
|
||||
success: { type: boolean, const: true }
|
||||
data: { $ref: "#/components/schemas/DocumentPage" }
|
||||
meta: { $ref: "#/components/schemas/ResponseMeta" }
|
||||
WorkingCopyDetailEnvelope:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [success, data, meta]
|
||||
properties:
|
||||
success: { type: boolean, const: true }
|
||||
data: { $ref: "#/components/schemas/WorkingCopyDetail" }
|
||||
meta: { $ref: "#/components/schemas/ResponseMeta" }
|
||||
WorkingCopyEnvelope:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [success, data, meta]
|
||||
properties:
|
||||
success: { type: boolean, const: true }
|
||||
data: { $ref: "#/components/schemas/WorkingCopy" }
|
||||
meta: { $ref: "#/components/schemas/ResponseMeta" }
|
||||
ValidationReportEnvelope:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [success, data, meta]
|
||||
properties:
|
||||
success: { type: boolean, const: true }
|
||||
data: { $ref: "#/components/schemas/ValidationReport" }
|
||||
meta: { $ref: "#/components/schemas/ResponseMeta" }
|
||||
PreviewDetailEnvelope:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [success, data, meta]
|
||||
properties:
|
||||
success: { type: boolean, const: true }
|
||||
data: { $ref: "#/components/schemas/PreviewDetail" }
|
||||
meta: { $ref: "#/components/schemas/ResponseMeta" }
|
||||
PublicPreviewEnvelope:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [success, data, meta]
|
||||
properties:
|
||||
success: { type: boolean, const: true }
|
||||
data: { $ref: "#/components/schemas/PublicPreview" }
|
||||
meta: { $ref: "#/components/schemas/ResponseMeta" }
|
||||
PublishResultEnvelope:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [success, data, meta]
|
||||
properties:
|
||||
success: { type: boolean, const: true }
|
||||
data: { $ref: "#/components/schemas/PublishResult" }
|
||||
meta: { $ref: "#/components/schemas/ResponseMeta" }
|
||||
PublicationPageEnvelope:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [success, data, meta]
|
||||
properties:
|
||||
success: { type: boolean, const: true }
|
||||
data: { $ref: "#/components/schemas/PublicationPage" }
|
||||
meta: { $ref: "#/components/schemas/ResponseMeta" }
|
||||
PublicationSnapshotEnvelope:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [success, data, meta]
|
||||
properties:
|
||||
success: { type: boolean, const: true }
|
||||
data: { $ref: "#/components/schemas/PublicationSnapshot" }
|
||||
meta: { $ref: "#/components/schemas/ResponseMeta" }
|
||||
CatalogPageEnvelope:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [success, data, meta]
|
||||
properties:
|
||||
success: { type: boolean, const: true }
|
||||
data: { $ref: "#/components/schemas/CatalogPage" }
|
||||
meta: { $ref: "#/components/schemas/ResponseMeta" }
|
||||
AssetPageEnvelope:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [success, data, meta]
|
||||
properties:
|
||||
success: { type: boolean, const: true }
|
||||
data: { $ref: "#/components/schemas/AssetPage" }
|
||||
meta: { $ref: "#/components/schemas/ResponseMeta" }
|
||||
AssetDetailEnvelope:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [success, data, meta]
|
||||
properties:
|
||||
success: { type: boolean, const: true }
|
||||
data: { $ref: "#/components/schemas/AssetDetail" }
|
||||
meta: { $ref: "#/components/schemas/ResponseMeta" }
|
||||
AssetEnvelope:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [success, data, meta]
|
||||
properties:
|
||||
success: { type: boolean, const: true }
|
||||
data: { $ref: "#/components/schemas/Asset" }
|
||||
meta: { $ref: "#/components/schemas/ResponseMeta" }
|
||||
# ---------------------------------------------------------------- session
|
||||
StudioSession:
|
||||
type: object
|
||||
@@ -1645,51 +1834,3 @@ components:
|
||||
pattern: "^(?:/(?:[^~/]|~0|~1)*)*$"
|
||||
description: JSON Pointer to the invalid field
|
||||
message: { type: string, minLength: 1, maxLength: 1000 }
|
||||
ProblemDetails:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
required: [type, title, status, detail, code]
|
||||
properties:
|
||||
type: { type: string, format: uri-reference }
|
||||
title: { type: string, minLength: 1, maxLength: 200 }
|
||||
status: { type: integer, minimum: 400, maximum: 599 }
|
||||
detail: { type: string, minLength: 1, maxLength: 5000 }
|
||||
code:
|
||||
type: string
|
||||
enum:
|
||||
- AUTHENTICATION_REQUIRED
|
||||
- STUDIO_ACCESS_DENIED
|
||||
- DOCUMENT_NOT_FOUND
|
||||
- VERSION_CONFLICT
|
||||
- REQUEST_VALIDATION_FAILED
|
||||
- VALIDATION_FAILED
|
||||
- VALIDATION_STALE
|
||||
- PREVIEW_NOT_FOUND
|
||||
- PREVIEW_STALE
|
||||
- PREVIEW_EXPIRED
|
||||
- PUBLICATION_NOT_FOUND
|
||||
- PUBLICATION_CONFLICT
|
||||
- PUBLICATION_EVENT_NOT_FOUND
|
||||
- PUBLICATION_SNAPSHOT_NOT_FOUND
|
||||
- WARNING_ACKNOWLEDGEMENT_REQUIRED
|
||||
- IDEMPOTENCY_KEY_REUSED
|
||||
- ASSET_NOT_FOUND
|
||||
- ASSET_NOT_READY
|
||||
- ASSET_IN_USE
|
||||
- ASSET_QUARANTINED
|
||||
- PAYLOAD_TOO_LARGE
|
||||
- UNSUPPORTED_MEDIA_TYPE
|
||||
- STUDIO_UNAVAILABLE
|
||||
instance: { type: string, format: uri-reference }
|
||||
traceId: { type: string, maxLength: 200 }
|
||||
fieldErrors: { type: array, maxItems: 200, items: { $ref: "#/components/schemas/FieldError" } }
|
||||
latestDocument: { $ref: "#/components/schemas/WorkingCopyDetail" }
|
||||
latestPublication: { $ref: "#/components/schemas/PublicationAggregate" }
|
||||
conflictingFields:
|
||||
type: array
|
||||
uniqueItems: true
|
||||
maxItems: 200
|
||||
items:
|
||||
type: string
|
||||
pattern: "^(?:/(?:[^~/]|~0|~1)*)*$"
|
||||
retryable: { type: boolean }
|
||||
|
||||
@@ -51,21 +51,73 @@ function zodValidator<T>(schemaId: string, schema: z.ZodType<T>): RuntimeValidat
|
||||
const passthrough = <T>(schemaId: string) =>
|
||||
zodValidator<T>(schemaId, z.unknown() as unknown as z.ZodType<T>);
|
||||
|
||||
const problemSchema = z
|
||||
/**
|
||||
* wire format은 봉투다 (ADR-006). 전송 계층은 봉투 뼈대만 검증하고 payload는
|
||||
* 통과시킨다 — generated 타입이 컴파일 시점 계약이고, 런타임 재검증은 계약 갱신
|
||||
* 때마다 두 곳을 고치게 만든다. 다만 봉투 자체는 반드시 검증한다: 여기서 통과시키면
|
||||
* 잘못된 모양이 앱 계층까지 조용히 흘러간다.
|
||||
*/
|
||||
const metaSchema = z
|
||||
.object({ requestId: z.string().min(1), traceId: z.string().min(1) })
|
||||
.loose();
|
||||
|
||||
export const envelopeData = <T>(schemaId: string): RuntimeValidator<T> =>
|
||||
zodValidator<T>(
|
||||
schemaId,
|
||||
z
|
||||
.object({ success: z.literal(true), data: z.unknown(), meta: metaSchema })
|
||||
.loose()
|
||||
.transform((envelope) => envelope.data as T) as unknown as z.ZodType<T>,
|
||||
);
|
||||
|
||||
const apiErrorSchema = z
|
||||
.object({
|
||||
// canonical: `format: uri-reference` only, no length bound.
|
||||
type: z.string().min(1),
|
||||
title: z.string().min(1).max(200),
|
||||
status: z.int().min(400).max(599),
|
||||
detail: z.string().min(1).max(5000),
|
||||
code: z.enum(STUDIO_ERROR_CODES as unknown as [string, ...string[]]),
|
||||
category: z.string().min(1),
|
||||
message: z.string().min(1).max(5000),
|
||||
retryable: z.boolean(),
|
||||
})
|
||||
.loose();
|
||||
|
||||
const PROBLEM = zodValidator("StudioProblemDetails", problemSchema);
|
||||
/**
|
||||
* 봉투 오류를 기존 ProblemDetails 형태로 옮긴다. 앱 계층(`StudioGatewayError`)은
|
||||
* 그 모양을 계속 쓰므로 매핑을 여기서 끝내면 아래 계층이 무변경이다.
|
||||
* `status`는 봉투에 없다 — 전송 계층이 실제 HTTP status를 따로 들고 있으므로
|
||||
* 0으로 두고 `toStudioGatewayError`가 outcome의 status로 덮는다.
|
||||
*/
|
||||
export const envelopeError = (): RuntimeValidator<StudioProblemShape> =>
|
||||
zodValidator<StudioProblemShape>(
|
||||
"StudioErrorEnvelope",
|
||||
z
|
||||
.object({ success: z.literal(false), error: apiErrorSchema, meta: metaSchema })
|
||||
.loose()
|
||||
.transform((envelope) => ({
|
||||
type: `https://techlog.local/problems/${envelope.error.code.toLowerCase().replaceAll("_", "-")}`,
|
||||
title: envelope.error.code,
|
||||
status: 0,
|
||||
detail: envelope.error.message,
|
||||
code: envelope.error.code,
|
||||
retryable: envelope.error.retryable,
|
||||
category: envelope.error.category,
|
||||
details: (envelope.error as { details?: unknown }).details ?? null,
|
||||
})) as unknown as z.ZodType<StudioProblemShape>,
|
||||
);
|
||||
|
||||
export type StudioProblemShape = Readonly<{
|
||||
type: string;
|
||||
title: string;
|
||||
status: number;
|
||||
detail: string;
|
||||
code: string;
|
||||
retryable: boolean;
|
||||
category: string;
|
||||
details: unknown;
|
||||
}>;
|
||||
|
||||
const PROBLEM = envelopeError();
|
||||
|
||||
/** 4xx 도메인 거절은 적용되지 않았음이 확정이다. 5xx/네트워크는 불확정이다. */
|
||||
const COMMAND_EFFECT: CommandEffectDescriptor<z.output<typeof problemSchema>> =
|
||||
const COMMAND_EFFECT: CommandEffectDescriptor<StudioProblemShape> =
|
||||
Object.freeze({
|
||||
successEffect: "APPLIED_CONFIRMED" as const,
|
||||
classifyProblem({ status }: Readonly<{ status: number; problem: unknown }>) {
|
||||
@@ -93,7 +145,7 @@ function safeOperation(
|
||||
method: "GET" as const,
|
||||
pathTemplate,
|
||||
inputValidator: passthrough(`${operationId}Input`),
|
||||
outputValidator: passthrough(`${operationId}Output`),
|
||||
outputValidator: envelopeData(`${operationId}Output`),
|
||||
problemValidator: PROBLEM,
|
||||
acceptedStatuses: Object.freeze([200]),
|
||||
emptyBodyStatuses: Object.freeze([]),
|
||||
@@ -141,7 +193,7 @@ function keyedOperation(
|
||||
method,
|
||||
pathTemplate,
|
||||
inputValidator: passthrough(`${operationId}Input`),
|
||||
outputValidator: passthrough(`${operationId}Output`),
|
||||
outputValidator: envelopeData(`${operationId}Output`),
|
||||
problemValidator: PROBLEM,
|
||||
acceptedStatuses: Object.freeze([options.acceptedStatus]),
|
||||
emptyBodyStatuses: Object.freeze(options.acceptedStatus === 204 ? [204] : []),
|
||||
|
||||
Reference in New Issue
Block a user