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:
DongHyeonka
2026-08-18 21:55:45 +09:00
co-authored by Claude Opus 5
parent d84b57bb3f
commit 25a6b63d27
11 changed files with 640 additions and 216 deletions
@@ -1,11 +1,21 @@
import { StudioGatewayError } from "../../application/ports/studio-gateway-error.ts"; import { StudioGatewayError } from "../../application/ports/studio-gateway-error.ts";
import type { UploadAssetForm } from "../../application/ports/studio-asset-gateway.ts"; import type { UploadAssetForm } from "../../application/ports/studio-asset-gateway.ts";
import type { Asset, ProblemDetails } from "../../contracts/studio/contract.ts"; import type { Asset, ProblemDetails } from "../../contracts/studio/contract.ts";
import { envelopeData, envelopeError } from "../../contracts/tech-log-studio-contract-contribution.ts";
import type { StudioAssetUploadTransport } from "./http-studio-asset-gateway.ts"; import type { StudioAssetUploadTransport } from "./http-studio-asset-gateway.ts";
import { STUDIO_ERROR_CODES } from "./studio-error-mapping.ts"; import { STUDIO_ERROR_CODES } from "./studio-error-mapping.ts";
const CODES = new Set<string>(STUDIO_ERROR_CODES); const CODES = new Set<string>(STUDIO_ERROR_CODES);
/**
* canonical `uploadStudioAsset`도 다른 18개 operation과 같은 봉투(ADR-006)를
* 쓴다 — 이 seam만 일반 계약 런타임을 안 거칠 뿐이지 wire format은 같다.
* 그래서 `tech-log-studio-contract-contribution.ts`의 언랩 validator를 그대로
* 재사용한다: 봉투 뼈대 검증 로직이 두 곳에서 따로 드리프트하는 것을 막는다.
*/
const UPLOAD_DATA = envelopeData<Asset>("uploadStudioAssetOutput");
const UPLOAD_PROBLEM = envelopeError();
/** /**
* The contract runtime can only express `requestBody: "NONE" | "JSON"` and the * The contract runtime can only express `requestBody: "NONE" | "JSON"` and the
* low-level client always serializes the body as JSON (see * low-level client always serializes the body as JSON (see
@@ -81,8 +91,9 @@ export function createAssetUploadTransport(
} }
if (response.status === 201 || response.status === 200) { if (response.status === 201 || response.status === 200) {
let body: unknown;
try { try {
return (await response.json()) as Asset; body = await response.json();
} catch { } catch {
// M1 (fix round 1). This port's contract is `StudioGatewayError`; // M1 (fix round 1). This port's contract is `StudioGatewayError`;
// a malformed success body must not throw a raw `SyntaxError` out // a malformed success body must not throw a raw `SyntaxError` out
@@ -91,16 +102,33 @@ export function createAssetUploadTransport(
`Upload returned status ${response.status} with a body that could not be parsed as JSON.`, `Upload returned status ${response.status} with a body that could not be parsed as JSON.`,
); );
} }
const parsed = UPLOAD_DATA.safeParse(body);
if (!parsed.success) {
// 봉투 뼈대(`{success:true, data, meta}`)가 아니다 — payload는
// 통과시키되 봉투 자체는 반드시 검증한다(다른 18개 operation과 동일
// 원칙, ADR-006).
throw unavailable(
`Upload returned status ${response.status} with a body that did not match the response envelope.`,
);
}
return parsed.data;
} }
let problem: ProblemDetails | null; let problemBody: unknown = null;
try { try {
problem = (await response.json()) as ProblemDetails; problemBody = await response.json();
} catch { } catch {
problem = null; problemBody = null;
} }
if (problem && typeof problem.code === "string" && CODES.has(problem.code)) { const parsedProblem = problemBody === null ? null : UPLOAD_PROBLEM.safeParse(problemBody);
throw new StudioGatewayError(problem); if (parsedProblem && parsedProblem.success && CODES.has(parsedProblem.data.code)) {
// `studio-error-mapping.ts`의 PROBLEM 분기와 같은 캐스트: 봉투는 이미
// `code`를 `apiErrorSchema`의 enum으로 검증했으므로(`CODES.has` 확인도
// 통과) `ProblemDetails["code"]`로 좁혀도 안전하다. envelope에는 HTTP
// status가 없다 (`envelopeError`가 0으로 둔다) — 이 seam은 실제 status를
// 이미 들고 있으므로 바로 덮는다.
const problem = parsedProblem.data as unknown as ProblemDetails;
throw new StudioGatewayError({ ...problem, status: response.status });
} }
// Fix round 2, item 2. The real status is passed through (not the // Fix round 2, item 2. The real status is passed through (not the
// hardcoded 503 default) so `http-studio-asset-gateway.ts`'s // hardcoded 503 default) so `http-studio-asset-gateway.ts`'s
@@ -57,13 +57,20 @@ export function toStudioGatewayError(
): StudioGatewayError { ): StudioGatewayError {
switch (outcome.kind) { switch (outcome.kind) {
case "PROBLEM": { case "PROBLEM": {
const problem = outcome.problem as ProblemDetails; // 봉투 오류는 wire에 HTTP status를 싣지 않는다 (`envelopeError`가 `status`를
// 0으로 둔다) — 실제 status는 전송 계층이 `outcome.metadata.status`로
// 이미 들고 있으므로 여기서 덮는다. `SafeResponseMetadata.status`는
// `PROBLEM` outcome에서 필수 필드다 (`http-execution-v3.ts`).
// `problem`이 falsy이거나 status가 0(봉투의 sentinel)이면 metadata로
// 덮는다 — 원래 코드처럼 `problem`을 안전하지 않게 역참조하지 않는다.
const problem = outcome.problem as ProblemDetails | undefined;
const status = problem?.status || outcome.metadata.status;
if (problem && typeof problem.code === "string" && CODES.has(problem.code)) { if (problem && typeof problem.code === "string" && CODES.has(problem.code)) {
return new StudioGatewayError(problem); return new StudioGatewayError({ ...problem, status });
} }
return synthetic( return synthetic(
"STUDIO_UNAVAILABLE", "STUDIO_UNAVAILABLE",
outcome.metadata.status, status,
`${operationId} returned an uncontracted problem code.`, `${operationId} returned an uncontracted problem code.`,
false, false,
); );
@@ -1,8 +1,8 @@
{ {
"packageId": "@tech-log/studio-contract", "packageId": "@tech-log/studio-contract",
"version": "2.0.0", "version": "3.0.0",
"digest": "sha256:99f54f56ea0c582eafdbdf9be5653e3384bef0a1b08bff67f3147ee0292019ea", "digest": "sha256:25ed2e9f5b76bfeacf66e5a84ea8daf05979faa0b54e4980781f02115bbf0627",
"sourceRevision": "ce2e748", "sourceRevision": "3e8a164",
"operationIds": [ "operationIds": [
"getStudioSession", "getStudioSession",
"getStudioDashboard", "getStudioDashboard",
@@ -23,7 +23,35 @@ export type PublicationListItem = Schemas["PublicationListItem"];
export type PublicationPage = Schemas["PublicationPage"]; export type PublicationPage = Schemas["PublicationPage"];
export type PublicationSnapshot = Schemas["PublicationSnapshot"]; export type PublicationSnapshot = Schemas["PublicationSnapshot"];
export type CatalogPage = Schemas["CatalogPage"]; 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 PublicRenderModel = Schemas["PublicRenderModel"];
export type Asset = Schemas["Asset"]; export type Asset = Schemas["Asset"];
export type AssetDetail = Schemas["AssetDetail"]; export type AssetDetail = Schemas["AssetDetail"];
@@ -89,8 +89,8 @@ export interface paths {
* @description 편집 가능한 content field만 저장한다. lifecycle 전이는 명시적인 Domain * @description 편집 가능한 content field만 저장한다. lifecycle 전이는 명시적인 Domain
* Action이 담당한다. 저장은 Public Projection을 변경하지 않는다. * Action이 담당한다. 저장은 Public Projection을 변경하지 않는다.
* *
* `expectedVersion` 불일치는 `VERSION_CONFLICT`이며 응답 `ProblemDetails` * `expectedVersion` 불일치는 `VERSION_CONFLICT`이며 응답 `error.details`
* `latestDocument`로 현재 상태를 함께 제공한다. * (`VersionConflictDetails`)의 `latestDocument`로 현재 상태를 함께 제공한다.
* *
*/ */
put: operations["saveStudioDocument"]; put: operations["saveStudioDocument"];
@@ -382,6 +382,128 @@ export interface paths {
export type webhooks = Record<string, never>; export type webhooks = Record<string, never>;
export interface components { export interface components {
schemas: { 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: { StudioSession: {
authenticated: boolean; authenticated: boolean;
displayName: string; displayName: string;
@@ -1246,25 +1368,6 @@ export interface components {
path: string; path: string;
message: 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: { responses: {
/** @description Malformed request */ /** @description Malformed request */
@@ -1273,7 +1376,7 @@ export interface components {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/problem+json": components["schemas"]["ProblemDetails"]; "application/json": components["schemas"]["ErrorEnvelope"];
}; };
}; };
/** @description Authentication required */ /** @description Authentication required */
@@ -1282,7 +1385,7 @@ export interface components {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/problem+json": components["schemas"]["ProblemDetails"]; "application/json": components["schemas"]["ErrorEnvelope"];
}; };
}; };
/** @description Studio access denied */ /** @description Studio access denied */
@@ -1291,7 +1394,7 @@ export interface components {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/problem+json": components["schemas"]["ProblemDetails"]; "application/json": components["schemas"]["ErrorEnvelope"];
}; };
}; };
/** @description Document not found */ /** @description Document not found */
@@ -1300,7 +1403,7 @@ export interface components {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/problem+json": components["schemas"]["ProblemDetails"]; "application/json": components["schemas"]["ErrorEnvelope"];
}; };
}; };
/** @description Document or preview not found */ /** @description Document or preview not found */
@@ -1309,7 +1412,7 @@ export interface components {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/problem+json": components["schemas"]["ProblemDetails"]; "application/json": components["schemas"]["ErrorEnvelope"];
}; };
}; };
/** @description Publication not found */ /** @description Publication not found */
@@ -1318,7 +1421,7 @@ export interface components {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/problem+json": components["schemas"]["ProblemDetails"]; "application/json": components["schemas"]["ErrorEnvelope"];
}; };
}; };
/** @description Publication event or snapshot not found */ /** @description Publication event or snapshot not found */
@@ -1327,7 +1430,7 @@ export interface components {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/problem+json": components["schemas"]["ProblemDetails"]; "application/json": components["schemas"]["ErrorEnvelope"];
}; };
}; };
/** @description Asset not found */ /** @description Asset not found */
@@ -1336,7 +1439,7 @@ export interface components {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/problem+json": components["schemas"]["ProblemDetails"]; "application/json": components["schemas"]["ErrorEnvelope"];
}; };
}; };
/** @description Command conflicts with current state, freshness, or idempotency. /** @description Command conflicts with current state, freshness, or idempotency.
@@ -1348,7 +1451,7 @@ export interface components {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/problem+json": components["schemas"]["ProblemDetails"]; "application/json": components["schemas"]["ErrorEnvelope"];
}; };
}; };
/** @description Request validation failed */ /** @description Request validation failed */
@@ -1357,7 +1460,7 @@ export interface components {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/problem+json": components["schemas"]["ProblemDetails"]; "application/json": components["schemas"]["ErrorEnvelope"];
}; };
}; };
/** @description Preview 생성이 도메인 규칙으로 거절되었다 */ /** @description Preview 생성이 도메인 규칙으로 거절되었다 */
@@ -1366,7 +1469,7 @@ export interface components {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/problem+json": components["schemas"]["ProblemDetails"]; "application/json": components["schemas"]["ErrorEnvelope"];
}; };
}; };
/** @description Publication validation이 실패했다. /** @description Publication validation이 실패했다.
@@ -1379,7 +1482,7 @@ export interface components {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/problem+json": components["schemas"]["ProblemDetails"]; "application/json": components["schemas"]["ErrorEnvelope"];
}; };
}; };
/** @description Asset metadata 변경이 거절되었다 */ /** @description Asset metadata 변경이 거절되었다 */
@@ -1388,7 +1491,7 @@ export interface components {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/problem+json": components["schemas"]["ProblemDetails"]; "application/json": components["schemas"]["ErrorEnvelope"];
}; };
}; };
/** @description Upload exceeds the configured size limit */ /** @description Upload exceeds the configured size limit */
@@ -1397,7 +1500,7 @@ export interface components {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/problem+json": components["schemas"]["ProblemDetails"]; "application/json": components["schemas"]["ErrorEnvelope"];
}; };
}; };
/** @description Unsupported media type */ /** @description Unsupported media type */
@@ -1406,7 +1509,7 @@ export interface components {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/problem+json": components["schemas"]["ProblemDetails"]; "application/json": components["schemas"]["ErrorEnvelope"];
}; };
}; };
/** @description Studio unavailable */ /** @description Studio unavailable */
@@ -1415,7 +1518,7 @@ export interface components {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/problem+json": components["schemas"]["ProblemDetails"]; "application/json": components["schemas"]["ErrorEnvelope"];
}; };
}; };
}; };
@@ -1470,7 +1573,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/json": components["schemas"]["StudioSession"]; "application/json": components["schemas"]["StudioSessionEnvelope"];
}; };
}; };
401: components["responses"]["AuthenticationRequired"]; 401: components["responses"]["AuthenticationRequired"];
@@ -1493,7 +1596,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/json": components["schemas"]["StudioDashboard"]; "application/json": components["schemas"]["StudioDashboardEnvelope"];
}; };
}; };
401: components["responses"]["AuthenticationRequired"]; 401: components["responses"]["AuthenticationRequired"];
@@ -1527,7 +1630,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/json": components["schemas"]["DocumentPage"]; "application/json": components["schemas"]["DocumentPageEnvelope"];
}; };
}; };
400: components["responses"]["MalformedRequest"]; 400: components["responses"]["MalformedRequest"];
@@ -1565,7 +1668,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/json": components["schemas"]["WorkingCopy"]; "application/json": components["schemas"]["WorkingCopyEnvelope"];
}; };
}; };
400: components["responses"]["MalformedRequest"]; 400: components["responses"]["MalformedRequest"];
@@ -1593,7 +1696,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/json": components["schemas"]["WorkingCopyDetail"]; "application/json": components["schemas"]["WorkingCopyDetailEnvelope"];
}; };
}; };
401: components["responses"]["AuthenticationRequired"]; 401: components["responses"]["AuthenticationRequired"];
@@ -1632,7 +1735,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/json": components["schemas"]["WorkingCopyDetail"]; "application/json": components["schemas"]["WorkingCopyDetailEnvelope"];
}; };
}; };
400: components["responses"]["MalformedRequest"]; 400: components["responses"]["MalformedRequest"];
@@ -1674,7 +1777,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/json": components["schemas"]["ValidationReport"]; "application/json": components["schemas"]["ValidationReportEnvelope"];
}; };
}; };
400: components["responses"]["MalformedRequest"]; 400: components["responses"]["MalformedRequest"];
@@ -1703,7 +1806,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/json": components["schemas"]["PreviewDetail"]; "application/json": components["schemas"]["PreviewDetailEnvelope"];
}; };
}; };
401: components["responses"]["AuthenticationRequired"]; 401: components["responses"]["AuthenticationRequired"];
@@ -1742,7 +1845,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/json": components["schemas"]["PublicPreview"]; "application/json": components["schemas"]["PublicPreviewEnvelope"];
}; };
}; };
400: components["responses"]["MalformedRequest"]; 400: components["responses"]["MalformedRequest"];
@@ -1784,7 +1887,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/json": components["schemas"]["PublishResult"]; "application/json": components["schemas"]["PublishResultEnvelope"];
}; };
}; };
400: components["responses"]["MalformedRequest"]; 400: components["responses"]["MalformedRequest"];
@@ -1818,7 +1921,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/json": components["schemas"]["PublicationPage"]; "application/json": components["schemas"]["PublicationPageEnvelope"];
}; };
}; };
400: components["responses"]["MalformedRequest"]; 400: components["responses"]["MalformedRequest"];
@@ -1858,7 +1961,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/json": components["schemas"]["PublishResult"]; "application/json": components["schemas"]["PublishResultEnvelope"];
}; };
}; };
400: components["responses"]["MalformedRequest"]; 400: components["responses"]["MalformedRequest"];
@@ -1887,7 +1990,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/json": components["schemas"]["PublicationSnapshot"]; "application/json": components["schemas"]["PublicationSnapshotEnvelope"];
}; };
}; };
401: components["responses"]["AuthenticationRequired"]; 401: components["responses"]["AuthenticationRequired"];
@@ -1918,7 +2021,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/json": components["schemas"]["CatalogPage"]; "application/json": components["schemas"]["CatalogPageEnvelope"];
}; };
}; };
400: components["responses"]["MalformedRequest"]; 400: components["responses"]["MalformedRequest"];
@@ -1951,7 +2054,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/json": components["schemas"]["AssetPage"]; "application/json": components["schemas"]["AssetPageEnvelope"];
}; };
}; };
400: components["responses"]["MalformedRequest"]; 400: components["responses"]["MalformedRequest"];
@@ -1989,7 +2092,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/json": components["schemas"]["Asset"]; "application/json": components["schemas"]["AssetEnvelope"];
}; };
}; };
400: components["responses"]["MalformedRequest"]; 400: components["responses"]["MalformedRequest"];
@@ -2019,7 +2122,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/json": components["schemas"]["AssetDetail"]; "application/json": components["schemas"]["AssetDetailEnvelope"];
}; };
}; };
401: components["responses"]["AuthenticationRequired"]; 401: components["responses"]["AuthenticationRequired"];
@@ -2058,7 +2161,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/json": components["schemas"]["Asset"]; "application/json": components["schemas"]["AssetEnvelope"];
}; };
}; };
400: components["responses"]["MalformedRequest"]; 400: components["responses"]["MalformedRequest"];
@@ -1,7 +1,7 @@
openapi: 3.1.0 openapi: 3.1.0
info: info:
title: Tech Log Studio API title: Tech Log Studio API
version: 2.0.0 version: 3.0.0
description: | description: |
Tech Log Studio orchestration 계약이다. Tech Log Studio orchestration 계약이다.
@@ -109,7 +109,7 @@ paths:
tags: [Session] tags: [Session]
summary: 현재 Studio 세션과 CSRF 토큰을 조회한다 summary: 현재 Studio 세션과 CSRF 토큰을 조회한다
responses: 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" } "401": { $ref: "#/components/responses/AuthenticationRequired" }
"403": { $ref: "#/components/responses/AccessDenied" } "403": { $ref: "#/components/responses/AccessDenied" }
"503": { $ref: "#/components/responses/StudioUnavailable" } "503": { $ref: "#/components/responses/StudioUnavailable" }
@@ -123,7 +123,7 @@ paths:
`nextAction`을 포함한 모든 workflow 상태는 서버가 계산한다. `nextAction`을 포함한 모든 workflow 상태는 서버가 계산한다.
Frontend는 여러 endpoint를 조합해 workflow 상태를 재추론하지 않는다. Frontend는 여러 endpoint를 조합해 workflow 상태를 재추론하지 않는다.
responses: 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" } "401": { $ref: "#/components/responses/AuthenticationRequired" }
"403": { $ref: "#/components/responses/AccessDenied" } "403": { $ref: "#/components/responses/AccessDenied" }
"503": { $ref: "#/components/responses/StudioUnavailable" } "503": { $ref: "#/components/responses/StudioUnavailable" }
@@ -150,7 +150,7 @@ paths:
- { $ref: "#/components/parameters/Cursor" } - { $ref: "#/components/parameters/Cursor" }
- { $ref: "#/components/parameters/Limit" } - { $ref: "#/components/parameters/Limit" }
responses: 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" } "400": { $ref: "#/components/responses/MalformedRequest" }
"401": { $ref: "#/components/responses/AuthenticationRequired" } "401": { $ref: "#/components/responses/AuthenticationRequired" }
"403": { $ref: "#/components/responses/AccessDenied" } "403": { $ref: "#/components/responses/AccessDenied" }
@@ -169,7 +169,7 @@ paths:
"201": "201":
description: Created working copy description: Created working copy
headers: { Idempotency-Replayed: { $ref: "#/components/headers/IdempotencyReplayed" } } 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" } "400": { $ref: "#/components/responses/MalformedRequest" }
"401": { $ref: "#/components/responses/AuthenticationRequired" } "401": { $ref: "#/components/responses/AuthenticationRequired" }
"403": { $ref: "#/components/responses/AccessDenied" } "403": { $ref: "#/components/responses/AccessDenied" }
@@ -184,7 +184,7 @@ paths:
tags: [Documents] tags: [Documents]
summary: Get a working copy and its current state summary: Get a working copy and its current state
responses: 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" } "401": { $ref: "#/components/responses/AuthenticationRequired" }
"403": { $ref: "#/components/responses/AccessDenied" } "403": { $ref: "#/components/responses/AccessDenied" }
"404": { $ref: "#/components/responses/DocumentNotFound" } "404": { $ref: "#/components/responses/DocumentNotFound" }
@@ -197,8 +197,8 @@ paths:
편집 가능한 content field만 저장한다. lifecycle 전이는 명시적인 Domain 편집 가능한 content field만 저장한다. lifecycle 전이는 명시적인 Domain
Action이 담당한다. 저장은 Public Projection을 변경하지 않는다. Action이 담당한다. 저장은 Public Projection을 변경하지 않는다.
`expectedVersion` 불일치는 `VERSION_CONFLICT`이며 응답 `ProblemDetails` `expectedVersion` 불일치는 `VERSION_CONFLICT`이며 응답 `error.details`
`latestDocument`로 현재 상태를 함께 제공한다. (`VersionConflictDetails`)의 `latestDocument`로 현재 상태를 함께 제공한다.
parameters: parameters:
- { $ref: "#/components/parameters/IdempotencyKey" } - { $ref: "#/components/parameters/IdempotencyKey" }
- { $ref: "#/components/parameters/CsrfToken" } - { $ref: "#/components/parameters/CsrfToken" }
@@ -207,7 +207,7 @@ paths:
"200": "200":
description: Saved working-copy detail description: Saved working-copy detail
headers: { Idempotency-Replayed: { $ref: "#/components/headers/IdempotencyReplayed" } } 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" } "400": { $ref: "#/components/responses/MalformedRequest" }
"401": { $ref: "#/components/responses/AuthenticationRequired" } "401": { $ref: "#/components/responses/AuthenticationRequired" }
"403": { $ref: "#/components/responses/AccessDenied" } "403": { $ref: "#/components/responses/AccessDenied" }
@@ -244,7 +244,7 @@ paths:
"200": "200":
description: Validation report description: Validation report
headers: { Idempotency-Replayed: { $ref: "#/components/headers/IdempotencyReplayed" } } 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" } "400": { $ref: "#/components/responses/MalformedRequest" }
"401": { $ref: "#/components/responses/AuthenticationRequired" } "401": { $ref: "#/components/responses/AuthenticationRequired" }
"403": { $ref: "#/components/responses/AccessDenied" } "403": { $ref: "#/components/responses/AccessDenied" }
@@ -264,7 +264,7 @@ paths:
anonymous `/preview/{token}` 계약은 폐기되었다. Preview는 인증된 anonymous `/preview/{token}` 계약은 폐기되었다. Preview는 인증된
Studio API로만 조회한다. Studio API로만 조회한다.
responses: 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" } "401": { $ref: "#/components/responses/AuthenticationRequired" }
"403": { $ref: "#/components/responses/AccessDenied" } "403": { $ref: "#/components/responses/AccessDenied" }
"404": { $ref: "#/components/responses/PreviewNotFound" } "404": { $ref: "#/components/responses/PreviewNotFound" }
@@ -285,7 +285,7 @@ paths:
"201": "201":
description: Created preview description: Created preview
headers: { Idempotency-Replayed: { $ref: "#/components/headers/IdempotencyReplayed" } } 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" } "400": { $ref: "#/components/responses/MalformedRequest" }
"401": { $ref: "#/components/responses/AuthenticationRequired" } "401": { $ref: "#/components/responses/AuthenticationRequired" }
"403": { $ref: "#/components/responses/AccessDenied" } "403": { $ref: "#/components/responses/AccessDenied" }
@@ -329,7 +329,7 @@ paths:
"200": "200":
description: Publication aggregate and immutable event description: Publication aggregate and immutable event
headers: { Idempotency-Replayed: { $ref: "#/components/headers/IdempotencyReplayed" } } 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" } "400": { $ref: "#/components/responses/MalformedRequest" }
"401": { $ref: "#/components/responses/AuthenticationRequired" } "401": { $ref: "#/components/responses/AuthenticationRequired" }
"403": { $ref: "#/components/responses/AccessDenied" } "403": { $ref: "#/components/responses/AccessDenied" }
@@ -350,7 +350,7 @@ paths:
- { $ref: "#/components/parameters/Cursor" } - { $ref: "#/components/parameters/Cursor" }
- { $ref: "#/components/parameters/Limit" } - { $ref: "#/components/parameters/Limit" }
responses: 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" } "400": { $ref: "#/components/responses/MalformedRequest" }
"401": { $ref: "#/components/responses/AuthenticationRequired" } "401": { $ref: "#/components/responses/AuthenticationRequired" }
"403": { $ref: "#/components/responses/AccessDenied" } "403": { $ref: "#/components/responses/AccessDenied" }
@@ -379,7 +379,7 @@ paths:
"200": "200":
description: Updated publication aggregate and event description: Updated publication aggregate and event
headers: { Idempotency-Replayed: { $ref: "#/components/headers/IdempotencyReplayed" } } 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" } "400": { $ref: "#/components/responses/MalformedRequest" }
"401": { $ref: "#/components/responses/AuthenticationRequired" } "401": { $ref: "#/components/responses/AuthenticationRequired" }
"403": { $ref: "#/components/responses/AccessDenied" } "403": { $ref: "#/components/responses/AccessDenied" }
@@ -401,7 +401,7 @@ paths:
`UNPUBLISHED` Event는 자체 snapshot을 갖지 않는다. 이 경우 `UNPUBLISHED` Event는 자체 snapshot을 갖지 않는다. 이 경우
`sourcePublishedEventId`가 가리키는 마지막 공개 Snapshot을 사용한다. `sourcePublishedEventId`가 가리키는 마지막 공개 Snapshot을 사용한다.
responses: 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" } "401": { $ref: "#/components/responses/AuthenticationRequired" }
"403": { $ref: "#/components/responses/AccessDenied" } "403": { $ref: "#/components/responses/AccessDenied" }
"404": { $ref: "#/components/responses/PublicationSnapshotNotFound" } "404": { $ref: "#/components/responses/PublicationSnapshotNotFound" }
@@ -430,7 +430,7 @@ paths:
- { $ref: "#/components/parameters/Cursor" } - { $ref: "#/components/parameters/Cursor" }
- { $ref: "#/components/parameters/Limit" } - { $ref: "#/components/parameters/Limit" }
responses: 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" } "400": { $ref: "#/components/responses/MalformedRequest" }
"401": { $ref: "#/components/responses/AuthenticationRequired" } "401": { $ref: "#/components/responses/AuthenticationRequired" }
"403": { $ref: "#/components/responses/AccessDenied" } "403": { $ref: "#/components/responses/AccessDenied" }
@@ -449,7 +449,7 @@ paths:
- { $ref: "#/components/parameters/Cursor" } - { $ref: "#/components/parameters/Cursor" }
- { $ref: "#/components/parameters/Limit" } - { $ref: "#/components/parameters/Limit" }
responses: 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" } "400": { $ref: "#/components/responses/MalformedRequest" }
"401": { $ref: "#/components/responses/AuthenticationRequired" } "401": { $ref: "#/components/responses/AuthenticationRequired" }
"403": { $ref: "#/components/responses/AccessDenied" } "403": { $ref: "#/components/responses/AccessDenied" }
@@ -483,7 +483,7 @@ paths:
"201": "201":
description: Stored asset description: Stored asset
headers: { Idempotency-Replayed: { $ref: "#/components/headers/IdempotencyReplayed" } } 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" } "400": { $ref: "#/components/responses/MalformedRequest" }
"401": { $ref: "#/components/responses/AuthenticationRequired" } "401": { $ref: "#/components/responses/AuthenticationRequired" }
"403": { $ref: "#/components/responses/AccessDenied" } "403": { $ref: "#/components/responses/AccessDenied" }
@@ -500,7 +500,7 @@ paths:
tags: [Assets] tags: [Assets]
summary: Get an asset with its usage summary: Get an asset with its usage
responses: 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" } "401": { $ref: "#/components/responses/AuthenticationRequired" }
"403": { $ref: "#/components/responses/AccessDenied" } "403": { $ref: "#/components/responses/AccessDenied" }
"404": { $ref: "#/components/responses/AssetNotFound" } "404": { $ref: "#/components/responses/AssetNotFound" }
@@ -520,7 +520,7 @@ paths:
"200": "200":
description: Updated asset description: Updated asset
headers: { Idempotency-Replayed: { $ref: "#/components/headers/IdempotencyReplayed" } } 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" } "400": { $ref: "#/components/responses/MalformedRequest" }
"401": { $ref: "#/components/responses/AuthenticationRequired" } "401": { $ref: "#/components/responses/AuthenticationRequired" }
"403": { $ref: "#/components/responses/AccessDenied" } "403": { $ref: "#/components/responses/AccessDenied" }
@@ -593,26 +593,26 @@ components:
IdempotencyReplayed: { description: True when the original result was replayed, schema: { type: boolean } } IdempotencyReplayed: { description: True when the original result was replayed, schema: { type: boolean } }
responses: responses:
MalformedRequest: { description: Malformed request, x-error-codes: [REQUEST_VALIDATION_FAILED], 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/problem+json: { schema: { $ref: "#/components/schemas/ProblemDetails" } } } } 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/problem+json: { schema: { $ref: "#/components/schemas/ProblemDetails" } } } } 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/problem+json: { schema: { $ref: "#/components/schemas/ProblemDetails" } } } } 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/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/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } }
PublicationNotFound: { description: Publication not found, x-error-codes: [PUBLICATION_NOT_FOUND], content: { application/problem+json: { schema: { $ref: "#/components/schemas/ProblemDetails" } } } } 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/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/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } }
AssetNotFound: { description: Asset not found, x-error-codes: [ASSET_NOT_FOUND], content: { application/problem+json: { schema: { $ref: "#/components/schemas/ProblemDetails" } } } } AssetNotFound: { description: Asset not found, x-error-codes: [ASSET_NOT_FOUND], content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } }
CommandConflict: CommandConflict:
description: | description: |
Command conflicts with current state, freshness, or idempotency. Command conflicts with current state, freshness, or idempotency.
`ASSET_IN_USE`는 사용 중이거나 공개 이력이 있는 Asset의 hard delete 시도다. `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] 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" } } } content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } }
RequestValidationFailed: { description: Request validation failed, x-error-codes: [REQUEST_VALIDATION_FAILED], content: { application/problem+json: { schema: { $ref: "#/components/schemas/ProblemDetails" } } } } RequestValidationFailed: { description: Request validation failed, x-error-codes: [REQUEST_VALIDATION_FAILED], content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } }
PreviewRejected: PreviewRejected:
description: Preview 생성이 도메인 규칙으로 거절되었다 description: Preview 생성이 도메인 규칙으로 거절되었다
x-error-codes: [REQUEST_VALIDATION_FAILED, VALIDATION_FAILED, ASSET_NOT_READY, ASSET_QUARANTINED] 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: PublishRejected:
description: | description: |
Publication validation이 실패했다. Publication validation이 실패했다.
@@ -620,16 +620,205 @@ components:
`WARNING_ACKNOWLEDGEMENT_REQUIRED`는 `acknowledgedWarningCodes`가 `WARNING_ACKNOWLEDGEMENT_REQUIRED`는 `acknowledgedWarningCodes`가
현재 Validation의 WARNING 집합을 덮지 못한 경우다. 현재 Validation의 WARNING 집합을 덮지 못한 경우다.
x-error-codes: [REQUEST_VALIDATION_FAILED, VALIDATION_FAILED, WARNING_ACKNOWLEDGEMENT_REQUIRED, ASSET_NOT_READY, ASSET_QUARANTINED] 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: AssetRejected:
description: Asset metadata 변경이 거절되었다 description: Asset metadata 변경이 거절되었다
x-error-codes: [REQUEST_VALIDATION_FAILED, ASSET_NOT_READY, ASSET_QUARANTINED] x-error-codes: [REQUEST_VALIDATION_FAILED, ASSET_NOT_READY, ASSET_QUARANTINED]
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/problem+json: { schema: { $ref: "#/components/schemas/ProblemDetails" } } } } 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/problem+json: { schema: { $ref: "#/components/schemas/ProblemDetails" } } } } 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/problem+json: { schema: { $ref: "#/components/schemas/ProblemDetails" } } } } StudioUnavailable: { description: Studio unavailable, x-error-codes: [STUDIO_UNAVAILABLE], content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } }
schemas: 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 # ---------------------------------------------------------------- session
StudioSession: StudioSession:
type: object type: object
@@ -1645,51 +1834,3 @@ components:
pattern: "^(?:/(?:[^~/]|~0|~1)*)*$" pattern: "^(?:/(?:[^~/]|~0|~1)*)*$"
description: JSON Pointer to the invalid field description: JSON Pointer to the invalid field
message: { type: string, minLength: 1, maxLength: 1000 } 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) => const passthrough = <T>(schemaId: string) =>
zodValidator<T>(schemaId, z.unknown() as unknown as z.ZodType<T>); 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({ .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[]]), 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(); .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/네트워크는 불확정이다. */ /** 4xx 도메인 거절은 적용되지 않았음이 확정이다. 5xx/네트워크는 불확정이다. */
const COMMAND_EFFECT: CommandEffectDescriptor<z.output<typeof problemSchema>> = const COMMAND_EFFECT: CommandEffectDescriptor<StudioProblemShape> =
Object.freeze({ Object.freeze({
successEffect: "APPLIED_CONFIRMED" as const, successEffect: "APPLIED_CONFIRMED" as const,
classifyProblem({ status }: Readonly<{ status: number; problem: unknown }>) { classifyProblem({ status }: Readonly<{ status: number; problem: unknown }>) {
@@ -93,7 +145,7 @@ function safeOperation(
method: "GET" as const, method: "GET" as const,
pathTemplate, pathTemplate,
inputValidator: passthrough(`${operationId}Input`), inputValidator: passthrough(`${operationId}Input`),
outputValidator: passthrough(`${operationId}Output`), outputValidator: envelopeData(`${operationId}Output`),
problemValidator: PROBLEM, problemValidator: PROBLEM,
acceptedStatuses: Object.freeze([200]), acceptedStatuses: Object.freeze([200]),
emptyBodyStatuses: Object.freeze([]), emptyBodyStatuses: Object.freeze([]),
@@ -141,7 +193,7 @@ function keyedOperation(
method, method,
pathTemplate, pathTemplate,
inputValidator: passthrough(`${operationId}Input`), inputValidator: passthrough(`${operationId}Input`),
outputValidator: passthrough(`${operationId}Output`), outputValidator: envelopeData(`${operationId}Output`),
problemValidator: PROBLEM, problemValidator: PROBLEM,
acceptedStatuses: Object.freeze([options.acceptedStatus]), acceptedStatuses: Object.freeze([options.acceptedStatus]),
emptyBodyStatuses: Object.freeze(options.acceptedStatus === 204 ? [204] : []), emptyBodyStatuses: Object.freeze(options.acceptedStatus === 204 ? [204] : []),
@@ -20,6 +20,14 @@ const transport = () =>
const svg = () => new File(["<svg/>"], "b.svg", { type: "image/svg+xml" }); const svg = () => new File(["<svg/>"], "b.svg", { type: "image/svg+xml" });
// wire format은 봉투다 (ADR-006) — `uploadStudioAsset`도 다른 operation과
// 같은 `AssetEnvelope`/`ErrorEnvelope`를 쓴다.
const META = { requestId: "r", traceId: "t", correlationId: null, page: null };
const dataEnvelope = (data: unknown) => ({ success: true, data, meta: META });
const errorEnvelope = (
error: Readonly<{ code: string; category: string; message: string; retryable: boolean }>,
) => ({ success: false, error, meta: META });
test("posts multipart form data with the supplied headers", async () => { test("posts multipart form data with the supplied headers", async () => {
let seen: { kind: unknown; alt: unknown; csrf: string | null; key: string | null } | null = null; let seen: { kind: unknown; alt: unknown; csrf: string | null; key: string | null } | null = null;
@@ -32,7 +40,7 @@ test("posts multipart form data with the supplied headers", async () => {
csrf: request.headers.get("X-CSRF-TOKEN"), csrf: request.headers.get("X-CSRF-TOKEN"),
key: request.headers.get("Idempotency-Key"), key: request.headers.get("Idempotency-Key"),
}; };
return HttpResponse.json({ id: "a", managementStatus: "READY" }, { status: 201 }); return HttpResponse.json(dataEnvelope({ id: "a", managementStatus: "READY" }), { status: 201 });
}), }),
); );
@@ -53,7 +61,7 @@ test("does not set content-type itself so the boundary survives", async () => {
server.use( server.use(
http.post(`${BASE}/api/v1/studio/assets`, ({ request }) => { http.post(`${BASE}/api/v1/studio/assets`, ({ request }) => {
contentType = request.headers.get("content-type"); contentType = request.headers.get("content-type");
return HttpResponse.json({ id: "a" }, { status: 201 }); return HttpResponse.json(dataEnvelope({ id: "a" }), { status: 201 });
}), }),
); );
@@ -66,14 +74,13 @@ test("maps 413 onto PAYLOAD_TOO_LARGE", async () => {
server.use( server.use(
http.post(`${BASE}/api/v1/studio/assets`, () => http.post(`${BASE}/api/v1/studio/assets`, () =>
HttpResponse.json( HttpResponse.json(
{ errorEnvelope({
type: "https://techlog.local/problems/payload-too-large",
title: "PAYLOAD_TOO_LARGE",
status: 413,
detail: "파일이 너무 큽니다.",
code: "PAYLOAD_TOO_LARGE", code: "PAYLOAD_TOO_LARGE",
}, category: "VALIDATION",
{ status: 413, headers: { "content-type": "application/problem+json" } }, message: "파일이 너무 큽니다.",
retryable: false,
}),
{ status: 413 },
), ),
), ),
); );
@@ -92,14 +99,13 @@ test("maps 415 onto UNSUPPORTED_MEDIA_TYPE", async () => {
server.use( server.use(
http.post(`${BASE}/api/v1/studio/assets`, () => http.post(`${BASE}/api/v1/studio/assets`, () =>
HttpResponse.json( HttpResponse.json(
{ errorEnvelope({
type: "https://techlog.local/problems/unsupported-media-type",
title: "UNSUPPORTED_MEDIA_TYPE",
status: 415,
detail: "지원하지 않는 형식입니다.",
code: "UNSUPPORTED_MEDIA_TYPE", code: "UNSUPPORTED_MEDIA_TYPE",
}, category: "VALIDATION",
{ status: 415, headers: { "content-type": "application/problem+json" } }, message: "지원하지 않는 형식입니다.",
retryable: false,
}),
{ status: 415 },
), ),
), ),
); );
@@ -128,14 +134,15 @@ test("falls back to STUDIO_UNAVAILABLE for an uncontracted problem code instead
server.use( server.use(
http.post(`${BASE}/api/v1/studio/assets`, () => http.post(`${BASE}/api/v1/studio/assets`, () =>
HttpResponse.json( HttpResponse.json(
{ // 봉투 뼈대는 정상이지만 `code`가 계약 밖이다 — envelope 검증은
type: "https://techlog.local/problems/teapot", // 통과하되(구조는 맞음) `apiErrorSchema`의 code enum에서 걸린다.
title: "IM_A_TEAPOT", errorEnvelope({
status: 418,
detail: "이 서버는 커피를 내릴 수 없습니다.",
code: "IM_A_TEAPOT", code: "IM_A_TEAPOT",
}, category: "INTERNAL",
{ status: 418, headers: { "content-type": "application/problem+json" } }, message: "이 서버는 커피를 내릴 수 없습니다.",
retryable: false,
}),
{ status: 418 },
), ),
), ),
); );
@@ -187,7 +194,7 @@ test("maps an aborted upload onto a non-retryable STUDIO_UNAVAILABLE", async ()
server.use( server.use(
http.post(`${BASE}/api/v1/studio/assets`, async () => { http.post(`${BASE}/api/v1/studio/assets`, async () => {
await new Promise((resolve) => setTimeout(resolve, 50)); await new Promise((resolve) => setTimeout(resolve, 50));
return HttpResponse.json({ id: "a" }, { status: 201 }); return HttpResponse.json(dataEnvelope({ id: "a" }), { status: 201 });
}), }),
); );
@@ -18,7 +18,7 @@ test("vendored contract matches the recorded canonical digest", () => {
test("canonical source records the pinned revision and version", () => { test("canonical source records the pinned revision and version", () => {
assert.equal(canonicalSource.packageId, "@tech-log/studio-contract"); assert.equal(canonicalSource.packageId, "@tech-log/studio-contract");
assert.equal(canonicalSource.version, "2.0.0"); assert.equal(canonicalSource.version, "3.0.0");
// revision은 생성 시점에 기록된다. canonical 저장소는 활발히 편집 중이므로 // revision은 생성 시점에 기록된다. canonical 저장소는 활발히 편집 중이므로
// 특정 값을 박아두면 계약이 그대로인데도 테스트가 깨진다. 형식만 고정한다. // 특정 값을 박아두면 계약이 그대로인데도 테스트가 깨진다. 형식만 고정한다.
assert.match(canonicalSource.sourceRevision, /^[0-9a-f]{7,64}$/); assert.match(canonicalSource.sourceRevision, /^[0-9a-f]{7,64}$/);
@@ -43,6 +43,16 @@ beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => server.resetHandlers()); afterEach(() => server.resetHandlers());
afterAll(() => server.close()); afterAll(() => server.close());
// wire format은 봉투다 (ADR-006) — 이 파일은 실제 platform 계약 런타임
// (`createContractHttpExecutor`)을 조립하므로 `outputValidator`
// (`envelopeData`)가 그대로 걸린다. 봉투가 아닌 본문은 SUCCESS_SCHEMA_INVALID로
// 거절된다.
const dataEnvelope = (data: unknown) => ({
success: true,
data,
meta: { requestId: "r", traceId: "t", correlationId: null, page: null },
});
function scopeSnapshot() { function scopeSnapshot() {
return Object.freeze({ return Object.freeze({
generation: 1, generation: 1,
@@ -152,13 +162,15 @@ test(
server.use( server.use(
http.get(`${BASE}/api/v1/studio/session`, () => { http.get(`${BASE}/api/v1/studio/session`, () => {
sessionCalls += 1; sessionCalls += 1;
return HttpResponse.json({ return HttpResponse.json(
authenticated: true, dataEnvelope({
displayName: "테스터", authenticated: true,
roles: ["editor"], displayName: "테스터",
csrfToken: "csrf-token-1", roles: ["editor"],
csrfHeaderName: "X-CSRF-TOKEN", csrfToken: "csrf-token-1",
}); csrfHeaderName: "X-CSRF-TOKEN",
}),
);
}), }),
); );
@@ -166,10 +178,7 @@ test(
server.use( server.use(
http.get(`${BASE}/api/v1/studio/dashboard`, ({ request }) => { http.get(`${BASE}/api/v1/studio/dashboard`, ({ request }) => {
jsonRequestHeader = request.headers.get("x-csrf-token"); jsonRequestHeader = request.headers.get("x-csrf-token");
return HttpResponse.json({ return HttpResponse.json(dataEnvelope({ documentTotals: {}, workflowSections: [] }));
documentTotals: {},
workflowSections: [],
});
}), }),
); );
@@ -177,7 +186,10 @@ test(
server.use( server.use(
http.post(`${BASE}/api/v1/studio/assets`, ({ request }) => { http.post(`${BASE}/api/v1/studio/assets`, ({ request }) => {
uploadRequestHeader = request.headers.get("X-CSRF-TOKEN"); uploadRequestHeader = request.headers.get("X-CSRF-TOKEN");
return HttpResponse.json({ id: "a", managementStatus: "READY" }, { status: 201 }); return HttpResponse.json(
dataEnvelope({ id: "a", managementStatus: "READY" }),
{ status: 201 },
);
}), }),
); );
@@ -232,13 +244,15 @@ test(
server.use( server.use(
http.get(`${BASE}/api/v1/studio/session`, () => { http.get(`${BASE}/api/v1/studio/session`, () => {
sessionCalls += 1; sessionCalls += 1;
return HttpResponse.json({ return HttpResponse.json(
authenticated: true, dataEnvelope({
displayName: "테스터", authenticated: true,
roles: ["editor"], displayName: "테스터",
csrfToken: `csrf-token-${sessionCalls}`, roles: ["editor"],
csrfHeaderName: "X-CSRF-TOKEN", csrfToken: `csrf-token-${sessionCalls}`,
}); csrfHeaderName: "X-CSRF-TOKEN",
}),
);
}), }),
); );
@@ -250,7 +264,7 @@ test(
// Second call: succeeds with whatever token is presented. // Second call: succeeds with whatever token is presented.
return dashboardHeaders.length === 1 return dashboardHeaders.length === 1
? new HttpResponse(null, { status: 403 }) ? new HttpResponse(null, { status: 403 })
: HttpResponse.json({ documentTotals: {}, workflowSections: [] }); : HttpResponse.json(dataEnvelope({ documentTotals: {}, workflowSections: [] }));
}), }),
); );
@@ -294,13 +308,15 @@ test(
server.use( server.use(
http.get(`${BASE}/api/v1/studio/session`, () => { http.get(`${BASE}/api/v1/studio/session`, () => {
sessionCalls += 1; sessionCalls += 1;
return HttpResponse.json({ return HttpResponse.json(
authenticated: true, dataEnvelope({
displayName: "테스터", authenticated: true,
roles: ["editor"], displayName: "테스터",
csrfToken: `csrf-token-${sessionCalls}`, roles: ["editor"],
csrfHeaderName: "X-CSRF-TOKEN", csrfToken: `csrf-token-${sessionCalls}`,
}); csrfHeaderName: "X-CSRF-TOKEN",
}),
);
}), }),
); );
@@ -308,7 +324,7 @@ test(
server.use( server.use(
http.get(`${BASE}/api/v1/studio/dashboard`, ({ request }) => { http.get(`${BASE}/api/v1/studio/dashboard`, ({ request }) => {
dashboardHeaders.push(request.headers.get("x-csrf-token")); dashboardHeaders.push(request.headers.get("x-csrf-token"));
return HttpResponse.json({ documentTotals: {}, workflowSections: [] }); return HttpResponse.json(dataEnvelope({ documentTotals: {}, workflowSections: [] }));
}), }),
); );
server.use( server.use(
@@ -0,0 +1,42 @@
import { describe, expect, it } from "vitest";
import { envelopeData, envelopeError } from "../../../src/features/tech-log/contracts/tech-log-studio-contract-contribution.ts";
describe("studio 봉투 언랩", () => {
it("성공 봉투에서 data를 꺼낸다", () => {
const result = envelopeData("getStudioSessionOutput").safeParse({
success: true,
data: { authenticated: true, displayName: "d", roles: [], csrfToken: "t", csrfHeaderName: "X-CSRF-TOKEN" },
meta: { requestId: "r", traceId: "t", correlationId: null, page: null },
});
expect(result.success).toBe(true);
if (result.success) expect(result.data).toMatchObject({ displayName: "d" });
});
it("봉투가 아닌 본문을 거절한다", () => {
const result = envelopeData("getStudioSessionOutput").safeParse({ displayName: "d" });
expect(result.success).toBe(false);
});
it("오류 봉투를 ProblemDetails 형태로 옮긴다", () => {
const result = envelopeError().safeParse({
success: false,
error: { code: "VERSION_CONFLICT", category: "CONFLICT", message: "conflict", retryable: false, details: null },
meta: { requestId: "r", traceId: "tr", correlationId: null, page: null },
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.code).toBe("VERSION_CONFLICT");
expect(result.data.status).toBe(0);
expect(result.data.title).toBe("VERSION_CONFLICT");
}
});
it("계약 밖 코드를 거절한다", () => {
const result = envelopeError().safeParse({
success: false,
error: { code: "NOT_A_STUDIO_CODE", category: "INTERNAL", message: "x", retryable: false, details: null },
meta: { requestId: "r", traceId: "tr", correlationId: null, page: null },
});
expect(result.success).toBe(false);
});
});