From 25a6b63d27ef245a3e77497e637fcee44e1e310c Mon Sep 17 00:00:00 2001 From: DongHyeonka Date: Tue, 18 Aug 2026 21:55:45 +0900 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20Studio=20=EC=9D=91=EB=8B=B5=20?= =?UTF-8?q?=EB=B4=89=ED=88=AC=EB=A5=BC=20=EC=A0=84=EC=86=A1=20=EA=B2=BD?= =?UTF-8?q?=EA=B3=84=EC=97=90=EC=84=9C=20=EC=96=B8=EB=9E=A9=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../adapters/http/asset-upload-transport.ts | 40 ++- .../adapters/http/studio-error-mapping.ts | 13 +- .../contracts/studio/canonical-source.json | 6 +- .../tech-log/contracts/studio/contract.ts | 30 +- .../tech-log/contracts/studio/generated.ts | 213 ++++++++---- .../contracts/studio/studio-api.openapi.yaml | 311 +++++++++++++----- .../tech-log-studio-contract-contribution.ts | 72 +++- .../tech-log/asset-upload-transport.test.ts | 55 ++-- .../tech-log/contract-generation.test.ts | 2 +- .../tech-log/studio-csrf-composition.test.ts | 72 ++-- .../tech-log/studio-envelope-unwrap.test.ts | 42 +++ 11 files changed, 640 insertions(+), 216 deletions(-) create mode 100644 tests/features/tech-log/studio-envelope-unwrap.test.ts diff --git a/src/features/tech-log/adapters/http/asset-upload-transport.ts b/src/features/tech-log/adapters/http/asset-upload-transport.ts index 5347aec..85a6540 100644 --- a/src/features/tech-log/adapters/http/asset-upload-transport.ts +++ b/src/features/tech-log/adapters/http/asset-upload-transport.ts @@ -1,11 +1,21 @@ import { StudioGatewayError } from "../../application/ports/studio-gateway-error.ts"; import type { UploadAssetForm } from "../../application/ports/studio-asset-gateway.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 { STUDIO_ERROR_CODES } from "./studio-error-mapping.ts"; const CODES = new Set(STUDIO_ERROR_CODES); +/** + * canonical `uploadStudioAsset`도 다른 18개 operation과 같은 봉투(ADR-006)를 + * 쓴다 — 이 seam만 일반 계약 런타임을 안 거칠 뿐이지 wire format은 같다. + * 그래서 `tech-log-studio-contract-contribution.ts`의 언랩 validator를 그대로 + * 재사용한다: 봉투 뼈대 검증 로직이 두 곳에서 따로 드리프트하는 것을 막는다. + */ +const UPLOAD_DATA = envelopeData("uploadStudioAssetOutput"); +const UPLOAD_PROBLEM = envelopeError(); + /** * The contract runtime can only express `requestBody: "NONE" | "JSON"` and the * low-level client always serializes the body as JSON (see @@ -81,8 +91,9 @@ export function createAssetUploadTransport( } if (response.status === 201 || response.status === 200) { + let body: unknown; try { - return (await response.json()) as Asset; + body = await response.json(); } catch { // M1 (fix round 1). This port's contract is `StudioGatewayError`; // 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.`, ); } + 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 { - problem = (await response.json()) as ProblemDetails; + problemBody = await response.json(); } catch { - problem = null; + problemBody = null; } - if (problem && typeof problem.code === "string" && CODES.has(problem.code)) { - throw new StudioGatewayError(problem); + const parsedProblem = problemBody === null ? null : UPLOAD_PROBLEM.safeParse(problemBody); + 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 // hardcoded 503 default) so `http-studio-asset-gateway.ts`'s diff --git a/src/features/tech-log/adapters/http/studio-error-mapping.ts b/src/features/tech-log/adapters/http/studio-error-mapping.ts index e34fb8c..a3c2814 100644 --- a/src/features/tech-log/adapters/http/studio-error-mapping.ts +++ b/src/features/tech-log/adapters/http/studio-error-mapping.ts @@ -57,13 +57,20 @@ export function toStudioGatewayError( ): StudioGatewayError { switch (outcome.kind) { 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)) { - return new StudioGatewayError(problem); + return new StudioGatewayError({ ...problem, status }); } return synthetic( "STUDIO_UNAVAILABLE", - outcome.metadata.status, + status, `${operationId} returned an uncontracted problem code.`, false, ); diff --git a/src/features/tech-log/contracts/studio/canonical-source.json b/src/features/tech-log/contracts/studio/canonical-source.json index a0a93d7..7ad0063 100644 --- a/src/features/tech-log/contracts/studio/canonical-source.json +++ b/src/features/tech-log/contracts/studio/canonical-source.json @@ -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", diff --git a/src/features/tech-log/contracts/studio/contract.ts b/src/features/tech-log/contracts/studio/contract.ts index 7a77f17..df28d6c 100644 --- a/src/features/tech-log/contracts/studio/contract.ts +++ b/src/features/tech-log/contracts/studio/contract.ts @@ -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"]; diff --git a/src/features/tech-log/contracts/studio/generated.ts b/src/features/tech-log/contracts/studio/generated.ts index 309bc03..423d857 100644 --- a/src/features/tech-log/contracts/studio/generated.ts +++ b/src/features/tech-log/contracts/studio/generated.ts @@ -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; 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"]; diff --git a/src/features/tech-log/contracts/studio/studio-api.openapi.yaml b/src/features/tech-log/contracts/studio/studio-api.openapi.yaml index 52cebfa..0f5b320 100644 --- a/src/features/tech-log/contracts/studio/studio-api.openapi.yaml +++ b/src/features/tech-log/contracts/studio/studio-api.openapi.yaml @@ -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 스키마는 그대로 두고 + # 응답만 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 } diff --git a/src/features/tech-log/contracts/tech-log-studio-contract-contribution.ts b/src/features/tech-log/contracts/tech-log-studio-contract-contribution.ts index 2d70c2a..99f6fa3 100644 --- a/src/features/tech-log/contracts/tech-log-studio-contract-contribution.ts +++ b/src/features/tech-log/contracts/tech-log-studio-contract-contribution.ts @@ -51,21 +51,73 @@ function zodValidator(schemaId: string, schema: z.ZodType): RuntimeValidat const passthrough = (schemaId: string) => zodValidator(schemaId, z.unknown() as unknown as z.ZodType); -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 = (schemaId: string): RuntimeValidator => + zodValidator( + schemaId, + z + .object({ success: z.literal(true), data: z.unknown(), meta: metaSchema }) + .loose() + .transform((envelope) => envelope.data as T) as unknown as z.ZodType, + ); + +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 => + zodValidator( + "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, + ); + +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> = +const COMMAND_EFFECT: CommandEffectDescriptor = 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] : []), diff --git a/tests/features/tech-log/asset-upload-transport.test.ts b/tests/features/tech-log/asset-upload-transport.test.ts index 1ca9522..590e9b4 100644 --- a/tests/features/tech-log/asset-upload-transport.test.ts +++ b/tests/features/tech-log/asset-upload-transport.test.ts @@ -20,6 +20,14 @@ const transport = () => const svg = () => new File([""], "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 () => { 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"), 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( http.post(`${BASE}/api/v1/studio/assets`, ({ request }) => { 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( http.post(`${BASE}/api/v1/studio/assets`, () => HttpResponse.json( - { - type: "https://techlog.local/problems/payload-too-large", - title: "PAYLOAD_TOO_LARGE", - status: 413, - detail: "파일이 너무 큽니다.", + errorEnvelope({ code: "PAYLOAD_TOO_LARGE", - }, - { status: 413, headers: { "content-type": "application/problem+json" } }, + category: "VALIDATION", + message: "파일이 너무 큽니다.", + retryable: false, + }), + { status: 413 }, ), ), ); @@ -92,14 +99,13 @@ test("maps 415 onto UNSUPPORTED_MEDIA_TYPE", async () => { server.use( http.post(`${BASE}/api/v1/studio/assets`, () => HttpResponse.json( - { - type: "https://techlog.local/problems/unsupported-media-type", - title: "UNSUPPORTED_MEDIA_TYPE", - status: 415, - detail: "지원하지 않는 형식입니다.", + errorEnvelope({ code: "UNSUPPORTED_MEDIA_TYPE", - }, - { status: 415, headers: { "content-type": "application/problem+json" } }, + category: "VALIDATION", + message: "지원하지 않는 형식입니다.", + retryable: false, + }), + { status: 415 }, ), ), ); @@ -128,14 +134,15 @@ test("falls back to STUDIO_UNAVAILABLE for an uncontracted problem code instead server.use( http.post(`${BASE}/api/v1/studio/assets`, () => HttpResponse.json( - { - type: "https://techlog.local/problems/teapot", - title: "IM_A_TEAPOT", - status: 418, - detail: "이 서버는 커피를 내릴 수 없습니다.", + // 봉투 뼈대는 정상이지만 `code`가 계약 밖이다 — envelope 검증은 + // 통과하되(구조는 맞음) `apiErrorSchema`의 code enum에서 걸린다. + errorEnvelope({ code: "IM_A_TEAPOT", - }, - { status: 418, headers: { "content-type": "application/problem+json" } }, + category: "INTERNAL", + message: "이 서버는 커피를 내릴 수 없습니다.", + retryable: false, + }), + { status: 418 }, ), ), ); @@ -187,7 +194,7 @@ test("maps an aborted upload onto a non-retryable STUDIO_UNAVAILABLE", async () server.use( http.post(`${BASE}/api/v1/studio/assets`, async () => { await new Promise((resolve) => setTimeout(resolve, 50)); - return HttpResponse.json({ id: "a" }, { status: 201 }); + return HttpResponse.json(dataEnvelope({ id: "a" }), { status: 201 }); }), ); diff --git a/tests/features/tech-log/contract-generation.test.ts b/tests/features/tech-log/contract-generation.test.ts index c02fc67..d90e769 100644 --- a/tests/features/tech-log/contract-generation.test.ts +++ b/tests/features/tech-log/contract-generation.test.ts @@ -18,7 +18,7 @@ test("vendored contract matches the recorded canonical digest", () => { test("canonical source records the pinned revision and version", () => { assert.equal(canonicalSource.packageId, "@tech-log/studio-contract"); - assert.equal(canonicalSource.version, "2.0.0"); + assert.equal(canonicalSource.version, "3.0.0"); // revision은 생성 시점에 기록된다. canonical 저장소는 활발히 편집 중이므로 // 특정 값을 박아두면 계약이 그대로인데도 테스트가 깨진다. 형식만 고정한다. assert.match(canonicalSource.sourceRevision, /^[0-9a-f]{7,64}$/); diff --git a/tests/features/tech-log/studio-csrf-composition.test.ts b/tests/features/tech-log/studio-csrf-composition.test.ts index 7904c4b..c5ab3f4 100644 --- a/tests/features/tech-log/studio-csrf-composition.test.ts +++ b/tests/features/tech-log/studio-csrf-composition.test.ts @@ -43,6 +43,16 @@ beforeAll(() => server.listen({ onUnhandledRequest: "error" })); afterEach(() => server.resetHandlers()); 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() { return Object.freeze({ generation: 1, @@ -152,13 +162,15 @@ test( server.use( http.get(`${BASE}/api/v1/studio/session`, () => { sessionCalls += 1; - return HttpResponse.json({ - authenticated: true, - displayName: "테스터", - roles: ["editor"], - csrfToken: "csrf-token-1", - csrfHeaderName: "X-CSRF-TOKEN", - }); + return HttpResponse.json( + dataEnvelope({ + authenticated: true, + displayName: "테스터", + roles: ["editor"], + csrfToken: "csrf-token-1", + csrfHeaderName: "X-CSRF-TOKEN", + }), + ); }), ); @@ -166,10 +178,7 @@ test( server.use( http.get(`${BASE}/api/v1/studio/dashboard`, ({ request }) => { jsonRequestHeader = request.headers.get("x-csrf-token"); - return HttpResponse.json({ - documentTotals: {}, - workflowSections: [], - }); + return HttpResponse.json(dataEnvelope({ documentTotals: {}, workflowSections: [] })); }), ); @@ -177,7 +186,10 @@ test( server.use( http.post(`${BASE}/api/v1/studio/assets`, ({ request }) => { 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( http.get(`${BASE}/api/v1/studio/session`, () => { sessionCalls += 1; - return HttpResponse.json({ - authenticated: true, - displayName: "테스터", - roles: ["editor"], - csrfToken: `csrf-token-${sessionCalls}`, - csrfHeaderName: "X-CSRF-TOKEN", - }); + return HttpResponse.json( + dataEnvelope({ + authenticated: true, + displayName: "테스터", + roles: ["editor"], + csrfToken: `csrf-token-${sessionCalls}`, + csrfHeaderName: "X-CSRF-TOKEN", + }), + ); }), ); @@ -250,7 +264,7 @@ test( // Second call: succeeds with whatever token is presented. return dashboardHeaders.length === 1 ? new HttpResponse(null, { status: 403 }) - : HttpResponse.json({ documentTotals: {}, workflowSections: [] }); + : HttpResponse.json(dataEnvelope({ documentTotals: {}, workflowSections: [] })); }), ); @@ -294,13 +308,15 @@ test( server.use( http.get(`${BASE}/api/v1/studio/session`, () => { sessionCalls += 1; - return HttpResponse.json({ - authenticated: true, - displayName: "테스터", - roles: ["editor"], - csrfToken: `csrf-token-${sessionCalls}`, - csrfHeaderName: "X-CSRF-TOKEN", - }); + return HttpResponse.json( + dataEnvelope({ + authenticated: true, + displayName: "테스터", + roles: ["editor"], + csrfToken: `csrf-token-${sessionCalls}`, + csrfHeaderName: "X-CSRF-TOKEN", + }), + ); }), ); @@ -308,7 +324,7 @@ test( server.use( http.get(`${BASE}/api/v1/studio/dashboard`, ({ request }) => { dashboardHeaders.push(request.headers.get("x-csrf-token")); - return HttpResponse.json({ documentTotals: {}, workflowSections: [] }); + return HttpResponse.json(dataEnvelope({ documentTotals: {}, workflowSections: [] })); }), ); server.use( diff --git a/tests/features/tech-log/studio-envelope-unwrap.test.ts b/tests/features/tech-log/studio-envelope-unwrap.test.ts new file mode 100644 index 0000000..5c248a6 --- /dev/null +++ b/tests/features/tech-log/studio-envelope-unwrap.test.ts @@ -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); + }); +}); From d23a18f659f8189b8c459fa530ed0c8a46606d17 Mon Sep 17 00:00:00 2001 From: DongHyeonka Date: Tue, 18 Aug 2026 22:13:33 +0900 Subject: [PATCH 2/5] =?UTF-8?q?fix:=20ProblemDetails=EB=A5=BC=20=EC=A0=84?= =?UTF-8?q?=EC=86=A1=20=EA=B3=84=EC=B8=B5=EC=9D=B4=20=EC=8B=A4=EC=A0=9C?= =?UTF-8?q?=EB=A1=9C=20=EB=A7=8C=EB=93=9C=EB=8A=94=20=EB=AA=A8=EC=96=91=20?= =?UTF-8?q?=ED=95=98=EB=82=98=EB=A1=9C=20=ED=95=A9=EC=B9=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 3 리뷰 finding(Important): contract.ts의 손수 유지되는 ProblemDetails가 tech-log-studio-contract-contribution.ts의 envelopeError()가 실제로 반환하는 모양과 별도로 정의돼 있었다. envelopeError()는 ApiError의 type/title/status/detail/code/retryable/category/details만 채우므로 ProblemDetails가 갖고 있던 옛 평면 필드(instance/traceId/fieldErrors/ latestDocument/latestPublication/conflictingFields)는 production에서 항상 undefined였다 — mock만 채워서 mock이 아무것도 검증하지 못하는 상태였다. - contract.ts: ProblemDetails에서 옛 평면 필드를 제거하고 details를 wire와 같은 union 타입(ValidationErrorDetails | VersionConflictDetails | PublicationConflictDetails | null)으로 정확히 준다. 세 타입을 이제 개별 export한다. - tech-log-studio-contract-contribution.ts: envelopeError()가 contract.ts의 ProblemDetails를 그대로 반환 타입으로 쓴다(로컬 StudioProblemShape 제거) — 이제 한 곳에만 정의가 있다. - mock-studio-gateway.ts / cursor.ts: fieldErrors/latestDocument/ conflictingFields/latestPublication을 wire와 같은 자리(details 안)로 옮긴다. - mock-studio-gateway.test.ts: 위 이동에 맞춰 details를 캐스트로 좁혀 읽도록 갱신. retryable은 optional로 유지했다 — 여러 테스트가 생략하고 만들며, 이번 finding과 무관해 required로 좁히면 관련 없는 파일들이 깨진다. 리뷰가 보류한 2건(asset-upload-transport.ts의 CODES.has 중복 검사, apiErrorSchema.category가 enum이 아닌 것)은 손대지 않았다. Co-Authored-By: Claude Opus 5 (1M context) --- src/features/tech-log/adapters/mock/cursor.ts | 4 +- .../adapters/mock/mock-studio-gateway.ts | 12 +++-- .../tech-log/contracts/studio/contract.ts | 49 ++++++++++++------- .../tech-log-studio-contract-contribution.ts | 30 ++++++------ .../tech-log/mock-studio-gateway.test.ts | 20 ++++---- 5 files changed, 67 insertions(+), 48 deletions(-) diff --git a/src/features/tech-log/adapters/mock/cursor.ts b/src/features/tech-log/adapters/mock/cursor.ts index cdba9b1..52e961d 100644 --- a/src/features/tech-log/adapters/mock/cursor.ts +++ b/src/features/tech-log/adapters/mock/cursor.ts @@ -9,7 +9,9 @@ function invalid(detail: string) { const problem: ProblemDetails = { type: "https://techlog.local/problems/request-validation-failed", title: "Request validation failed", status: 422, detail, code: "REQUEST_VALIDATION_FAILED", retryable: false, - fieldErrors: [{ path: "/cursor", message: detail }], + // wire와 같은 자리: `details`가 `ValidationErrorDetails` 모양이다 (Task 3 + // fix round 1 — 예전엔 `fieldErrors`가 최상위 필드였다). + details: { fieldErrors: [{ path: "/cursor", message: detail }] }, }; return new StudioGatewayError(problem); } diff --git a/src/features/tech-log/adapters/mock/mock-studio-gateway.ts b/src/features/tech-log/adapters/mock/mock-studio-gateway.ts index 59f481a..ec825c9 100644 --- a/src/features/tech-log/adapters/mock/mock-studio-gateway.ts +++ b/src/features/tech-log/adapters/mock/mock-studio-gateway.ts @@ -60,7 +60,11 @@ function gatewayProblem(status: number, code: ProblemDetails["code"], detail: st } function requestError(fieldErrors: components["schemas"]["FieldError"][]) { - return gatewayProblem(422, "REQUEST_VALIDATION_FAILED", "Request fields are invalid.", { fieldErrors }); + // wire와 같은 자리: `details`가 `ValidationErrorDetails` 모양이다 (Task 3 + // fix round 1 — 예전엔 `fieldErrors`가 최상위 필드였다). + return gatewayProblem(422, "REQUEST_VALIDATION_FAILED", "Request fields are invalid.", { + details: { fieldErrors }, + }); } function inputOf(document: WorkingCopy): WorkingCopyInput { @@ -169,7 +173,7 @@ export function createMockStudioGateway(supplied: Partial { if (value.version !== expected) throw gatewayProblem(409, "VERSION_CONFLICT", `Expected ${expected}; current ${value.version}.`, { latestDocument: clone(detail(value.id)), conflictingFields: [] }); }; + const version = (value: WorkingCopy, expected: number) => { if (value.version !== expected) throw gatewayProblem(409, "VERSION_CONFLICT", `Expected ${expected}; current ${value.version}.`, { details: { latestDocument: clone(detail(value.id)), conflictingFields: [] } }); }; const structure = (input: WorkingCopyInput) => { const errors = validateWorkingCopyInputStructure(input); if (errors.length) throw requestError(errors); }; const materialize = (id: string, value: number, input: WorkingCopyInput): WorkingCopy => ({ ...clone(input), id, version: value, updatedAt: dependencies.clock.now().toISOString(), relations: input.relations.map((relation) => ({ ...relation, id: relation.id ?? dependencies.idGenerator.next(), targetId: relation.targetId! })) }) as WorkingCopy; const summary = (value: WorkingCopy): components["schemas"]["DocumentSummary"] => { @@ -197,7 +201,7 @@ export function createMockStudioGateway(supplied: Partial { uuid(documentId, "/documentId"); return detail(documentId); }); }, saveDocument(documentId, command, options) { return idempotent("save", documentId, () => command, options, () => { uuid(documentId, "/documentId"); structure(command.document); const current = document(documentId); version(current, command.expectedVersion); - if (state.pendingConflicts.delete(documentId)) { const latest = materialize(documentId, current.version + 1, { ...inputOf(current), title: "서버에서 먼저 수정된 제목", summary: "서버 최신 요약" }); state.documents.set(documentId, latest); state.validations.delete(documentId); throw gatewayProblem(409, "VERSION_CONFLICT", "The server document changed.", { latestDocument: clone(detail(documentId)), conflictingFields: ["/title", "/summary"] }); } + if (state.pendingConflicts.delete(documentId)) { const latest = materialize(documentId, current.version + 1, { ...inputOf(current), title: "서버에서 먼저 수정된 제목", summary: "서버 최신 요약" }); state.documents.set(documentId, latest); state.validations.delete(documentId); throw gatewayProblem(409, "VERSION_CONFLICT", "The server document changed.", { details: { latestDocument: clone(detail(documentId)), conflictingFields: ["/title", "/summary"] } }); } const saved = materialize(documentId, current.version + 1, command.document); state.documents.set(documentId, saved); state.validations.delete(documentId); return detail(documentId); }); }, validateDocument(documentId, command, options) { return idempotent("validate", documentId, () => command, options, () => { uuid(documentId, "/documentId"); const value = document(documentId); version(value, command.expectedVersion); const report = validateWorkingCopy(value, { now: dependencies.clock.now(), validationId: dependencies.idGenerator.next(), dependencyRevision: dependencies.dependencyRevision.current(), catalog: state.catalog, documents: [...state.documents.values()], assets: [...dependencies.assets.values()] }); state.validations.set(documentId, report); return report; }); }, @@ -215,7 +219,7 @@ export function createMockStudioGateway(supplied: Partial issue.severity === "WARNING").map((issue) => issue.code); if (!exactSet(warnings, command.acknowledgedWarningCodes)) throw requestError([{ path: "/acknowledgedWarningCodes", message: "Acknowledge all current warnings." }]); const eventId = dependencies.idGenerator.next(); const publicationId = existing?.publicationId ?? dependencies.idGenerator.next(); const event: PublicationEvent = { publicationEventId: eventId, publicationId, documentId, type: existing ? "REPUBLISHED" : "PUBLISHED", occurredAt: now.toISOString(), publishedVersion: value.version, sourcePublishedEventId: null, snapshotAvailable: true }; const publication: PublicationAggregate = { publicationId, documentId, status: "PUBLISHED", publishedVersion: value.version, publicationRevision: (existing?.publicationRevision ?? 0) + 1, latestEventId: eventId, publicPath: preview.renderModel.publicPath, updatedAt: now.toISOString() }; state.events.set(eventId, event); state.publications.set(documentId, publication); state.snapshots.set(eventId, { event: clone(event), renderModel: clone(preview.renderModel), contentFormatVersion: CONTENT_FORMAT_VERSION, rendererContractVersion: RENDERER_CONTRACT_VERSION }); return { publication, event } satisfies PublishResult; }); }, - unpublishPublication(publicationId, command, options) { return idempotent("unpublish", publicationId, () => command, options, () => { uuid(publicationId, "/publicationId"); const current = [...state.publications.values()].find((item) => item.publicationId === publicationId); if (!current) throw gatewayProblem(404, "PUBLICATION_NOT_FOUND", "Publication not found."); if (current.publicationRevision !== command.expectedPublicationRevision) throw gatewayProblem(409, "PUBLICATION_CONFLICT", "Publication revision changed.", { latestPublication: clone(current) }); if (current.status === "UNPUBLISHED") return { publication: current, event: state.events.get(current.latestEventId)! }; const event: PublicationEvent = { publicationEventId: dependencies.idGenerator.next(), publicationId, documentId: current.documentId, type: "UNPUBLISHED", occurredAt: dependencies.clock.now().toISOString(), publishedVersion: current.publishedVersion, sourcePublishedEventId: current.latestEventId, snapshotAvailable: false }; const publication: PublicationAggregate = { ...current, status: "UNPUBLISHED", publicationRevision: current.publicationRevision + 1, latestEventId: event.publicationEventId, updatedAt: event.occurredAt }; state.events.set(event.publicationEventId, event); state.publications.set(current.documentId, publication); return { publication, event }; }); }, + unpublishPublication(publicationId, command, options) { return idempotent("unpublish", publicationId, () => command, options, () => { uuid(publicationId, "/publicationId"); const current = [...state.publications.values()].find((item) => item.publicationId === publicationId); if (!current) throw gatewayProblem(404, "PUBLICATION_NOT_FOUND", "Publication not found."); if (current.publicationRevision !== command.expectedPublicationRevision) throw gatewayProblem(409, "PUBLICATION_CONFLICT", "Publication revision changed.", { details: { latestPublication: clone(current) } }); if (current.status === "UNPUBLISHED") return { publication: current, event: state.events.get(current.latestEventId)! }; const event: PublicationEvent = { publicationEventId: dependencies.idGenerator.next(), publicationId, documentId: current.documentId, type: "UNPUBLISHED", occurredAt: dependencies.clock.now().toISOString(), publishedVersion: current.publishedVersion, sourcePublishedEventId: current.latestEventId, snapshotAvailable: false }; const publication: PublicationAggregate = { ...current, status: "UNPUBLISHED", publicationRevision: current.publicationRevision + 1, latestEventId: event.publicationEventId, updatedAt: event.occurredAt }; state.events.set(event.publicationEventId, event); state.publications.set(current.documentId, publication); return { publication, event }; }); }, listPublications(query, options) { return read(options, () => { queryText(query.q); const limit = limitOf(query.limit); const normalized = { q: normalizeQ(query.q), type: query.type ?? null, sort: "OCCURRED_DESC" }; const binding = cursorBinding(normalized); const cursor = query.cursor ? decodeCursor(query.cursor, binding) : null; const all = [...state.events.values()].filter((event) => (!normalized.type || event.type === normalized.type) && (!normalized.q || `${document(event.documentId).title} ${document(event.documentId).summary}`.toLocaleLowerCase("ko-KR").includes(normalized.q))).sort((a, b) => b.occurredAt.localeCompare(a.occurredAt) || a.publicationEventId.localeCompare(b.publicationEventId)); const source = cursor ? all.filter((event) => event.occurredAt < cursor.lastValue || (event.occurredAt === cursor.lastValue && event.publicationEventId > cursor.lastId)) : all; const selected = source.slice(0, limit); const last = selected.at(-1); return { items: selected.map(publicationRow), nextCursor: selected.length < source.length && last ? encodeCursor({ binding, lastValue: last.occurredAt, lastId: last.publicationEventId }) : null } satisfies PublicationPage; }); }, getPublicationSnapshot(publicationEventId, options) { return read(options, () => { uuid(publicationEventId, "/publicationEventId"); if (!state.events.has(publicationEventId)) throw gatewayProblem(404, "PUBLICATION_EVENT_NOT_FOUND", "Publication event not found."); const snapshot = state.snapshots.get(publicationEventId); if (!snapshot) throw gatewayProblem(404, "PUBLICATION_SNAPSHOT_NOT_FOUND", "Publication snapshot not found."); return snapshot; }); }, getCatalog(query, options) { return read(options, () => { if (!query.type) throw requestError([{ path: "/type", message: "type is required." }]); queryText(query.q); const limit = limitOf(query.limit); const normalized = { type: query.type, q: normalizeQ(query.q), sort: "LABEL_ASC" }; const binding = cursorBinding(normalized); const cursor = query.cursor ? decodeCursor(query.cursor, binding) : null; const all = state.catalog.filter((item) => item.type === query.type && (!normalized.q || item.label.toLocaleLowerCase("ko-KR").includes(normalized.q))).sort((a, b) => a.label.localeCompare(b.label, "ko") || a.id.localeCompare(b.id)); const source = cursor ? all.filter((item) => item.label.localeCompare(cursor.lastValue, "ko") > 0 || (item.label === cursor.lastValue && item.id > cursor.lastId)) : all; const selected = source.slice(0, limit); const last = selected.at(-1); return { items: selected, nextCursor: selected.length < source.length && last ? encodeCursor({ binding, lastValue: last.label, lastId: last.id }) : null } satisfies CatalogPage; }); }, diff --git a/src/features/tech-log/contracts/studio/contract.ts b/src/features/tech-log/contracts/studio/contract.ts index df28d6c..1a34959 100644 --- a/src/features/tech-log/contracts/studio/contract.ts +++ b/src/features/tech-log/contracts/studio/contract.ts @@ -25,14 +25,32 @@ export type PublicationSnapshot = Schemas["PublicationSnapshot"]; export type CatalogPage = Schemas["CatalogPage"]; /** * ADR-006으로 canonical 계약의 오류가 봉투(`ErrorEnvelope`/`ApiError`)로 - * 바뀌면서 `ProblemDetails` 스키마 자체는 canonical에서 삭제됐다. 앱 계층 - * (`StudioGatewayError`, mock 게이트웨이들, `asset-upload-transport.ts`)은 - * 여전히 이 평평한(flat) 모양을 소비한다 — 전송 경계 - * (`tech-log-studio-contract-contribution.ts`의 `envelopeError`)가 `ApiError`를 - * 여기로 옮기고, mock은 이 모양을 직접 구성한다. 그래서 더 이상 생성된 - * 스키마에서 뽑지 않고 여기서 손으로 유지한다. + * 바뀌면서 `ProblemDetails` 스키마 자체는 canonical에서 삭제됐다. 더 이상 + * 생성된 스키마에서 뽑지 않고 여기서 손으로 유지하되, 이 모양은 전송 경계 + * (`tech-log-studio-contract-contribution.ts`의 `envelopeError`)가 실제로 + * 만드는 모양과 **동일해야 한다** — 그게 이 값이 production에서 채워지는 + * 유일한 경로다. `ApiError`가 옮겨주는 필드(`type/title/status/detail/code/ + * category/retryable/details`)만 갖는다. + * + * (Task 3 fix round 1) 이전에는 ADR-006 이전 평면 wire 모양에서 넘어온 + * `instance`/`traceId`/`fieldErrors`/`latestDocument`/`latestPublication`/ + * `conflictingFields`를 최상위 필드로 따로 두고 있었다. `envelopeError`는 + * 그 필드들을 채우지 않으므로(옮길 대상이 없음) production 값에서는 항상 + * `undefined`였고, mock 게이트웨이만 채웠다 — 타입은 있는데 mock에 대고 + * 짜면 통과하고 실제 HTTP 경로에서는 조용히 비는, 봉투 검증 설계가 막으려던 + * 함정이었다. 그 데이터는 이제 wire와 동일하게 `details` 안에 둔다 — mock도 + * 여기 채운다(`mock-studio-gateway.ts`, `cursor.ts`). */ -export type ProblemDetails = { +export type ValidationErrorDetails = Schemas["ValidationErrorDetails"]; +export type VersionConflictDetails = Schemas["VersionConflictDetails"]; +export type PublicationConflictDetails = Schemas["PublicationConflictDetails"]; +export type ProblemDetailsPayload = + | ValidationErrorDetails + | VersionConflictDetails + | PublicationConflictDetails + | null; + +export type ProblemDetails = Readonly<{ /** Format: uri-reference */ type: string; title: string; @@ -40,18 +58,13 @@ export type ProblemDetails = { detail: string; code: Schemas["ApiError"]["code"]; category?: Schemas["ApiError"]["category"]; + // optional 유지: 기존 호출부(테스트의 `new StudioGatewayError({...})` 리터럴 + // 다수, `synthetic()`의 일부 경로)가 `retryable`을 생략한다. 이번 fix + // round의 finding은 `details`/평면 필드 문제이지 이 필드의 필수 여부가 + // 아니다 — required로 좁히면 무관한 파일들이 깨진다. 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; -}; + details?: ProblemDetailsPayload; +}>; export type PublicRenderModel = Schemas["PublicRenderModel"]; export type Asset = Schemas["Asset"]; export type AssetDetail = Schemas["AssetDetail"]; diff --git a/src/features/tech-log/contracts/tech-log-studio-contract-contribution.ts b/src/features/tech-log/contracts/tech-log-studio-contract-contribution.ts index 99f6fa3..5a02f26 100644 --- a/src/features/tech-log/contracts/tech-log-studio-contract-contribution.ts +++ b/src/features/tech-log/contracts/tech-log-studio-contract-contribution.ts @@ -8,6 +8,7 @@ import type { } from "../../../contracts/external-contract-runtime.ts"; import { TECH_LOG_FEATURE_ID } from "../application/tech-log-feature-input.ts"; import canonicalSource from "./studio/canonical-source.json" with { type: "json" }; +import type { ProblemDetails } from "./studio/contract.ts"; import { STUDIO_ERROR_CODES } from "../adapters/http/studio-error-mapping.ts"; import { assertExactlyOneTechLogStudioBootstrapOperation, @@ -84,9 +85,17 @@ const apiErrorSchema = z * 그 모양을 계속 쓰므로 매핑을 여기서 끝내면 아래 계층이 무변경이다. * `status`는 봉투에 없다 — 전송 계층이 실제 HTTP status를 따로 들고 있으므로 * 0으로 두고 `toStudioGatewayError`가 outcome의 status로 덮는다. + * + * (Task 3 fix round 1) `ProblemDetails`는 `contract.ts`에서 가져온다 — 이 + * transform이 실제로 만드는 모양과 `contract.ts`가 선언하는 모양이 서로 다른 + * 파일에서 독립적으로 정의되면(원래 상태) 둘이 갈라져도 아무 게이트도 못 + * 잡는다. `details`는 wire 그대로 통째로 옮긴다 — `fieldErrors`/ + * `latestDocument`/`conflictingFields`/`latestPublication`으로 분해하지 + * 않는다. 지금 그 필드들을 평평하게 읽는 소비자가 없고, 분해는 실제 소비자가 + * 생겼을 때 추가할 투기적 작업이다. */ -export const envelopeError = (): RuntimeValidator => - zodValidator( +export const envelopeError = (): RuntimeValidator => + zodValidator( "StudioErrorEnvelope", z .object({ success: z.literal(false), error: apiErrorSchema, meta: metaSchema }) @@ -99,25 +108,14 @@ export const envelopeError = (): RuntimeValidator => 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, + details: (envelope.error as { details?: ProblemDetails["details"] }).details ?? null, + })) as unknown as z.ZodType, ); -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 = +const COMMAND_EFFECT: CommandEffectDescriptor = Object.freeze({ successEffect: "APPLIED_CONFIRMED" as const, classifyProblem({ status }: Readonly<{ status: number; problem: unknown }>) { diff --git a/tests/features/tech-log/mock-studio-gateway.test.ts b/tests/features/tech-log/mock-studio-gateway.test.ts index e3300dd..a7c6589 100644 --- a/tests/features/tech-log/mock-studio-gateway.test.ts +++ b/tests/features/tech-log/mock-studio-gateway.test.ts @@ -3,6 +3,8 @@ import { test } from "vitest"; import { isStudioGatewayError } from "../../../src/features/tech-log/application/ports/studio-gateway-error.ts"; import type { + ValidationErrorDetails, + VersionConflictDetails, WorkingCopy, WorkingCopyInput, } from "../../../src/features/tech-log/contracts/studio/contract.ts"; @@ -71,8 +73,11 @@ function isProblem(status: number, code: string, paths?: string[]) { assert.equal(error.status, status); assert.equal(error.code, code); if (paths) { + // wire와 같은 자리: `REQUEST_VALIDATION_FAILED`의 detail은 + // `ValidationErrorDetails`로 `details`에 있다 (Task 3 fix round 1). + const details = error.problem.details as ValidationErrorDetails; assert.deepEqual( - error.problem.fieldErrors?.map(({ path }) => path), + details.fieldErrors.map(({ path }) => path), paths, ); } @@ -203,9 +208,8 @@ test("runtime OpenAPI validation enforces discriminators, extras, dates, UUIDs a }), (error) => { assert.ok(isStudioGatewayError(error)); - assert.ok( - error.problem.fieldErrors?.some(({ path }) => path === pointer), - ); + const details = error.problem.details as ValidationErrorDetails; + assert.ok(details.fieldErrors.some(({ path }) => path === pointer)); return true; }, ); @@ -575,12 +579,10 @@ test("publication history, immutable snapshots, conflict fixtures and 404 bodies (error) => { assert.ok(isStudioGatewayError(error)); assert.equal(error.code, "VERSION_CONFLICT"); - assert.deepEqual(error.problem.conflictingFields, [ - "/title", - "/summary", - ]); + const details = error.problem.details as VersionConflictDetails; + assert.deepEqual(details.conflictingFields, ["/title", "/summary"]); assert.equal( - error.problem.latestDocument?.document.version, + details.latestDocument.document.version, conflict.document.version + 1, ); return true; From 9244f5c15da295d2479d1285038e7783272f847f Mon Sep 17 00:00:00 2001 From: DongHyeonka Date: Tue, 18 Aug 2026 22:32:32 +0900 Subject: [PATCH 3/5] =?UTF-8?q?contract:=20discriminator=20enum=20?= =?UTF-8?q?=EC=A0=95=EC=A0=95=20=EB=B0=98=EC=98=81=ED=95=B4=20studio=20?= =?UTF-8?q?=EA=B3=84=EC=95=BD=20=EC=9E=AC=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit design-package d170392이 discriminator 판별 필드를 const에서 단일값 enum으로 바꿨다(wire 의미 동일, openapi-generator 7.18.0의 discriminator+const NPE를 피하려는 정정). vendor된 계약과 생성 타입을 다시 맞춘다. 실질적인 리터럴 타입은 그대로다 — JSDoc 태그만 @constant에서 @enum {string}으로 바뀌었다. Co-Authored-By: Claude Opus 5 (1M context) --- .../contracts/studio/canonical-source.json | 4 +- .../tech-log/contracts/studio/generated.ts | 32 +++++------ .../contracts/studio/studio-api.openapi.yaml | 54 +++++++++---------- 3 files changed, 45 insertions(+), 45 deletions(-) diff --git a/src/features/tech-log/contracts/studio/canonical-source.json b/src/features/tech-log/contracts/studio/canonical-source.json index 7ad0063..128290c 100644 --- a/src/features/tech-log/contracts/studio/canonical-source.json +++ b/src/features/tech-log/contracts/studio/canonical-source.json @@ -1,8 +1,8 @@ { "packageId": "@tech-log/studio-contract", "version": "3.0.0", - "digest": "sha256:25ed2e9f5b76bfeacf66e5a84ea8daf05979faa0b54e4980781f02115bbf0627", - "sourceRevision": "3e8a164", + "digest": "sha256:8ec2170ccdb27bd7ef29b46d732527987cf69d2c9818f7fe1943cb4d12b0dde4", + "sourceRevision": "d170392", "operationIds": [ "getStudioSession", "getStudioDashboard", diff --git a/src/features/tech-log/contracts/studio/generated.ts b/src/features/tech-log/contracts/studio/generated.ts index 423d857..d3f735a 100644 --- a/src/features/tech-log/contracts/studio/generated.ts +++ b/src/features/tech-log/contracts/studio/generated.ts @@ -588,7 +588,7 @@ export interface components { relations: components["schemas"]["RelationInput"][]; }; CaseInput: components["schemas"]["WorkingCopyInputBase"] & { - /** @constant */ + /** @enum {string} */ kind: "CASE"; problem: string; conclusion: string; @@ -608,7 +608,7 @@ export interface components { kind: "CASE"; }; ReferenceInput: components["schemas"]["WorkingCopyInputBase"] & { - /** @constant */ + /** @enum {string} */ kind: "REFERENCE"; purpose: string; rules: components["schemas"]["ReferenceRule"][]; @@ -625,7 +625,7 @@ export interface components { kind: "REFERENCE"; }; QuestionInput: components["schemas"]["WorkingCopyInputBase"] & { - /** @constant */ + /** @enum {string} */ kind: "QUESTION"; /** * @description Backend Inquiry lifecycle의 축약 view다. @@ -652,7 +652,7 @@ export interface components { kind: "QUESTION"; }; ProjectDecisionInput: components["schemas"]["WorkingCopyInputBase"] & { - /** @constant */ + /** @enum {string} */ kind: "PROJECT_DECISION"; /** * @description UI 용어다. Backend Domain의 `ACCEPTED`/`ADOPTED` 명칭이 다르면 @@ -687,7 +687,7 @@ export interface components { updatedAt: string; }; CaseWorkingCopy: components["schemas"]["WorkingCopyBase"] & { - /** @constant */ + /** @enum {string} */ kind: "CASE"; problem: string; conclusion: string; @@ -704,7 +704,7 @@ export interface components { kind: "CASE"; }; ReferenceWorkingCopy: components["schemas"]["WorkingCopyBase"] & { - /** @constant */ + /** @enum {string} */ kind: "REFERENCE"; purpose: string; rules: components["schemas"]["ReferenceRule"][]; @@ -721,7 +721,7 @@ export interface components { kind: "REFERENCE"; }; QuestionWorkingCopy: components["schemas"]["WorkingCopyBase"] & { - /** @constant */ + /** @enum {string} */ kind: "QUESTION"; /** @enum {string|null} */ questionStatus: "OPEN" | "RESOLVED" | null; @@ -740,7 +740,7 @@ export interface components { kind: "QUESTION"; }; ProjectDecisionWorkingCopy: components["schemas"]["WorkingCopyBase"] & { - /** @constant */ + /** @enum {string} */ kind: "PROJECT_DECISION"; /** @enum {string|null} */ decisionStatus: "PROPOSED" | "ADOPTED" | null; @@ -911,7 +911,7 @@ export interface components { }; Inline: components["schemas"]["InlineText"] | components["schemas"]["InlineEmphasis"] | components["schemas"]["InlineStrong"] | components["schemas"]["InlineCode"] | components["schemas"]["InlineLink"] | components["schemas"]["InlineStatus"]; InlineEmphasis: components["schemas"]["InlineContainer"] & { - /** @constant */ + /** @enum {string} */ type?: "EMPHASIS"; } & { /** @@ -921,7 +921,7 @@ export interface components { type: "EMPHASIS"; }; InlineStrong: components["schemas"]["InlineContainer"] & { - /** @constant */ + /** @enum {string} */ type?: "STRONG"; } & { /** @@ -966,7 +966,7 @@ export interface components { items: components["schemas"]["ListItem"][]; }; UnorderedListBlock: components["schemas"]["ListBlockBase"] & { - /** @constant */ + /** @enum {string} */ type?: "UNORDERED_LIST"; } & { /** @@ -976,7 +976,7 @@ export interface components { type: "UNORDERED_LIST"; }; OrderedListBlock: components["schemas"]["ListBlockBase"] & { - /** @constant */ + /** @enum {string} */ type?: "ORDERED_LIST"; } & { /** @@ -1074,7 +1074,7 @@ export interface components { }; CaseRenderBlock: components["schemas"]["HeadingBlock"] | components["schemas"]["ParagraphBlock"] | components["schemas"]["BlockquoteBlock"] | components["schemas"]["UnorderedListBlock"] | components["schemas"]["OrderedListBlock"] | components["schemas"]["CodeBlock"] | components["schemas"]["DataTableBlock"] | components["schemas"]["CalloutBlock"] | components["schemas"]["EvidenceFigureBlock"]; CasePublicRenderModel: components["schemas"]["PublicRenderModelBase"] & { - /** @constant */ + /** @enum {string} */ kind: "CASE"; problem: string; conclusion: string; @@ -1091,7 +1091,7 @@ export interface components { kind: "CASE"; }; ReferencePublicRenderModel: components["schemas"]["PublicRenderModelBase"] & { - /** @constant */ + /** @enum {string} */ kind: "REFERENCE"; purpose: string; rules: components["schemas"]["ReferenceRule"][]; @@ -1113,7 +1113,7 @@ export interface components { linkLabel: string; }; QuestionPublicRenderModel: components["schemas"]["PublicRenderModelBase"] & { - /** @constant */ + /** @enum {string} */ kind: "QUESTION"; /** * @description 공개 표현용 축약 상태다. Domain의 `INVESTIGATING`/`PAUSED`는 `OPEN`으로 표현된다. @@ -1135,7 +1135,7 @@ export interface components { kind: "QUESTION"; }; ProjectDecisionPublicRenderModel: components["schemas"]["PublicRenderModelBase"] & { - /** @constant */ + /** @enum {string} */ kind: "PROJECT_DECISION"; /** @enum {string} */ status: "PROPOSED" | "ADOPTED"; diff --git a/src/features/tech-log/contracts/studio/studio-api.openapi.yaml b/src/features/tech-log/contracts/studio/studio-api.openapi.yaml index 0f5b320..8cc18c2 100644 --- a/src/features/tech-log/contracts/studio/studio-api.openapi.yaml +++ b/src/features/tech-log/contracts/studio/studio-api.openapi.yaml @@ -928,7 +928,7 @@ components: - type: object required: [kind, problem, conclusion, environment, reproduction, lastVerifiedOn, bodyMarkdown] properties: - kind: { type: string, const: CASE } + kind: { type: string, enum: [CASE] } problem: { type: string, maxLength: 100000 } conclusion: { type: string, maxLength: 100000 } environment: { type: string, maxLength: 100000 } @@ -947,7 +947,7 @@ components: - type: object required: [kind, purpose, rules, applyWhen, exceptions, examples, verifiedOn] properties: - kind: { type: string, const: REFERENCE } + kind: { type: string, enum: [REFERENCE] } purpose: { type: string, maxLength: 100000 } rules: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/ReferenceRule" } } applyWhen: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } } @@ -961,7 +961,7 @@ components: - type: object required: [kind, questionStatus, facts, assumptions, unknowns, constraints, options, nextValidation, resolution] properties: - kind: { type: string, const: QUESTION } + kind: { type: string, enum: [QUESTION] } questionStatus: type: [string, "null"] enum: [OPEN, RESOLVED, null] @@ -988,7 +988,7 @@ components: - type: object required: [kind, decisionStatus, decidedOn, statement, rationale, consequences] properties: - kind: { type: string, const: PROJECT_DECISION } + kind: { type: string, enum: [PROJECT_DECISION] } decisionStatus: type: [string, "null"] enum: [PROPOSED, ADOPTED, null] @@ -1035,7 +1035,7 @@ components: - type: object required: [kind, problem, conclusion, environment, reproduction, lastVerifiedOn, bodyMarkdown] properties: - kind: { type: string, const: CASE } + kind: { type: string, enum: [CASE] } problem: { type: string, maxLength: 100000 } conclusion: { type: string, maxLength: 100000 } environment: { type: string, maxLength: 100000 } @@ -1049,7 +1049,7 @@ components: - type: object required: [kind, purpose, rules, applyWhen, exceptions, examples, verifiedOn] properties: - kind: { type: string, const: REFERENCE } + kind: { type: string, enum: [REFERENCE] } purpose: { type: string, maxLength: 100000 } rules: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/ReferenceRule" } } applyWhen: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } } @@ -1063,7 +1063,7 @@ components: - type: object required: [kind, questionStatus, facts, assumptions, unknowns, constraints, options, nextValidation, resolution] properties: - kind: { type: string, const: QUESTION } + kind: { type: string, enum: [QUESTION] } questionStatus: { type: [string, "null"], enum: [OPEN, RESOLVED, null] } facts: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } } assumptions: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } } @@ -1082,7 +1082,7 @@ components: - type: object required: [kind, decisionStatus, decidedOn, statement, rationale, consequences] properties: - kind: { type: string, const: PROJECT_DECISION } + kind: { type: string, enum: [PROJECT_DECISION] } decisionStatus: { type: [string, "null"], enum: [PROPOSED, ADOPTED, null] } decidedOn: { type: [string, "null"], format: date } statement: { type: string, maxLength: 100000 } @@ -1251,7 +1251,7 @@ components: additionalProperties: false required: [type, text] properties: - type: { type: string, const: TEXT } + type: { type: string, enum: [TEXT] } text: { type: string, minLength: 1, maxLength: 100000 } InlineContainer: type: object @@ -1264,14 +1264,14 @@ components: additionalProperties: false required: [type, code] properties: - type: { type: string, const: INLINE_CODE } + type: { type: string, enum: [INLINE_CODE] } code: { type: string, minLength: 1, maxLength: 100000 } InlineLink: type: object additionalProperties: false required: [type, label, href] properties: - type: { type: string, const: LINK } + type: { type: string, enum: [LINK] } label: { type: string, minLength: 1, maxLength: 100000 } href: { type: string, format: uri, maxLength: 2000 } InlineStatus: @@ -1279,7 +1279,7 @@ components: additionalProperties: false required: [type, label, tone] properties: - type: { type: string, const: STATUS } + type: { type: string, enum: [STATUS] } label: { type: string, minLength: 1, maxLength: 120 } tone: { type: string, enum: [warning, evidence, neutral] } Inline: @@ -1303,18 +1303,18 @@ components: unevaluatedProperties: false allOf: - { $ref: "#/components/schemas/InlineContainer" } - - { type: object, properties: { type: { type: string, const: EMPHASIS } } } + - { type: object, properties: { type: { type: string, enum: [EMPHASIS] } } } InlineStrong: unevaluatedProperties: false allOf: - { $ref: "#/components/schemas/InlineContainer" } - - { type: object, properties: { type: { type: string, const: STRONG } } } + - { type: object, properties: { type: { type: string, enum: [STRONG] } } } HeadingBlock: type: object additionalProperties: false required: [type, id, level, content] properties: - type: { type: string, const: HEADING } + type: { type: string, enum: [HEADING] } id: { type: string, minLength: 1, maxLength: 200 } level: { type: integer, minimum: 2, maximum: 4 } content: { type: array, maxItems: 1000, items: { $ref: "#/components/schemas/Inline" } } @@ -1323,14 +1323,14 @@ components: additionalProperties: false required: [type, content] properties: - type: { type: string, const: PARAGRAPH } + type: { type: string, enum: [PARAGRAPH] } content: { type: array, maxItems: 1000, items: { $ref: "#/components/schemas/Inline" } } BlockquoteBlock: type: object additionalProperties: false required: [type, content] properties: - type: { type: string, const: BLOCKQUOTE } + type: { type: string, enum: [BLOCKQUOTE] } content: { type: array, maxItems: 1000, items: { $ref: "#/components/schemas/Inline" } } ListItem: type: object @@ -1349,18 +1349,18 @@ components: unevaluatedProperties: false allOf: - { $ref: "#/components/schemas/ListBlockBase" } - - { type: object, properties: { type: { type: string, const: UNORDERED_LIST } } } + - { type: object, properties: { type: { type: string, enum: [UNORDERED_LIST] } } } OrderedListBlock: unevaluatedProperties: false allOf: - { $ref: "#/components/schemas/ListBlockBase" } - - { type: object, properties: { type: { type: string, const: ORDERED_LIST } } } + - { type: object, properties: { type: { type: string, enum: [ORDERED_LIST] } } } CodeBlock: type: object additionalProperties: false required: [type, code, language, label] properties: - type: { type: string, const: CODE_BLOCK } + type: { type: string, enum: [CODE_BLOCK] } code: { type: string, maxLength: 100000 } language: { type: [string, "null"], maxLength: 100 } label: { type: [string, "null"], maxLength: 200 } @@ -1391,7 +1391,7 @@ components: additionalProperties: false required: [type, id, caption, rowHeaderColumn, columns, rows] properties: - type: { type: string, const: DATA_TABLE } + type: { type: string, enum: [DATA_TABLE] } id: { type: string, minLength: 1, maxLength: 200 } caption: { type: string, maxLength: 1000 } rowHeaderColumn: { type: [integer, "null"], minimum: 1 } @@ -1402,7 +1402,7 @@ components: additionalProperties: false required: [type, tone, label, content] properties: - type: { type: string, const: CALLOUT } + type: { type: string, enum: [CALLOUT] } tone: { type: string, enum: [warning, info] } label: { type: string, maxLength: 200 } content: { type: array, maxItems: 1000, items: { $ref: "#/components/schemas/Inline" } } @@ -1425,7 +1425,7 @@ components: Asset decorative=true → alt="" 허용 ``` properties: - type: { type: string, const: EVIDENCE_FIGURE } + type: { type: string, enum: [EVIDENCE_FIGURE] } key: { type: string, minLength: 1, maxLength: 200 } alt: { type: string, maxLength: 1000 } caption: { type: string, maxLength: 1000 } @@ -1477,7 +1477,7 @@ components: - type: object required: [kind, problem, conclusion, environment, reproduction, lastVerifiedOn, bodyBlocks] properties: - kind: { type: string, const: CASE } + kind: { type: string, enum: [CASE] } problem: { type: string, minLength: 1, maxLength: 100000 } conclusion: { type: string, minLength: 1, maxLength: 100000 } environment: { type: string, maxLength: 100000 } @@ -1491,7 +1491,7 @@ components: - type: object required: [kind, purpose, rules, applyWhen, exceptions, examples, verifiedOn] properties: - kind: { type: string, const: REFERENCE } + kind: { type: string, enum: [REFERENCE] } purpose: { type: string, minLength: 1, maxLength: 100000 } rules: { type: array, minItems: 1, maxItems: 50, items: { $ref: "#/components/schemas/ReferenceRule" } } applyWhen: { type: array, minItems: 1, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } } @@ -1513,7 +1513,7 @@ components: - type: object required: [kind, status, facts, assumptions, unknowns, constraints, options, nextValidation, resolution] properties: - kind: { type: string, const: QUESTION } + kind: { type: string, enum: [QUESTION] } status: type: string enum: [OPEN, RESOLVED] @@ -1535,7 +1535,7 @@ components: - type: object required: [kind, status, decidedOn, statement, rationale, consequences] properties: - kind: { type: string, const: PROJECT_DECISION } + kind: { type: string, enum: [PROJECT_DECISION] } status: { type: string, enum: [PROPOSED, ADOPTED] } decidedOn: { type: string, format: date } statement: { type: string, minLength: 1, maxLength: 100000 } From aaaf0ac343a38bb9038f6b60800a0d5f5aa8b877 Mon Sep 17 00:00:00 2001 From: DongHyeonka Date: Tue, 18 Aug 2026 22:39:52 +0900 Subject: [PATCH 4/5] =?UTF-8?q?contract:=20ResponseMeta.page=20=EC=8A=A4?= =?UTF-8?q?=ED=82=A4=EB=A7=88=20=ED=99=95=EC=9E=A5=20=EB=B0=98=EC=98=81?= =?UTF-8?q?=ED=95=B4=20studio=20=EA=B3=84=EC=95=BD=20=EC=9E=AC=EC=83=9D?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit design-package 6a85d81이 ResponseMeta.page를 순수 null 타입에서 [object, null]로 넓혔다(openapi-generator가 순수 null 타입을 다루지 못해 생긴 계약 결함 수정, wire 의미는 그대로 — page는 여전히 항상 null). page의 생성 타입이 `null`에서 `{ [key: string]: unknown } | null`로 넓어졌다 — 의도된 변경이다. Co-Authored-By: Claude Opus 5 (1M context) --- .../tech-log/contracts/studio/canonical-source.json | 4 ++-- src/features/tech-log/contracts/studio/generated.ts | 6 ++++-- .../tech-log/contracts/studio/studio-api.openapi.yaml | 2 +- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/features/tech-log/contracts/studio/canonical-source.json b/src/features/tech-log/contracts/studio/canonical-source.json index 128290c..094d82d 100644 --- a/src/features/tech-log/contracts/studio/canonical-source.json +++ b/src/features/tech-log/contracts/studio/canonical-source.json @@ -1,8 +1,8 @@ { "packageId": "@tech-log/studio-contract", "version": "3.0.0", - "digest": "sha256:8ec2170ccdb27bd7ef29b46d732527987cf69d2c9818f7fe1943cb4d12b0dde4", - "sourceRevision": "d170392", + "digest": "sha256:e44b9b50e30ccd876ba4a6d6e082b7b14fad602c56619d0eb196c07c756106b0", + "sourceRevision": "6a85d81", "operationIds": [ "getStudioSession", "getStudioDashboard", diff --git a/src/features/tech-log/contracts/studio/generated.ts b/src/features/tech-log/contracts/studio/generated.ts index d3f735a..3524be1 100644 --- a/src/features/tech-log/contracts/studio/generated.ts +++ b/src/features/tech-log/contracts/studio/generated.ts @@ -386,8 +386,10 @@ export interface components { requestId: string; traceId: string; correlationId?: string | null; - /** @description Studio는 body 안 cursor 페이지네이션을 쓰므로 항상 null이다 */ - page?: null; + /** @description Studio는 body 안 cursor 페이지네이션을 쓰므로 항상 null이다. 백엔드 템플릿의 ResponseMeta record가 이 필드를 직렬화한다. */ + page?: { + [key: string]: unknown; + } | null; }; ApiError: { /** @enum {string} */ diff --git a/src/features/tech-log/contracts/studio/studio-api.openapi.yaml b/src/features/tech-log/contracts/studio/studio-api.openapi.yaml index 8cc18c2..731a2f6 100644 --- a/src/features/tech-log/contracts/studio/studio-api.openapi.yaml +++ b/src/features/tech-log/contracts/studio/studio-api.openapi.yaml @@ -641,7 +641,7 @@ components: 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이다 } + page: { type: ["object", "null"], additionalProperties: true, description: "Studio는 body 안 cursor 페이지네이션을 쓰므로 항상 null이다. 백엔드 템플릿의 ResponseMeta record가 이 필드를 직렬화한다." } ApiError: type: object additionalProperties: false From 3e2406349a6024c39bd13a9f031b24d3aa4c1c3c Mon Sep 17 00:00:00 2001 From: DongHyeonka Date: Wed, 19 Aug 2026 00:13:38 +0900 Subject: [PATCH 5/5] =?UTF-8?q?contract:=20studio=20=EA=B3=84=EC=95=BD?= =?UTF-8?q?=EC=9D=98=20VALIDATION=5FFAILED=20=EA=B0=9C=EB=AA=85(DOCUMENT?= =?UTF-8?q?=5FVALIDATION=5FFAILED)=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit canonical studio-v1.yaml(tech-log-design-package b20d7a2)이 VALIDATION_FAILED를 DOCUMENT_VALIDATION_FAILED로 개명했다 — 스켈레톤 전역 OperationalError. VALIDATION_FAILED(400)와 code 문자열이 충돌해 같은 code가 두 HTTP status를 갖던 문제를 해소한다. - generate:tech-log-contract로 vendor된 계약·생성 타입·canonical-source.json 재생성 - STUDIO_ERROR_CODES(손수 유지되는 계약 미러)의 해당 항목 개명 계약 밖 코드는 STUDIO_UNAVAILABLE로 접히므로 이 배열이 계약과 어긋나면 안 된다. Co-Authored-By: Claude Opus 5 (1M context) --- src/features/tech-log/adapters/http/studio-error-mapping.ts | 2 +- .../tech-log/contracts/studio/canonical-source.json | 4 ++-- src/features/tech-log/contracts/studio/generated.ts | 2 +- .../tech-log/contracts/studio/studio-api.openapi.yaml | 6 +++--- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/features/tech-log/adapters/http/studio-error-mapping.ts b/src/features/tech-log/adapters/http/studio-error-mapping.ts index a3c2814..75974f1 100644 --- a/src/features/tech-log/adapters/http/studio-error-mapping.ts +++ b/src/features/tech-log/adapters/http/studio-error-mapping.ts @@ -9,7 +9,7 @@ export const STUDIO_ERROR_CODES = Object.freeze([ "DOCUMENT_NOT_FOUND", "VERSION_CONFLICT", "REQUEST_VALIDATION_FAILED", - "VALIDATION_FAILED", + "DOCUMENT_VALIDATION_FAILED", "VALIDATION_STALE", "PREVIEW_NOT_FOUND", "PREVIEW_STALE", diff --git a/src/features/tech-log/contracts/studio/canonical-source.json b/src/features/tech-log/contracts/studio/canonical-source.json index 094d82d..1645536 100644 --- a/src/features/tech-log/contracts/studio/canonical-source.json +++ b/src/features/tech-log/contracts/studio/canonical-source.json @@ -1,8 +1,8 @@ { "packageId": "@tech-log/studio-contract", "version": "3.0.0", - "digest": "sha256:e44b9b50e30ccd876ba4a6d6e082b7b14fad602c56619d0eb196c07c756106b0", - "sourceRevision": "6a85d81", + "digest": "sha256:6cae9924403d0761f401643a022980b8e04183eea0d890c143c9fbbbbc7431e4", + "sourceRevision": "b20d7a2", "operationIds": [ "getStudioSession", "getStudioDashboard", diff --git a/src/features/tech-log/contracts/studio/generated.ts b/src/features/tech-log/contracts/studio/generated.ts index 3524be1..ccca359 100644 --- a/src/features/tech-log/contracts/studio/generated.ts +++ b/src/features/tech-log/contracts/studio/generated.ts @@ -393,7 +393,7 @@ export interface components { }; 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"; + code: "AUTHENTICATION_REQUIRED" | "STUDIO_ACCESS_DENIED" | "DOCUMENT_NOT_FOUND" | "VERSION_CONFLICT" | "REQUEST_VALIDATION_FAILED" | "DOCUMENT_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; diff --git a/src/features/tech-log/contracts/studio/studio-api.openapi.yaml b/src/features/tech-log/contracts/studio/studio-api.openapi.yaml index 731a2f6..836aa00 100644 --- a/src/features/tech-log/contracts/studio/studio-api.openapi.yaml +++ b/src/features/tech-log/contracts/studio/studio-api.openapi.yaml @@ -611,7 +611,7 @@ components: 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] + x-error-codes: [REQUEST_VALIDATION_FAILED, DOCUMENT_VALIDATION_FAILED, ASSET_NOT_READY, ASSET_QUARANTINED] content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } PublishRejected: description: | @@ -619,7 +619,7 @@ components: `WARNING_ACKNOWLEDGEMENT_REQUIRED`는 `acknowledgedWarningCodes`가 현재 Validation의 WARNING 집합을 덮지 못한 경우다. - x-error-codes: [REQUEST_VALIDATION_FAILED, VALIDATION_FAILED, WARNING_ACKNOWLEDGEMENT_REQUIRED, ASSET_NOT_READY, ASSET_QUARANTINED] + x-error-codes: [REQUEST_VALIDATION_FAILED, DOCUMENT_VALIDATION_FAILED, WARNING_ACKNOWLEDGEMENT_REQUIRED, ASSET_NOT_READY, ASSET_QUARANTINED] content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } AssetRejected: description: Asset metadata 변경이 거절되었다 @@ -650,7 +650,7 @@ components: code: type: string enum: [AUTHENTICATION_REQUIRED, STUDIO_ACCESS_DENIED, DOCUMENT_NOT_FOUND, VERSION_CONFLICT, - REQUEST_VALIDATION_FAILED, VALIDATION_FAILED, VALIDATION_STALE, PREVIEW_NOT_FOUND, + REQUEST_VALIDATION_FAILED, DOCUMENT_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,