merge: feature/studio-response-envelope — Studio 응답 봉투를 전송 경계에서 언랩

계약 v3.0.0(ADR-006)에 맞춰 재생성하고, envelopeData/envelopeError가
{success,data,meta}를 언랩한다. 앱·도메인 계층과 StudioGateway 포트는 무변경 —
언랩이 전송 경계에서 끝난다.

검증: check:tech-log-contract / test:tech-log 337건 / tsc --noEmit 전부 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-08-19 15:16:10 +09:00
co-authored by Claude Opus 5
14 changed files with 721 additions and 276 deletions
@@ -1,11 +1,21 @@
import { StudioGatewayError } from "../../application/ports/studio-gateway-error.ts"; import { StudioGatewayError } from "../../application/ports/studio-gateway-error.ts";
import type { UploadAssetForm } from "../../application/ports/studio-asset-gateway.ts"; import type { UploadAssetForm } from "../../application/ports/studio-asset-gateway.ts";
import type { Asset, ProblemDetails } from "../../contracts/studio/contract.ts"; import type { Asset, ProblemDetails } from "../../contracts/studio/contract.ts";
import { envelopeData, envelopeError } from "../../contracts/tech-log-studio-contract-contribution.ts";
import type { StudioAssetUploadTransport } from "./http-studio-asset-gateway.ts"; import type { StudioAssetUploadTransport } from "./http-studio-asset-gateway.ts";
import { STUDIO_ERROR_CODES } from "./studio-error-mapping.ts"; import { STUDIO_ERROR_CODES } from "./studio-error-mapping.ts";
const CODES = new Set<string>(STUDIO_ERROR_CODES); const CODES = new Set<string>(STUDIO_ERROR_CODES);
/**
* canonical `uploadStudioAsset`도 다른 18개 operation과 같은 봉투(ADR-006)를
* 쓴다 — 이 seam만 일반 계약 런타임을 안 거칠 뿐이지 wire format은 같다.
* 그래서 `tech-log-studio-contract-contribution.ts`의 언랩 validator를 그대로
* 재사용한다: 봉투 뼈대 검증 로직이 두 곳에서 따로 드리프트하는 것을 막는다.
*/
const UPLOAD_DATA = envelopeData<Asset>("uploadStudioAssetOutput");
const UPLOAD_PROBLEM = envelopeError();
/** /**
* The contract runtime can only express `requestBody: "NONE" | "JSON"` and the * The contract runtime can only express `requestBody: "NONE" | "JSON"` and the
* low-level client always serializes the body as JSON (see * low-level client always serializes the body as JSON (see
@@ -81,8 +91,9 @@ export function createAssetUploadTransport(
} }
if (response.status === 201 || response.status === 200) { if (response.status === 201 || response.status === 200) {
let body: unknown;
try { try {
return (await response.json()) as Asset; body = await response.json();
} catch { } catch {
// M1 (fix round 1). This port's contract is `StudioGatewayError`; // M1 (fix round 1). This port's contract is `StudioGatewayError`;
// a malformed success body must not throw a raw `SyntaxError` out // a malformed success body must not throw a raw `SyntaxError` out
@@ -91,16 +102,33 @@ export function createAssetUploadTransport(
`Upload returned status ${response.status} with a body that could not be parsed as JSON.`, `Upload returned status ${response.status} with a body that could not be parsed as JSON.`,
); );
} }
const parsed = UPLOAD_DATA.safeParse(body);
if (!parsed.success) {
// 봉투 뼈대(`{success:true, data, meta}`)가 아니다 — payload는
// 통과시키되 봉투 자체는 반드시 검증한다(다른 18개 operation과 동일
// 원칙, ADR-006).
throw unavailable(
`Upload returned status ${response.status} with a body that did not match the response envelope.`,
);
}
return parsed.data;
} }
let problem: ProblemDetails | null; let problemBody: unknown = null;
try { try {
problem = (await response.json()) as ProblemDetails; problemBody = await response.json();
} catch { } catch {
problem = null; problemBody = null;
} }
if (problem && typeof problem.code === "string" && CODES.has(problem.code)) { const parsedProblem = problemBody === null ? null : UPLOAD_PROBLEM.safeParse(problemBody);
throw new StudioGatewayError(problem); if (parsedProblem && parsedProblem.success && CODES.has(parsedProblem.data.code)) {
// `studio-error-mapping.ts`의 PROBLEM 분기와 같은 캐스트: 봉투는 이미
// `code`를 `apiErrorSchema`의 enum으로 검증했으므로(`CODES.has` 확인도
// 통과) `ProblemDetails["code"]`로 좁혀도 안전하다. envelope에는 HTTP
// status가 없다 (`envelopeError`가 0으로 둔다) — 이 seam은 실제 status를
// 이미 들고 있으므로 바로 덮는다.
const problem = parsedProblem.data as unknown as ProblemDetails;
throw new StudioGatewayError({ ...problem, status: response.status });
} }
// Fix round 2, item 2. The real status is passed through (not the // Fix round 2, item 2. The real status is passed through (not the
// hardcoded 503 default) so `http-studio-asset-gateway.ts`'s // hardcoded 503 default) so `http-studio-asset-gateway.ts`'s
@@ -9,7 +9,7 @@ export const STUDIO_ERROR_CODES = Object.freeze([
"DOCUMENT_NOT_FOUND", "DOCUMENT_NOT_FOUND",
"VERSION_CONFLICT", "VERSION_CONFLICT",
"REQUEST_VALIDATION_FAILED", "REQUEST_VALIDATION_FAILED",
"VALIDATION_FAILED", "DOCUMENT_VALIDATION_FAILED",
"VALIDATION_STALE", "VALIDATION_STALE",
"PREVIEW_NOT_FOUND", "PREVIEW_NOT_FOUND",
"PREVIEW_STALE", "PREVIEW_STALE",
@@ -57,13 +57,20 @@ export function toStudioGatewayError(
): StudioGatewayError { ): StudioGatewayError {
switch (outcome.kind) { switch (outcome.kind) {
case "PROBLEM": { case "PROBLEM": {
const problem = outcome.problem as ProblemDetails; // 봉투 오류는 wire에 HTTP status를 싣지 않는다 (`envelopeError`가 `status`를
// 0으로 둔다) — 실제 status는 전송 계층이 `outcome.metadata.status`로
// 이미 들고 있으므로 여기서 덮는다. `SafeResponseMetadata.status`는
// `PROBLEM` outcome에서 필수 필드다 (`http-execution-v3.ts`).
// `problem`이 falsy이거나 status가 0(봉투의 sentinel)이면 metadata로
// 덮는다 — 원래 코드처럼 `problem`을 안전하지 않게 역참조하지 않는다.
const problem = outcome.problem as ProblemDetails | undefined;
const status = problem?.status || outcome.metadata.status;
if (problem && typeof problem.code === "string" && CODES.has(problem.code)) { if (problem && typeof problem.code === "string" && CODES.has(problem.code)) {
return new StudioGatewayError(problem); return new StudioGatewayError({ ...problem, status });
} }
return synthetic( return synthetic(
"STUDIO_UNAVAILABLE", "STUDIO_UNAVAILABLE",
outcome.metadata.status, status,
`${operationId} returned an uncontracted problem code.`, `${operationId} returned an uncontracted problem code.`,
false, false,
); );
@@ -9,7 +9,9 @@ function invalid(detail: string) {
const problem: ProblemDetails = { const problem: ProblemDetails = {
type: "https://techlog.local/problems/request-validation-failed", title: "Request validation failed", type: "https://techlog.local/problems/request-validation-failed", title: "Request validation failed",
status: 422, detail, code: "REQUEST_VALIDATION_FAILED", retryable: false, 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); return new StudioGatewayError(problem);
} }
@@ -60,7 +60,11 @@ function gatewayProblem(status: number, code: ProblemDetails["code"], detail: st
} }
function requestError(fieldErrors: components["schemas"]["FieldError"][]) { 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 { function inputOf(document: WorkingCopy): WorkingCopyInput {
@@ -169,7 +173,7 @@ export function createMockStudioGateway(supplied: Partial<MockStudioDependencies
const nextAction = deriveDocumentState({ document: base.document, validation: base.currentValidation, preview: base.latestPreview, publication: base.currentPublication, dependencyRevision: base.dependencyRevision, now: dependencies.clock.now() }).nextAction; const nextAction = deriveDocumentState({ document: base.document, validation: base.currentValidation, preview: base.latestPreview, publication: base.currentPublication, dependencyRevision: base.dependencyRevision, now: dependencies.clock.now() }).nextAction;
return { ...base, nextAction }; return { ...base, nextAction };
}; };
const version = (value: WorkingCopy, expected: number) => { 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 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 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"] => { const summary = (value: WorkingCopy): components["schemas"]["DocumentSummary"] => {
@@ -197,7 +201,7 @@ export function createMockStudioGateway(supplied: Partial<MockStudioDependencies
getDocument(documentId, options) { return read(options, () => { uuid(documentId, "/documentId"); return detail(documentId); }); }, getDocument(documentId, options) { return read(options, () => { uuid(documentId, "/documentId"); return detail(documentId); }); },
saveDocument(documentId, command, options) { return idempotent("save", documentId, () => command, options, () => { saveDocument(documentId, command, options) { return idempotent("save", documentId, () => command, options, () => {
uuid(documentId, "/documentId"); structure(command.document); const current = document(documentId); version(current, command.expectedVersion); 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); 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; }); }, 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<MockStudioDependencies
const warnings = validation.issues.filter((issue) => issue.severity === "WARNING").map((issue) => issue.code); if (!exactSet(warnings, command.acknowledgedWarningCodes)) throw requestError([{ path: "/acknowledgedWarningCodes", message: "Acknowledge all current warnings." }]); const warnings = validation.issues.filter((issue) => 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; 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; }); }, 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; }); }, 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; }); }, 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; }); },
@@ -1,8 +1,8 @@
{ {
"packageId": "@tech-log/studio-contract", "packageId": "@tech-log/studio-contract",
"version": "2.0.0", "version": "3.0.0",
"digest": "sha256:99f54f56ea0c582eafdbdf9be5653e3384bef0a1b08bff67f3147ee0292019ea", "digest": "sha256:6cae9924403d0761f401643a022980b8e04183eea0d890c143c9fbbbbc7431e4",
"sourceRevision": "ce2e748", "sourceRevision": "b20d7a2",
"operationIds": [ "operationIds": [
"getStudioSession", "getStudioSession",
"getStudioDashboard", "getStudioDashboard",
@@ -23,7 +23,48 @@ export type PublicationListItem = Schemas["PublicationListItem"];
export type PublicationPage = Schemas["PublicationPage"]; export type PublicationPage = Schemas["PublicationPage"];
export type PublicationSnapshot = Schemas["PublicationSnapshot"]; export type PublicationSnapshot = Schemas["PublicationSnapshot"];
export type CatalogPage = Schemas["CatalogPage"]; export type CatalogPage = Schemas["CatalogPage"];
export type ProblemDetails = Schemas["ProblemDetails"]; /**
* ADR-006으로 canonical 계약의 오류가 봉투(`ErrorEnvelope`/`ApiError`)로
* 바뀌면서 `ProblemDetails` 스키마 자체는 canonical에서 삭제됐다. 더 이상
* 생성된 스키마에서 뽑지 않고 여기서 손으로 유지하되, 이 모양은 전송 경계
* (`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 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;
status: number;
detail: string;
code: Schemas["ApiError"]["code"];
category?: Schemas["ApiError"]["category"];
// optional 유지: 기존 호출부(테스트의 `new StudioGatewayError({...})` 리터럴
// 다수, `synthetic()`의 일부 경로)가 `retryable`을 생략한다. 이번 fix
// round의 finding은 `details`/평면 필드 문제이지 이 필드의 필수 여부가
// 아니다 — required로 좁히면 무관한 파일들이 깨진다.
retryable?: boolean;
details?: ProblemDetailsPayload;
}>;
export type PublicRenderModel = Schemas["PublicRenderModel"]; export type PublicRenderModel = Schemas["PublicRenderModel"];
export type Asset = Schemas["Asset"]; export type Asset = Schemas["Asset"];
export type AssetDetail = Schemas["AssetDetail"]; export type AssetDetail = Schemas["AssetDetail"];
@@ -89,8 +89,8 @@ export interface paths {
* @description 편집 가능한 content field만 저장한다. lifecycle 전이는 명시적인 Domain * @description 편집 가능한 content field만 저장한다. lifecycle 전이는 명시적인 Domain
* Action이 담당한다. 저장은 Public Projection을 변경하지 않는다. * Action이 담당한다. 저장은 Public Projection을 변경하지 않는다.
* *
* `expectedVersion` 불일치는 `VERSION_CONFLICT`이며 응답 `ProblemDetails` * `expectedVersion` 불일치는 `VERSION_CONFLICT`이며 응답 `error.details`
* `latestDocument`로 현재 상태를 함께 제공한다. * (`VersionConflictDetails`)의 `latestDocument`로 현재 상태를 함께 제공한다.
* *
*/ */
put: operations["saveStudioDocument"]; put: operations["saveStudioDocument"];
@@ -382,6 +382,130 @@ export interface paths {
export type webhooks = Record<string, never>; export type webhooks = Record<string, never>;
export interface components { export interface components {
schemas: { schemas: {
ResponseMeta: {
requestId: string;
traceId: string;
correlationId?: string | null;
/** @description Studio는 body 안 cursor 페이지네이션을 쓰므로 항상 null이다. 백엔드 템플릿의 ResponseMeta record가 이 필드를 직렬화한다. */
page?: {
[key: string]: unknown;
} | null;
};
ApiError: {
/** @enum {string} */
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;
retryable: boolean;
details?: components["schemas"]["ValidationErrorDetails"] | components["schemas"]["VersionConflictDetails"] | components["schemas"]["PublicationConflictDetails"] | null;
};
ErrorEnvelope: {
/** @constant */
success: false;
error: components["schemas"]["ApiError"];
meta: components["schemas"]["ResponseMeta"];
};
ValidationErrorDetails: {
fieldErrors: components["schemas"]["FieldError"][];
};
VersionConflictDetails: {
latestDocument: components["schemas"]["WorkingCopyDetail"];
conflictingFields?: string[];
};
PublicationConflictDetails: {
latestPublication: components["schemas"]["PublicationAggregate"];
};
StudioSessionEnvelope: {
/** @constant */
success: true;
data: components["schemas"]["StudioSession"];
meta: components["schemas"]["ResponseMeta"];
};
StudioDashboardEnvelope: {
/** @constant */
success: true;
data: components["schemas"]["StudioDashboard"];
meta: components["schemas"]["ResponseMeta"];
};
DocumentPageEnvelope: {
/** @constant */
success: true;
data: components["schemas"]["DocumentPage"];
meta: components["schemas"]["ResponseMeta"];
};
WorkingCopyDetailEnvelope: {
/** @constant */
success: true;
data: components["schemas"]["WorkingCopyDetail"];
meta: components["schemas"]["ResponseMeta"];
};
WorkingCopyEnvelope: {
/** @constant */
success: true;
data: components["schemas"]["WorkingCopy"];
meta: components["schemas"]["ResponseMeta"];
};
ValidationReportEnvelope: {
/** @constant */
success: true;
data: components["schemas"]["ValidationReport"];
meta: components["schemas"]["ResponseMeta"];
};
PreviewDetailEnvelope: {
/** @constant */
success: true;
data: components["schemas"]["PreviewDetail"];
meta: components["schemas"]["ResponseMeta"];
};
PublicPreviewEnvelope: {
/** @constant */
success: true;
data: components["schemas"]["PublicPreview"];
meta: components["schemas"]["ResponseMeta"];
};
PublishResultEnvelope: {
/** @constant */
success: true;
data: components["schemas"]["PublishResult"];
meta: components["schemas"]["ResponseMeta"];
};
PublicationPageEnvelope: {
/** @constant */
success: true;
data: components["schemas"]["PublicationPage"];
meta: components["schemas"]["ResponseMeta"];
};
PublicationSnapshotEnvelope: {
/** @constant */
success: true;
data: components["schemas"]["PublicationSnapshot"];
meta: components["schemas"]["ResponseMeta"];
};
CatalogPageEnvelope: {
/** @constant */
success: true;
data: components["schemas"]["CatalogPage"];
meta: components["schemas"]["ResponseMeta"];
};
AssetPageEnvelope: {
/** @constant */
success: true;
data: components["schemas"]["AssetPage"];
meta: components["schemas"]["ResponseMeta"];
};
AssetDetailEnvelope: {
/** @constant */
success: true;
data: components["schemas"]["AssetDetail"];
meta: components["schemas"]["ResponseMeta"];
};
AssetEnvelope: {
/** @constant */
success: true;
data: components["schemas"]["Asset"];
meta: components["schemas"]["ResponseMeta"];
};
StudioSession: { StudioSession: {
authenticated: boolean; authenticated: boolean;
displayName: string; displayName: string;
@@ -466,7 +590,7 @@ export interface components {
relations: components["schemas"]["RelationInput"][]; relations: components["schemas"]["RelationInput"][];
}; };
CaseInput: components["schemas"]["WorkingCopyInputBase"] & { CaseInput: components["schemas"]["WorkingCopyInputBase"] & {
/** @constant */ /** @enum {string} */
kind: "CASE"; kind: "CASE";
problem: string; problem: string;
conclusion: string; conclusion: string;
@@ -486,7 +610,7 @@ export interface components {
kind: "CASE"; kind: "CASE";
}; };
ReferenceInput: components["schemas"]["WorkingCopyInputBase"] & { ReferenceInput: components["schemas"]["WorkingCopyInputBase"] & {
/** @constant */ /** @enum {string} */
kind: "REFERENCE"; kind: "REFERENCE";
purpose: string; purpose: string;
rules: components["schemas"]["ReferenceRule"][]; rules: components["schemas"]["ReferenceRule"][];
@@ -503,7 +627,7 @@ export interface components {
kind: "REFERENCE"; kind: "REFERENCE";
}; };
QuestionInput: components["schemas"]["WorkingCopyInputBase"] & { QuestionInput: components["schemas"]["WorkingCopyInputBase"] & {
/** @constant */ /** @enum {string} */
kind: "QUESTION"; kind: "QUESTION";
/** /**
* @description Backend Inquiry lifecycle의 축약 view다. * @description Backend Inquiry lifecycle의 축약 view다.
@@ -530,7 +654,7 @@ export interface components {
kind: "QUESTION"; kind: "QUESTION";
}; };
ProjectDecisionInput: components["schemas"]["WorkingCopyInputBase"] & { ProjectDecisionInput: components["schemas"]["WorkingCopyInputBase"] & {
/** @constant */ /** @enum {string} */
kind: "PROJECT_DECISION"; kind: "PROJECT_DECISION";
/** /**
* @description UI 용어다. Backend Domain의 `ACCEPTED`/`ADOPTED` 명칭이 다르면 * @description UI 용어다. Backend Domain의 `ACCEPTED`/`ADOPTED` 명칭이 다르면
@@ -565,7 +689,7 @@ export interface components {
updatedAt: string; updatedAt: string;
}; };
CaseWorkingCopy: components["schemas"]["WorkingCopyBase"] & { CaseWorkingCopy: components["schemas"]["WorkingCopyBase"] & {
/** @constant */ /** @enum {string} */
kind: "CASE"; kind: "CASE";
problem: string; problem: string;
conclusion: string; conclusion: string;
@@ -582,7 +706,7 @@ export interface components {
kind: "CASE"; kind: "CASE";
}; };
ReferenceWorkingCopy: components["schemas"]["WorkingCopyBase"] & { ReferenceWorkingCopy: components["schemas"]["WorkingCopyBase"] & {
/** @constant */ /** @enum {string} */
kind: "REFERENCE"; kind: "REFERENCE";
purpose: string; purpose: string;
rules: components["schemas"]["ReferenceRule"][]; rules: components["schemas"]["ReferenceRule"][];
@@ -599,7 +723,7 @@ export interface components {
kind: "REFERENCE"; kind: "REFERENCE";
}; };
QuestionWorkingCopy: components["schemas"]["WorkingCopyBase"] & { QuestionWorkingCopy: components["schemas"]["WorkingCopyBase"] & {
/** @constant */ /** @enum {string} */
kind: "QUESTION"; kind: "QUESTION";
/** @enum {string|null} */ /** @enum {string|null} */
questionStatus: "OPEN" | "RESOLVED" | null; questionStatus: "OPEN" | "RESOLVED" | null;
@@ -618,7 +742,7 @@ export interface components {
kind: "QUESTION"; kind: "QUESTION";
}; };
ProjectDecisionWorkingCopy: components["schemas"]["WorkingCopyBase"] & { ProjectDecisionWorkingCopy: components["schemas"]["WorkingCopyBase"] & {
/** @constant */ /** @enum {string} */
kind: "PROJECT_DECISION"; kind: "PROJECT_DECISION";
/** @enum {string|null} */ /** @enum {string|null} */
decisionStatus: "PROPOSED" | "ADOPTED" | null; decisionStatus: "PROPOSED" | "ADOPTED" | null;
@@ -789,7 +913,7 @@ export interface components {
}; };
Inline: components["schemas"]["InlineText"] | components["schemas"]["InlineEmphasis"] | components["schemas"]["InlineStrong"] | components["schemas"]["InlineCode"] | components["schemas"]["InlineLink"] | components["schemas"]["InlineStatus"]; Inline: components["schemas"]["InlineText"] | components["schemas"]["InlineEmphasis"] | components["schemas"]["InlineStrong"] | components["schemas"]["InlineCode"] | components["schemas"]["InlineLink"] | components["schemas"]["InlineStatus"];
InlineEmphasis: components["schemas"]["InlineContainer"] & { InlineEmphasis: components["schemas"]["InlineContainer"] & {
/** @constant */ /** @enum {string} */
type?: "EMPHASIS"; type?: "EMPHASIS";
} & { } & {
/** /**
@@ -799,7 +923,7 @@ export interface components {
type: "EMPHASIS"; type: "EMPHASIS";
}; };
InlineStrong: components["schemas"]["InlineContainer"] & { InlineStrong: components["schemas"]["InlineContainer"] & {
/** @constant */ /** @enum {string} */
type?: "STRONG"; type?: "STRONG";
} & { } & {
/** /**
@@ -844,7 +968,7 @@ export interface components {
items: components["schemas"]["ListItem"][]; items: components["schemas"]["ListItem"][];
}; };
UnorderedListBlock: components["schemas"]["ListBlockBase"] & { UnorderedListBlock: components["schemas"]["ListBlockBase"] & {
/** @constant */ /** @enum {string} */
type?: "UNORDERED_LIST"; type?: "UNORDERED_LIST";
} & { } & {
/** /**
@@ -854,7 +978,7 @@ export interface components {
type: "UNORDERED_LIST"; type: "UNORDERED_LIST";
}; };
OrderedListBlock: components["schemas"]["ListBlockBase"] & { OrderedListBlock: components["schemas"]["ListBlockBase"] & {
/** @constant */ /** @enum {string} */
type?: "ORDERED_LIST"; type?: "ORDERED_LIST";
} & { } & {
/** /**
@@ -952,7 +1076,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"]; 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"] & { CasePublicRenderModel: components["schemas"]["PublicRenderModelBase"] & {
/** @constant */ /** @enum {string} */
kind: "CASE"; kind: "CASE";
problem: string; problem: string;
conclusion: string; conclusion: string;
@@ -969,7 +1093,7 @@ export interface components {
kind: "CASE"; kind: "CASE";
}; };
ReferencePublicRenderModel: components["schemas"]["PublicRenderModelBase"] & { ReferencePublicRenderModel: components["schemas"]["PublicRenderModelBase"] & {
/** @constant */ /** @enum {string} */
kind: "REFERENCE"; kind: "REFERENCE";
purpose: string; purpose: string;
rules: components["schemas"]["ReferenceRule"][]; rules: components["schemas"]["ReferenceRule"][];
@@ -991,7 +1115,7 @@ export interface components {
linkLabel: string; linkLabel: string;
}; };
QuestionPublicRenderModel: components["schemas"]["PublicRenderModelBase"] & { QuestionPublicRenderModel: components["schemas"]["PublicRenderModelBase"] & {
/** @constant */ /** @enum {string} */
kind: "QUESTION"; kind: "QUESTION";
/** /**
* @description 공개 표현용 축약 상태다. Domain의 `INVESTIGATING`/`PAUSED`는 `OPEN`으로 표현된다. * @description 공개 표현용 축약 상태다. Domain의 `INVESTIGATING`/`PAUSED`는 `OPEN`으로 표현된다.
@@ -1013,7 +1137,7 @@ export interface components {
kind: "QUESTION"; kind: "QUESTION";
}; };
ProjectDecisionPublicRenderModel: components["schemas"]["PublicRenderModelBase"] & { ProjectDecisionPublicRenderModel: components["schemas"]["PublicRenderModelBase"] & {
/** @constant */ /** @enum {string} */
kind: "PROJECT_DECISION"; kind: "PROJECT_DECISION";
/** @enum {string} */ /** @enum {string} */
status: "PROPOSED" | "ADOPTED"; status: "PROPOSED" | "ADOPTED";
@@ -1246,25 +1370,6 @@ export interface components {
path: string; path: string;
message: string; message: string;
}; };
ProblemDetails: {
/** Format: uri-reference */
type: string;
title: string;
status: number;
detail: string;
/** @enum {string} */
code: "AUTHENTICATION_REQUIRED" | "STUDIO_ACCESS_DENIED" | "DOCUMENT_NOT_FOUND" | "VERSION_CONFLICT" | "REQUEST_VALIDATION_FAILED" | "VALIDATION_FAILED" | "VALIDATION_STALE" | "PREVIEW_NOT_FOUND" | "PREVIEW_STALE" | "PREVIEW_EXPIRED" | "PUBLICATION_NOT_FOUND" | "PUBLICATION_CONFLICT" | "PUBLICATION_EVENT_NOT_FOUND" | "PUBLICATION_SNAPSHOT_NOT_FOUND" | "WARNING_ACKNOWLEDGEMENT_REQUIRED" | "IDEMPOTENCY_KEY_REUSED" | "ASSET_NOT_FOUND" | "ASSET_NOT_READY" | "ASSET_IN_USE" | "ASSET_QUARANTINED" | "PAYLOAD_TOO_LARGE" | "UNSUPPORTED_MEDIA_TYPE" | "STUDIO_UNAVAILABLE";
/** Format: uri-reference */
instance?: string;
traceId?: string;
fieldErrors?: components["schemas"]["FieldError"][];
latestDocument?: components["schemas"]["WorkingCopyDetail"];
latestPublication?: components["schemas"]["PublicationAggregate"];
conflictingFields?: string[];
retryable?: boolean;
} & {
[key: string]: unknown;
};
}; };
responses: { responses: {
/** @description Malformed request */ /** @description Malformed request */
@@ -1273,7 +1378,7 @@ export interface components {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/problem+json": components["schemas"]["ProblemDetails"]; "application/json": components["schemas"]["ErrorEnvelope"];
}; };
}; };
/** @description Authentication required */ /** @description Authentication required */
@@ -1282,7 +1387,7 @@ export interface components {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/problem+json": components["schemas"]["ProblemDetails"]; "application/json": components["schemas"]["ErrorEnvelope"];
}; };
}; };
/** @description Studio access denied */ /** @description Studio access denied */
@@ -1291,7 +1396,7 @@ export interface components {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/problem+json": components["schemas"]["ProblemDetails"]; "application/json": components["schemas"]["ErrorEnvelope"];
}; };
}; };
/** @description Document not found */ /** @description Document not found */
@@ -1300,7 +1405,7 @@ export interface components {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/problem+json": components["schemas"]["ProblemDetails"]; "application/json": components["schemas"]["ErrorEnvelope"];
}; };
}; };
/** @description Document or preview not found */ /** @description Document or preview not found */
@@ -1309,7 +1414,7 @@ export interface components {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/problem+json": components["schemas"]["ProblemDetails"]; "application/json": components["schemas"]["ErrorEnvelope"];
}; };
}; };
/** @description Publication not found */ /** @description Publication not found */
@@ -1318,7 +1423,7 @@ export interface components {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/problem+json": components["schemas"]["ProblemDetails"]; "application/json": components["schemas"]["ErrorEnvelope"];
}; };
}; };
/** @description Publication event or snapshot not found */ /** @description Publication event or snapshot not found */
@@ -1327,7 +1432,7 @@ export interface components {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/problem+json": components["schemas"]["ProblemDetails"]; "application/json": components["schemas"]["ErrorEnvelope"];
}; };
}; };
/** @description Asset not found */ /** @description Asset not found */
@@ -1336,7 +1441,7 @@ export interface components {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/problem+json": components["schemas"]["ProblemDetails"]; "application/json": components["schemas"]["ErrorEnvelope"];
}; };
}; };
/** @description Command conflicts with current state, freshness, or idempotency. /** @description Command conflicts with current state, freshness, or idempotency.
@@ -1348,7 +1453,7 @@ export interface components {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/problem+json": components["schemas"]["ProblemDetails"]; "application/json": components["schemas"]["ErrorEnvelope"];
}; };
}; };
/** @description Request validation failed */ /** @description Request validation failed */
@@ -1357,7 +1462,7 @@ export interface components {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/problem+json": components["schemas"]["ProblemDetails"]; "application/json": components["schemas"]["ErrorEnvelope"];
}; };
}; };
/** @description Preview 생성이 도메인 규칙으로 거절되었다 */ /** @description Preview 생성이 도메인 규칙으로 거절되었다 */
@@ -1366,7 +1471,7 @@ export interface components {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/problem+json": components["schemas"]["ProblemDetails"]; "application/json": components["schemas"]["ErrorEnvelope"];
}; };
}; };
/** @description Publication validation이 실패했다. /** @description Publication validation이 실패했다.
@@ -1379,7 +1484,7 @@ export interface components {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/problem+json": components["schemas"]["ProblemDetails"]; "application/json": components["schemas"]["ErrorEnvelope"];
}; };
}; };
/** @description Asset metadata 변경이 거절되었다 */ /** @description Asset metadata 변경이 거절되었다 */
@@ -1388,7 +1493,7 @@ export interface components {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/problem+json": components["schemas"]["ProblemDetails"]; "application/json": components["schemas"]["ErrorEnvelope"];
}; };
}; };
/** @description Upload exceeds the configured size limit */ /** @description Upload exceeds the configured size limit */
@@ -1397,7 +1502,7 @@ export interface components {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/problem+json": components["schemas"]["ProblemDetails"]; "application/json": components["schemas"]["ErrorEnvelope"];
}; };
}; };
/** @description Unsupported media type */ /** @description Unsupported media type */
@@ -1406,7 +1511,7 @@ export interface components {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/problem+json": components["schemas"]["ProblemDetails"]; "application/json": components["schemas"]["ErrorEnvelope"];
}; };
}; };
/** @description Studio unavailable */ /** @description Studio unavailable */
@@ -1415,7 +1520,7 @@ export interface components {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/problem+json": components["schemas"]["ProblemDetails"]; "application/json": components["schemas"]["ErrorEnvelope"];
}; };
}; };
}; };
@@ -1470,7 +1575,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/json": components["schemas"]["StudioSession"]; "application/json": components["schemas"]["StudioSessionEnvelope"];
}; };
}; };
401: components["responses"]["AuthenticationRequired"]; 401: components["responses"]["AuthenticationRequired"];
@@ -1493,7 +1598,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/json": components["schemas"]["StudioDashboard"]; "application/json": components["schemas"]["StudioDashboardEnvelope"];
}; };
}; };
401: components["responses"]["AuthenticationRequired"]; 401: components["responses"]["AuthenticationRequired"];
@@ -1527,7 +1632,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/json": components["schemas"]["DocumentPage"]; "application/json": components["schemas"]["DocumentPageEnvelope"];
}; };
}; };
400: components["responses"]["MalformedRequest"]; 400: components["responses"]["MalformedRequest"];
@@ -1565,7 +1670,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/json": components["schemas"]["WorkingCopy"]; "application/json": components["schemas"]["WorkingCopyEnvelope"];
}; };
}; };
400: components["responses"]["MalformedRequest"]; 400: components["responses"]["MalformedRequest"];
@@ -1593,7 +1698,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/json": components["schemas"]["WorkingCopyDetail"]; "application/json": components["schemas"]["WorkingCopyDetailEnvelope"];
}; };
}; };
401: components["responses"]["AuthenticationRequired"]; 401: components["responses"]["AuthenticationRequired"];
@@ -1632,7 +1737,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/json": components["schemas"]["WorkingCopyDetail"]; "application/json": components["schemas"]["WorkingCopyDetailEnvelope"];
}; };
}; };
400: components["responses"]["MalformedRequest"]; 400: components["responses"]["MalformedRequest"];
@@ -1674,7 +1779,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/json": components["schemas"]["ValidationReport"]; "application/json": components["schemas"]["ValidationReportEnvelope"];
}; };
}; };
400: components["responses"]["MalformedRequest"]; 400: components["responses"]["MalformedRequest"];
@@ -1703,7 +1808,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/json": components["schemas"]["PreviewDetail"]; "application/json": components["schemas"]["PreviewDetailEnvelope"];
}; };
}; };
401: components["responses"]["AuthenticationRequired"]; 401: components["responses"]["AuthenticationRequired"];
@@ -1742,7 +1847,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/json": components["schemas"]["PublicPreview"]; "application/json": components["schemas"]["PublicPreviewEnvelope"];
}; };
}; };
400: components["responses"]["MalformedRequest"]; 400: components["responses"]["MalformedRequest"];
@@ -1784,7 +1889,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/json": components["schemas"]["PublishResult"]; "application/json": components["schemas"]["PublishResultEnvelope"];
}; };
}; };
400: components["responses"]["MalformedRequest"]; 400: components["responses"]["MalformedRequest"];
@@ -1818,7 +1923,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/json": components["schemas"]["PublicationPage"]; "application/json": components["schemas"]["PublicationPageEnvelope"];
}; };
}; };
400: components["responses"]["MalformedRequest"]; 400: components["responses"]["MalformedRequest"];
@@ -1858,7 +1963,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/json": components["schemas"]["PublishResult"]; "application/json": components["schemas"]["PublishResultEnvelope"];
}; };
}; };
400: components["responses"]["MalformedRequest"]; 400: components["responses"]["MalformedRequest"];
@@ -1887,7 +1992,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/json": components["schemas"]["PublicationSnapshot"]; "application/json": components["schemas"]["PublicationSnapshotEnvelope"];
}; };
}; };
401: components["responses"]["AuthenticationRequired"]; 401: components["responses"]["AuthenticationRequired"];
@@ -1918,7 +2023,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/json": components["schemas"]["CatalogPage"]; "application/json": components["schemas"]["CatalogPageEnvelope"];
}; };
}; };
400: components["responses"]["MalformedRequest"]; 400: components["responses"]["MalformedRequest"];
@@ -1951,7 +2056,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/json": components["schemas"]["AssetPage"]; "application/json": components["schemas"]["AssetPageEnvelope"];
}; };
}; };
400: components["responses"]["MalformedRequest"]; 400: components["responses"]["MalformedRequest"];
@@ -1989,7 +2094,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/json": components["schemas"]["Asset"]; "application/json": components["schemas"]["AssetEnvelope"];
}; };
}; };
400: components["responses"]["MalformedRequest"]; 400: components["responses"]["MalformedRequest"];
@@ -2019,7 +2124,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/json": components["schemas"]["AssetDetail"]; "application/json": components["schemas"]["AssetDetailEnvelope"];
}; };
}; };
401: components["responses"]["AuthenticationRequired"]; 401: components["responses"]["AuthenticationRequired"];
@@ -2058,7 +2163,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/json": components["schemas"]["Asset"]; "application/json": components["schemas"]["AssetEnvelope"];
}; };
}; };
400: components["responses"]["MalformedRequest"]; 400: components["responses"]["MalformedRequest"];
@@ -1,7 +1,7 @@
openapi: 3.1.0 openapi: 3.1.0
info: info:
title: Tech Log Studio API title: Tech Log Studio API
version: 2.0.0 version: 3.0.0
description: | description: |
Tech Log Studio orchestration 계약이다. Tech Log Studio orchestration 계약이다.
@@ -109,7 +109,7 @@ paths:
tags: [Session] tags: [Session]
summary: 현재 Studio 세션과 CSRF 토큰을 조회한다 summary: 현재 Studio 세션과 CSRF 토큰을 조회한다
responses: responses:
"200": { description: 인증된 Studio 세션, content: { application/json: { schema: { $ref: "#/components/schemas/StudioSession" } } } } "200": { description: 인증된 Studio 세션, content: { application/json: { schema: { $ref: "#/components/schemas/StudioSessionEnvelope" } } } }
"401": { $ref: "#/components/responses/AuthenticationRequired" } "401": { $ref: "#/components/responses/AuthenticationRequired" }
"403": { $ref: "#/components/responses/AccessDenied" } "403": { $ref: "#/components/responses/AccessDenied" }
"503": { $ref: "#/components/responses/StudioUnavailable" } "503": { $ref: "#/components/responses/StudioUnavailable" }
@@ -123,7 +123,7 @@ paths:
`nextAction`을 포함한 모든 workflow 상태는 서버가 계산한다. `nextAction`을 포함한 모든 workflow 상태는 서버가 계산한다.
Frontend는 여러 endpoint를 조합해 workflow 상태를 재추론하지 않는다. Frontend는 여러 endpoint를 조합해 workflow 상태를 재추론하지 않는다.
responses: responses:
"200": { description: Dashboard lists and totals, content: { application/json: { schema: { $ref: "#/components/schemas/StudioDashboard" } } } } "200": { description: Dashboard lists and totals, content: { application/json: { schema: { $ref: "#/components/schemas/StudioDashboardEnvelope" } } } }
"401": { $ref: "#/components/responses/AuthenticationRequired" } "401": { $ref: "#/components/responses/AuthenticationRequired" }
"403": { $ref: "#/components/responses/AccessDenied" } "403": { $ref: "#/components/responses/AccessDenied" }
"503": { $ref: "#/components/responses/StudioUnavailable" } "503": { $ref: "#/components/responses/StudioUnavailable" }
@@ -150,7 +150,7 @@ paths:
- { $ref: "#/components/parameters/Cursor" } - { $ref: "#/components/parameters/Cursor" }
- { $ref: "#/components/parameters/Limit" } - { $ref: "#/components/parameters/Limit" }
responses: responses:
"200": { description: Working-copy cursor page, content: { application/json: { schema: { $ref: "#/components/schemas/DocumentPage" } } } } "200": { description: Working-copy cursor page, content: { application/json: { schema: { $ref: "#/components/schemas/DocumentPageEnvelope" } } } }
"400": { $ref: "#/components/responses/MalformedRequest" } "400": { $ref: "#/components/responses/MalformedRequest" }
"401": { $ref: "#/components/responses/AuthenticationRequired" } "401": { $ref: "#/components/responses/AuthenticationRequired" }
"403": { $ref: "#/components/responses/AccessDenied" } "403": { $ref: "#/components/responses/AccessDenied" }
@@ -169,7 +169,7 @@ paths:
"201": "201":
description: Created working copy description: Created working copy
headers: { Idempotency-Replayed: { $ref: "#/components/headers/IdempotencyReplayed" } } headers: { Idempotency-Replayed: { $ref: "#/components/headers/IdempotencyReplayed" } }
content: { application/json: { schema: { $ref: "#/components/schemas/WorkingCopy" } } } content: { application/json: { schema: { $ref: "#/components/schemas/WorkingCopyEnvelope" } } }
"400": { $ref: "#/components/responses/MalformedRequest" } "400": { $ref: "#/components/responses/MalformedRequest" }
"401": { $ref: "#/components/responses/AuthenticationRequired" } "401": { $ref: "#/components/responses/AuthenticationRequired" }
"403": { $ref: "#/components/responses/AccessDenied" } "403": { $ref: "#/components/responses/AccessDenied" }
@@ -184,7 +184,7 @@ paths:
tags: [Documents] tags: [Documents]
summary: Get a working copy and its current state summary: Get a working copy and its current state
responses: responses:
"200": { description: Working-copy detail, content: { application/json: { schema: { $ref: "#/components/schemas/WorkingCopyDetail" } } } } "200": { description: Working-copy detail, content: { application/json: { schema: { $ref: "#/components/schemas/WorkingCopyDetailEnvelope" } } } }
"401": { $ref: "#/components/responses/AuthenticationRequired" } "401": { $ref: "#/components/responses/AuthenticationRequired" }
"403": { $ref: "#/components/responses/AccessDenied" } "403": { $ref: "#/components/responses/AccessDenied" }
"404": { $ref: "#/components/responses/DocumentNotFound" } "404": { $ref: "#/components/responses/DocumentNotFound" }
@@ -197,8 +197,8 @@ paths:
편집 가능한 content field만 저장한다. lifecycle 전이는 명시적인 Domain 편집 가능한 content field만 저장한다. lifecycle 전이는 명시적인 Domain
Action이 담당한다. 저장은 Public Projection을 변경하지 않는다. Action이 담당한다. 저장은 Public Projection을 변경하지 않는다.
`expectedVersion` 불일치는 `VERSION_CONFLICT`이며 응답 `ProblemDetails` `expectedVersion` 불일치는 `VERSION_CONFLICT`이며 응답 `error.details`
`latestDocument`로 현재 상태를 함께 제공한다. (`VersionConflictDetails`)의 `latestDocument`로 현재 상태를 함께 제공한다.
parameters: parameters:
- { $ref: "#/components/parameters/IdempotencyKey" } - { $ref: "#/components/parameters/IdempotencyKey" }
- { $ref: "#/components/parameters/CsrfToken" } - { $ref: "#/components/parameters/CsrfToken" }
@@ -207,7 +207,7 @@ paths:
"200": "200":
description: Saved working-copy detail description: Saved working-copy detail
headers: { Idempotency-Replayed: { $ref: "#/components/headers/IdempotencyReplayed" } } headers: { Idempotency-Replayed: { $ref: "#/components/headers/IdempotencyReplayed" } }
content: { application/json: { schema: { $ref: "#/components/schemas/WorkingCopyDetail" } } } content: { application/json: { schema: { $ref: "#/components/schemas/WorkingCopyDetailEnvelope" } } }
"400": { $ref: "#/components/responses/MalformedRequest" } "400": { $ref: "#/components/responses/MalformedRequest" }
"401": { $ref: "#/components/responses/AuthenticationRequired" } "401": { $ref: "#/components/responses/AuthenticationRequired" }
"403": { $ref: "#/components/responses/AccessDenied" } "403": { $ref: "#/components/responses/AccessDenied" }
@@ -244,7 +244,7 @@ paths:
"200": "200":
description: Validation report description: Validation report
headers: { Idempotency-Replayed: { $ref: "#/components/headers/IdempotencyReplayed" } } headers: { Idempotency-Replayed: { $ref: "#/components/headers/IdempotencyReplayed" } }
content: { application/json: { schema: { $ref: "#/components/schemas/ValidationReport" } } } content: { application/json: { schema: { $ref: "#/components/schemas/ValidationReportEnvelope" } } }
"400": { $ref: "#/components/responses/MalformedRequest" } "400": { $ref: "#/components/responses/MalformedRequest" }
"401": { $ref: "#/components/responses/AuthenticationRequired" } "401": { $ref: "#/components/responses/AuthenticationRequired" }
"403": { $ref: "#/components/responses/AccessDenied" } "403": { $ref: "#/components/responses/AccessDenied" }
@@ -264,7 +264,7 @@ paths:
anonymous `/preview/{token}` 계약은 폐기되었다. Preview는 인증된 anonymous `/preview/{token}` 계약은 폐기되었다. Preview는 인증된
Studio API로만 조회한다. Studio API로만 조회한다.
responses: responses:
"200": { description: Preview detail, content: { application/json: { schema: { $ref: "#/components/schemas/PreviewDetail" } } } } "200": { description: Preview detail, content: { application/json: { schema: { $ref: "#/components/schemas/PreviewDetailEnvelope" } } } }
"401": { $ref: "#/components/responses/AuthenticationRequired" } "401": { $ref: "#/components/responses/AuthenticationRequired" }
"403": { $ref: "#/components/responses/AccessDenied" } "403": { $ref: "#/components/responses/AccessDenied" }
"404": { $ref: "#/components/responses/PreviewNotFound" } "404": { $ref: "#/components/responses/PreviewNotFound" }
@@ -285,7 +285,7 @@ paths:
"201": "201":
description: Created preview description: Created preview
headers: { Idempotency-Replayed: { $ref: "#/components/headers/IdempotencyReplayed" } } headers: { Idempotency-Replayed: { $ref: "#/components/headers/IdempotencyReplayed" } }
content: { application/json: { schema: { $ref: "#/components/schemas/PublicPreview" } } } content: { application/json: { schema: { $ref: "#/components/schemas/PublicPreviewEnvelope" } } }
"400": { $ref: "#/components/responses/MalformedRequest" } "400": { $ref: "#/components/responses/MalformedRequest" }
"401": { $ref: "#/components/responses/AuthenticationRequired" } "401": { $ref: "#/components/responses/AuthenticationRequired" }
"403": { $ref: "#/components/responses/AccessDenied" } "403": { $ref: "#/components/responses/AccessDenied" }
@@ -329,7 +329,7 @@ paths:
"200": "200":
description: Publication aggregate and immutable event description: Publication aggregate and immutable event
headers: { Idempotency-Replayed: { $ref: "#/components/headers/IdempotencyReplayed" } } headers: { Idempotency-Replayed: { $ref: "#/components/headers/IdempotencyReplayed" } }
content: { application/json: { schema: { $ref: "#/components/schemas/PublishResult" } } } content: { application/json: { schema: { $ref: "#/components/schemas/PublishResultEnvelope" } } }
"400": { $ref: "#/components/responses/MalformedRequest" } "400": { $ref: "#/components/responses/MalformedRequest" }
"401": { $ref: "#/components/responses/AuthenticationRequired" } "401": { $ref: "#/components/responses/AuthenticationRequired" }
"403": { $ref: "#/components/responses/AccessDenied" } "403": { $ref: "#/components/responses/AccessDenied" }
@@ -350,7 +350,7 @@ paths:
- { $ref: "#/components/parameters/Cursor" } - { $ref: "#/components/parameters/Cursor" }
- { $ref: "#/components/parameters/Limit" } - { $ref: "#/components/parameters/Limit" }
responses: responses:
"200": { description: Publication cursor page, content: { application/json: { schema: { $ref: "#/components/schemas/PublicationPage" } } } } "200": { description: Publication cursor page, content: { application/json: { schema: { $ref: "#/components/schemas/PublicationPageEnvelope" } } } }
"400": { $ref: "#/components/responses/MalformedRequest" } "400": { $ref: "#/components/responses/MalformedRequest" }
"401": { $ref: "#/components/responses/AuthenticationRequired" } "401": { $ref: "#/components/responses/AuthenticationRequired" }
"403": { $ref: "#/components/responses/AccessDenied" } "403": { $ref: "#/components/responses/AccessDenied" }
@@ -379,7 +379,7 @@ paths:
"200": "200":
description: Updated publication aggregate and event description: Updated publication aggregate and event
headers: { Idempotency-Replayed: { $ref: "#/components/headers/IdempotencyReplayed" } } headers: { Idempotency-Replayed: { $ref: "#/components/headers/IdempotencyReplayed" } }
content: { application/json: { schema: { $ref: "#/components/schemas/PublishResult" } } } content: { application/json: { schema: { $ref: "#/components/schemas/PublishResultEnvelope" } } }
"400": { $ref: "#/components/responses/MalformedRequest" } "400": { $ref: "#/components/responses/MalformedRequest" }
"401": { $ref: "#/components/responses/AuthenticationRequired" } "401": { $ref: "#/components/responses/AuthenticationRequired" }
"403": { $ref: "#/components/responses/AccessDenied" } "403": { $ref: "#/components/responses/AccessDenied" }
@@ -401,7 +401,7 @@ paths:
`UNPUBLISHED` Event는 자체 snapshot을 갖지 않는다. 이 경우 `UNPUBLISHED` Event는 자체 snapshot을 갖지 않는다. 이 경우
`sourcePublishedEventId`가 가리키는 마지막 공개 Snapshot을 사용한다. `sourcePublishedEventId`가 가리키는 마지막 공개 Snapshot을 사용한다.
responses: responses:
"200": { description: Publication snapshot, content: { application/json: { schema: { $ref: "#/components/schemas/PublicationSnapshot" } } } } "200": { description: Publication snapshot, content: { application/json: { schema: { $ref: "#/components/schemas/PublicationSnapshotEnvelope" } } } }
"401": { $ref: "#/components/responses/AuthenticationRequired" } "401": { $ref: "#/components/responses/AuthenticationRequired" }
"403": { $ref: "#/components/responses/AccessDenied" } "403": { $ref: "#/components/responses/AccessDenied" }
"404": { $ref: "#/components/responses/PublicationSnapshotNotFound" } "404": { $ref: "#/components/responses/PublicationSnapshotNotFound" }
@@ -430,7 +430,7 @@ paths:
- { $ref: "#/components/parameters/Cursor" } - { $ref: "#/components/parameters/Cursor" }
- { $ref: "#/components/parameters/Limit" } - { $ref: "#/components/parameters/Limit" }
responses: responses:
"200": { description: Catalog cursor page, content: { application/json: { schema: { $ref: "#/components/schemas/CatalogPage" } } } } "200": { description: Catalog cursor page, content: { application/json: { schema: { $ref: "#/components/schemas/CatalogPageEnvelope" } } } }
"400": { $ref: "#/components/responses/MalformedRequest" } "400": { $ref: "#/components/responses/MalformedRequest" }
"401": { $ref: "#/components/responses/AuthenticationRequired" } "401": { $ref: "#/components/responses/AuthenticationRequired" }
"403": { $ref: "#/components/responses/AccessDenied" } "403": { $ref: "#/components/responses/AccessDenied" }
@@ -449,7 +449,7 @@ paths:
- { $ref: "#/components/parameters/Cursor" } - { $ref: "#/components/parameters/Cursor" }
- { $ref: "#/components/parameters/Limit" } - { $ref: "#/components/parameters/Limit" }
responses: responses:
"200": { description: Asset cursor page, content: { application/json: { schema: { $ref: "#/components/schemas/AssetPage" } } } } "200": { description: Asset cursor page, content: { application/json: { schema: { $ref: "#/components/schemas/AssetPageEnvelope" } } } }
"400": { $ref: "#/components/responses/MalformedRequest" } "400": { $ref: "#/components/responses/MalformedRequest" }
"401": { $ref: "#/components/responses/AuthenticationRequired" } "401": { $ref: "#/components/responses/AuthenticationRequired" }
"403": { $ref: "#/components/responses/AccessDenied" } "403": { $ref: "#/components/responses/AccessDenied" }
@@ -483,7 +483,7 @@ paths:
"201": "201":
description: Stored asset description: Stored asset
headers: { Idempotency-Replayed: { $ref: "#/components/headers/IdempotencyReplayed" } } headers: { Idempotency-Replayed: { $ref: "#/components/headers/IdempotencyReplayed" } }
content: { application/json: { schema: { $ref: "#/components/schemas/Asset" } } } content: { application/json: { schema: { $ref: "#/components/schemas/AssetEnvelope" } } }
"400": { $ref: "#/components/responses/MalformedRequest" } "400": { $ref: "#/components/responses/MalformedRequest" }
"401": { $ref: "#/components/responses/AuthenticationRequired" } "401": { $ref: "#/components/responses/AuthenticationRequired" }
"403": { $ref: "#/components/responses/AccessDenied" } "403": { $ref: "#/components/responses/AccessDenied" }
@@ -500,7 +500,7 @@ paths:
tags: [Assets] tags: [Assets]
summary: Get an asset with its usage summary: Get an asset with its usage
responses: responses:
"200": { description: Asset detail, content: { application/json: { schema: { $ref: "#/components/schemas/AssetDetail" } } } } "200": { description: Asset detail, content: { application/json: { schema: { $ref: "#/components/schemas/AssetDetailEnvelope" } } } }
"401": { $ref: "#/components/responses/AuthenticationRequired" } "401": { $ref: "#/components/responses/AuthenticationRequired" }
"403": { $ref: "#/components/responses/AccessDenied" } "403": { $ref: "#/components/responses/AccessDenied" }
"404": { $ref: "#/components/responses/AssetNotFound" } "404": { $ref: "#/components/responses/AssetNotFound" }
@@ -520,7 +520,7 @@ paths:
"200": "200":
description: Updated asset description: Updated asset
headers: { Idempotency-Replayed: { $ref: "#/components/headers/IdempotencyReplayed" } } headers: { Idempotency-Replayed: { $ref: "#/components/headers/IdempotencyReplayed" } }
content: { application/json: { schema: { $ref: "#/components/schemas/Asset" } } } content: { application/json: { schema: { $ref: "#/components/schemas/AssetEnvelope" } } }
"400": { $ref: "#/components/responses/MalformedRequest" } "400": { $ref: "#/components/responses/MalformedRequest" }
"401": { $ref: "#/components/responses/AuthenticationRequired" } "401": { $ref: "#/components/responses/AuthenticationRequired" }
"403": { $ref: "#/components/responses/AccessDenied" } "403": { $ref: "#/components/responses/AccessDenied" }
@@ -593,43 +593,232 @@ components:
IdempotencyReplayed: { description: True when the original result was replayed, schema: { type: boolean } } IdempotencyReplayed: { description: True when the original result was replayed, schema: { type: boolean } }
responses: responses:
MalformedRequest: { description: Malformed request, x-error-codes: [REQUEST_VALIDATION_FAILED], content: { application/problem+json: { schema: { $ref: "#/components/schemas/ProblemDetails" } } } } MalformedRequest: { description: Malformed request, x-error-codes: [REQUEST_VALIDATION_FAILED], content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } }
AuthenticationRequired: { description: Authentication required, x-error-codes: [AUTHENTICATION_REQUIRED], content: { application/problem+json: { schema: { $ref: "#/components/schemas/ProblemDetails" } } } } AuthenticationRequired: { description: Authentication required, x-error-codes: [AUTHENTICATION_REQUIRED], content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } }
AccessDenied: { description: Studio access denied, x-error-codes: [STUDIO_ACCESS_DENIED], content: { application/problem+json: { schema: { $ref: "#/components/schemas/ProblemDetails" } } } } AccessDenied: { description: Studio access denied, x-error-codes: [STUDIO_ACCESS_DENIED], content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } }
DocumentNotFound: { description: Document not found, x-error-codes: [DOCUMENT_NOT_FOUND], content: { application/problem+json: { schema: { $ref: "#/components/schemas/ProblemDetails" } } } } DocumentNotFound: { description: Document not found, x-error-codes: [DOCUMENT_NOT_FOUND], content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } }
PreviewNotFound: { description: Document or preview not found, x-error-codes: [DOCUMENT_NOT_FOUND, PREVIEW_NOT_FOUND], content: { application/problem+json: { schema: { $ref: "#/components/schemas/ProblemDetails" } } } } PreviewNotFound: { description: Document or preview not found, x-error-codes: [DOCUMENT_NOT_FOUND, PREVIEW_NOT_FOUND], content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } }
PublicationNotFound: { description: Publication not found, x-error-codes: [PUBLICATION_NOT_FOUND], content: { application/problem+json: { schema: { $ref: "#/components/schemas/ProblemDetails" } } } } PublicationNotFound: { description: Publication not found, x-error-codes: [PUBLICATION_NOT_FOUND], content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } }
PublicationSnapshotNotFound: { description: Publication event or snapshot not found, x-error-codes: [PUBLICATION_EVENT_NOT_FOUND, PUBLICATION_SNAPSHOT_NOT_FOUND], content: { application/problem+json: { schema: { $ref: "#/components/schemas/ProblemDetails" } } } } PublicationSnapshotNotFound: { description: Publication event or snapshot not found, x-error-codes: [PUBLICATION_EVENT_NOT_FOUND, PUBLICATION_SNAPSHOT_NOT_FOUND], content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } }
AssetNotFound: { description: Asset not found, x-error-codes: [ASSET_NOT_FOUND], content: { application/problem+json: { schema: { $ref: "#/components/schemas/ProblemDetails" } } } } AssetNotFound: { description: Asset not found, x-error-codes: [ASSET_NOT_FOUND], content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } }
CommandConflict: CommandConflict:
description: | description: |
Command conflicts with current state, freshness, or idempotency. Command conflicts with current state, freshness, or idempotency.
`ASSET_IN_USE`는 사용 중이거나 공개 이력이 있는 Asset의 hard delete 시도다. `ASSET_IN_USE`는 사용 중이거나 공개 이력이 있는 Asset의 hard delete 시도다.
x-error-codes: [VERSION_CONFLICT, PUBLICATION_CONFLICT, VALIDATION_STALE, PREVIEW_STALE, PREVIEW_EXPIRED, IDEMPOTENCY_KEY_REUSED, ASSET_IN_USE] x-error-codes: [VERSION_CONFLICT, PUBLICATION_CONFLICT, VALIDATION_STALE, PREVIEW_STALE, PREVIEW_EXPIRED, IDEMPOTENCY_KEY_REUSED, ASSET_IN_USE]
content: { application/problem+json: { schema: { $ref: "#/components/schemas/ProblemDetails" } } } content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } }
RequestValidationFailed: { description: Request validation failed, x-error-codes: [REQUEST_VALIDATION_FAILED], content: { application/problem+json: { schema: { $ref: "#/components/schemas/ProblemDetails" } } } } RequestValidationFailed: { description: Request validation failed, x-error-codes: [REQUEST_VALIDATION_FAILED], content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } }
PreviewRejected: PreviewRejected:
description: Preview 생성이 도메인 규칙으로 거절되었다 description: Preview 생성이 도메인 규칙으로 거절되었다
x-error-codes: [REQUEST_VALIDATION_FAILED, VALIDATION_FAILED, ASSET_NOT_READY, ASSET_QUARANTINED] x-error-codes: [REQUEST_VALIDATION_FAILED, DOCUMENT_VALIDATION_FAILED, ASSET_NOT_READY, ASSET_QUARANTINED]
content: { application/problem+json: { schema: { $ref: "#/components/schemas/ProblemDetails" } } } content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } }
PublishRejected: PublishRejected:
description: | description: |
Publication validation이 실패했다. Publication validation이 실패했다.
`WARNING_ACKNOWLEDGEMENT_REQUIRED`는 `acknowledgedWarningCodes`가 `WARNING_ACKNOWLEDGEMENT_REQUIRED`는 `acknowledgedWarningCodes`가
현재 Validation의 WARNING 집합을 덮지 못한 경우다. 현재 Validation의 WARNING 집합을 덮지 못한 경우다.
x-error-codes: [REQUEST_VALIDATION_FAILED, VALIDATION_FAILED, WARNING_ACKNOWLEDGEMENT_REQUIRED, ASSET_NOT_READY, ASSET_QUARANTINED] x-error-codes: [REQUEST_VALIDATION_FAILED, DOCUMENT_VALIDATION_FAILED, WARNING_ACKNOWLEDGEMENT_REQUIRED, ASSET_NOT_READY, ASSET_QUARANTINED]
content: { application/problem+json: { schema: { $ref: "#/components/schemas/ProblemDetails" } } } content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } }
AssetRejected: AssetRejected:
description: Asset metadata 변경이 거절되었다 description: Asset metadata 변경이 거절되었다
x-error-codes: [REQUEST_VALIDATION_FAILED, ASSET_NOT_READY, ASSET_QUARANTINED] x-error-codes: [REQUEST_VALIDATION_FAILED, ASSET_NOT_READY, ASSET_QUARANTINED]
content: { application/problem+json: { schema: { $ref: "#/components/schemas/ProblemDetails" } } } content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } }
PayloadTooLarge: { description: Upload exceeds the configured size limit, x-error-codes: [PAYLOAD_TOO_LARGE], content: { application/problem+json: { schema: { $ref: "#/components/schemas/ProblemDetails" } } } } PayloadTooLarge: { description: Upload exceeds the configured size limit, x-error-codes: [PAYLOAD_TOO_LARGE], content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } }
UnsupportedMediaType: { description: Unsupported media type, x-error-codes: [UNSUPPORTED_MEDIA_TYPE], content: { application/problem+json: { schema: { $ref: "#/components/schemas/ProblemDetails" } } } } UnsupportedMediaType: { description: Unsupported media type, x-error-codes: [UNSUPPORTED_MEDIA_TYPE], content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } }
StudioUnavailable: { description: Studio unavailable, x-error-codes: [STUDIO_UNAVAILABLE], content: { application/problem+json: { schema: { $ref: "#/components/schemas/ProblemDetails" } } } } StudioUnavailable: { description: Studio unavailable, x-error-codes: [STUDIO_UNAVAILABLE], content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } }
schemas: schemas:
# ------------------------------------------------------------- envelope
# wire format은 봉투다 (ADR-006). payload 스키마는 그대로 두고
# 응답만 <Payload>Envelope으로 감싼다.
ResponseMeta:
type: object
additionalProperties: false
required: [requestId, traceId]
properties:
requestId: { type: string, minLength: 1, maxLength: 200 }
traceId: { type: string, minLength: 1, maxLength: 200 }
correlationId: { type: [string, "null"], maxLength: 200 }
page: { type: ["object", "null"], additionalProperties: true, description: "Studio는 body 안 cursor 페이지네이션을 쓰므로 항상 null이다. 백엔드 템플릿의 ResponseMeta record가 이 필드를 직렬화한다." }
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, 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]
category:
type: string
enum: [VALIDATION, AUTH, AUTHZ, NOT_FOUND, CONFLICT, RATE_LIMIT,
TRANSIENT_DEPENDENCY, PERMANENT_DEPENDENCY, DATA_INTEGRITY, INTERNAL]
message: { type: string, minLength: 1, maxLength: 5000 }
retryable: { type: boolean }
details:
oneOf:
- $ref: "#/components/schemas/ValidationErrorDetails"
- $ref: "#/components/schemas/VersionConflictDetails"
- $ref: "#/components/schemas/PublicationConflictDetails"
- type: "null"
ErrorEnvelope:
type: object
additionalProperties: false
required: [success, error, meta]
properties:
success: { type: boolean, const: false }
error: { $ref: "#/components/schemas/ApiError" }
meta: { $ref: "#/components/schemas/ResponseMeta" }
ValidationErrorDetails:
type: object
additionalProperties: false
required: [fieldErrors]
properties:
fieldErrors: { type: array, maxItems: 200, items: { $ref: "#/components/schemas/FieldError" } }
VersionConflictDetails:
type: object
additionalProperties: false
required: [latestDocument]
properties:
latestDocument: { $ref: "#/components/schemas/WorkingCopyDetail" }
conflictingFields:
type: array
uniqueItems: true
maxItems: 200
items: { type: string, pattern: "^(?:/(?:[^~/]|~0|~1)*)*$" }
PublicationConflictDetails:
type: object
additionalProperties: false
required: [latestPublication]
properties:
latestPublication: { $ref: "#/components/schemas/PublicationAggregate" }
StudioSessionEnvelope:
type: object
additionalProperties: false
required: [success, data, meta]
properties:
success: { type: boolean, const: true }
data: { $ref: "#/components/schemas/StudioSession" }
meta: { $ref: "#/components/schemas/ResponseMeta" }
StudioDashboardEnvelope:
type: object
additionalProperties: false
required: [success, data, meta]
properties:
success: { type: boolean, const: true }
data: { $ref: "#/components/schemas/StudioDashboard" }
meta: { $ref: "#/components/schemas/ResponseMeta" }
DocumentPageEnvelope:
type: object
additionalProperties: false
required: [success, data, meta]
properties:
success: { type: boolean, const: true }
data: { $ref: "#/components/schemas/DocumentPage" }
meta: { $ref: "#/components/schemas/ResponseMeta" }
WorkingCopyDetailEnvelope:
type: object
additionalProperties: false
required: [success, data, meta]
properties:
success: { type: boolean, const: true }
data: { $ref: "#/components/schemas/WorkingCopyDetail" }
meta: { $ref: "#/components/schemas/ResponseMeta" }
WorkingCopyEnvelope:
type: object
additionalProperties: false
required: [success, data, meta]
properties:
success: { type: boolean, const: true }
data: { $ref: "#/components/schemas/WorkingCopy" }
meta: { $ref: "#/components/schemas/ResponseMeta" }
ValidationReportEnvelope:
type: object
additionalProperties: false
required: [success, data, meta]
properties:
success: { type: boolean, const: true }
data: { $ref: "#/components/schemas/ValidationReport" }
meta: { $ref: "#/components/schemas/ResponseMeta" }
PreviewDetailEnvelope:
type: object
additionalProperties: false
required: [success, data, meta]
properties:
success: { type: boolean, const: true }
data: { $ref: "#/components/schemas/PreviewDetail" }
meta: { $ref: "#/components/schemas/ResponseMeta" }
PublicPreviewEnvelope:
type: object
additionalProperties: false
required: [success, data, meta]
properties:
success: { type: boolean, const: true }
data: { $ref: "#/components/schemas/PublicPreview" }
meta: { $ref: "#/components/schemas/ResponseMeta" }
PublishResultEnvelope:
type: object
additionalProperties: false
required: [success, data, meta]
properties:
success: { type: boolean, const: true }
data: { $ref: "#/components/schemas/PublishResult" }
meta: { $ref: "#/components/schemas/ResponseMeta" }
PublicationPageEnvelope:
type: object
additionalProperties: false
required: [success, data, meta]
properties:
success: { type: boolean, const: true }
data: { $ref: "#/components/schemas/PublicationPage" }
meta: { $ref: "#/components/schemas/ResponseMeta" }
PublicationSnapshotEnvelope:
type: object
additionalProperties: false
required: [success, data, meta]
properties:
success: { type: boolean, const: true }
data: { $ref: "#/components/schemas/PublicationSnapshot" }
meta: { $ref: "#/components/schemas/ResponseMeta" }
CatalogPageEnvelope:
type: object
additionalProperties: false
required: [success, data, meta]
properties:
success: { type: boolean, const: true }
data: { $ref: "#/components/schemas/CatalogPage" }
meta: { $ref: "#/components/schemas/ResponseMeta" }
AssetPageEnvelope:
type: object
additionalProperties: false
required: [success, data, meta]
properties:
success: { type: boolean, const: true }
data: { $ref: "#/components/schemas/AssetPage" }
meta: { $ref: "#/components/schemas/ResponseMeta" }
AssetDetailEnvelope:
type: object
additionalProperties: false
required: [success, data, meta]
properties:
success: { type: boolean, const: true }
data: { $ref: "#/components/schemas/AssetDetail" }
meta: { $ref: "#/components/schemas/ResponseMeta" }
AssetEnvelope:
type: object
additionalProperties: false
required: [success, data, meta]
properties:
success: { type: boolean, const: true }
data: { $ref: "#/components/schemas/Asset" }
meta: { $ref: "#/components/schemas/ResponseMeta" }
# ---------------------------------------------------------------- session # ---------------------------------------------------------------- session
StudioSession: StudioSession:
type: object type: object
@@ -739,7 +928,7 @@ components:
- type: object - type: object
required: [kind, problem, conclusion, environment, reproduction, lastVerifiedOn, bodyMarkdown] required: [kind, problem, conclusion, environment, reproduction, lastVerifiedOn, bodyMarkdown]
properties: properties:
kind: { type: string, const: CASE } kind: { type: string, enum: [CASE] }
problem: { type: string, maxLength: 100000 } problem: { type: string, maxLength: 100000 }
conclusion: { type: string, maxLength: 100000 } conclusion: { type: string, maxLength: 100000 }
environment: { type: string, maxLength: 100000 } environment: { type: string, maxLength: 100000 }
@@ -758,7 +947,7 @@ components:
- type: object - type: object
required: [kind, purpose, rules, applyWhen, exceptions, examples, verifiedOn] required: [kind, purpose, rules, applyWhen, exceptions, examples, verifiedOn]
properties: properties:
kind: { type: string, const: REFERENCE } kind: { type: string, enum: [REFERENCE] }
purpose: { type: string, maxLength: 100000 } purpose: { type: string, maxLength: 100000 }
rules: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/ReferenceRule" } } rules: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/ReferenceRule" } }
applyWhen: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } } applyWhen: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } }
@@ -772,7 +961,7 @@ components:
- type: object - type: object
required: [kind, questionStatus, facts, assumptions, unknowns, constraints, options, nextValidation, resolution] required: [kind, questionStatus, facts, assumptions, unknowns, constraints, options, nextValidation, resolution]
properties: properties:
kind: { type: string, const: QUESTION } kind: { type: string, enum: [QUESTION] }
questionStatus: questionStatus:
type: [string, "null"] type: [string, "null"]
enum: [OPEN, RESOLVED, null] enum: [OPEN, RESOLVED, null]
@@ -799,7 +988,7 @@ components:
- type: object - type: object
required: [kind, decisionStatus, decidedOn, statement, rationale, consequences] required: [kind, decisionStatus, decidedOn, statement, rationale, consequences]
properties: properties:
kind: { type: string, const: PROJECT_DECISION } kind: { type: string, enum: [PROJECT_DECISION] }
decisionStatus: decisionStatus:
type: [string, "null"] type: [string, "null"]
enum: [PROPOSED, ADOPTED, null] enum: [PROPOSED, ADOPTED, null]
@@ -846,7 +1035,7 @@ components:
- type: object - type: object
required: [kind, problem, conclusion, environment, reproduction, lastVerifiedOn, bodyMarkdown] required: [kind, problem, conclusion, environment, reproduction, lastVerifiedOn, bodyMarkdown]
properties: properties:
kind: { type: string, const: CASE } kind: { type: string, enum: [CASE] }
problem: { type: string, maxLength: 100000 } problem: { type: string, maxLength: 100000 }
conclusion: { type: string, maxLength: 100000 } conclusion: { type: string, maxLength: 100000 }
environment: { type: string, maxLength: 100000 } environment: { type: string, maxLength: 100000 }
@@ -860,7 +1049,7 @@ components:
- type: object - type: object
required: [kind, purpose, rules, applyWhen, exceptions, examples, verifiedOn] required: [kind, purpose, rules, applyWhen, exceptions, examples, verifiedOn]
properties: properties:
kind: { type: string, const: REFERENCE } kind: { type: string, enum: [REFERENCE] }
purpose: { type: string, maxLength: 100000 } purpose: { type: string, maxLength: 100000 }
rules: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/ReferenceRule" } } rules: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/ReferenceRule" } }
applyWhen: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } } applyWhen: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } }
@@ -874,7 +1063,7 @@ components:
- type: object - type: object
required: [kind, questionStatus, facts, assumptions, unknowns, constraints, options, nextValidation, resolution] required: [kind, questionStatus, facts, assumptions, unknowns, constraints, options, nextValidation, resolution]
properties: properties:
kind: { type: string, const: QUESTION } kind: { type: string, enum: [QUESTION] }
questionStatus: { type: [string, "null"], enum: [OPEN, RESOLVED, null] } questionStatus: { type: [string, "null"], enum: [OPEN, RESOLVED, null] }
facts: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } } facts: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } }
assumptions: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } } assumptions: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } }
@@ -893,7 +1082,7 @@ components:
- type: object - type: object
required: [kind, decisionStatus, decidedOn, statement, rationale, consequences] required: [kind, decisionStatus, decidedOn, statement, rationale, consequences]
properties: properties:
kind: { type: string, const: PROJECT_DECISION } kind: { type: string, enum: [PROJECT_DECISION] }
decisionStatus: { type: [string, "null"], enum: [PROPOSED, ADOPTED, null] } decisionStatus: { type: [string, "null"], enum: [PROPOSED, ADOPTED, null] }
decidedOn: { type: [string, "null"], format: date } decidedOn: { type: [string, "null"], format: date }
statement: { type: string, maxLength: 100000 } statement: { type: string, maxLength: 100000 }
@@ -1062,7 +1251,7 @@ components:
additionalProperties: false additionalProperties: false
required: [type, text] required: [type, text]
properties: properties:
type: { type: string, const: TEXT } type: { type: string, enum: [TEXT] }
text: { type: string, minLength: 1, maxLength: 100000 } text: { type: string, minLength: 1, maxLength: 100000 }
InlineContainer: InlineContainer:
type: object type: object
@@ -1075,14 +1264,14 @@ components:
additionalProperties: false additionalProperties: false
required: [type, code] required: [type, code]
properties: properties:
type: { type: string, const: INLINE_CODE } type: { type: string, enum: [INLINE_CODE] }
code: { type: string, minLength: 1, maxLength: 100000 } code: { type: string, minLength: 1, maxLength: 100000 }
InlineLink: InlineLink:
type: object type: object
additionalProperties: false additionalProperties: false
required: [type, label, href] required: [type, label, href]
properties: properties:
type: { type: string, const: LINK } type: { type: string, enum: [LINK] }
label: { type: string, minLength: 1, maxLength: 100000 } label: { type: string, minLength: 1, maxLength: 100000 }
href: { type: string, format: uri, maxLength: 2000 } href: { type: string, format: uri, maxLength: 2000 }
InlineStatus: InlineStatus:
@@ -1090,7 +1279,7 @@ components:
additionalProperties: false additionalProperties: false
required: [type, label, tone] required: [type, label, tone]
properties: properties:
type: { type: string, const: STATUS } type: { type: string, enum: [STATUS] }
label: { type: string, minLength: 1, maxLength: 120 } label: { type: string, minLength: 1, maxLength: 120 }
tone: { type: string, enum: [warning, evidence, neutral] } tone: { type: string, enum: [warning, evidence, neutral] }
Inline: Inline:
@@ -1114,18 +1303,18 @@ components:
unevaluatedProperties: false unevaluatedProperties: false
allOf: allOf:
- { $ref: "#/components/schemas/InlineContainer" } - { $ref: "#/components/schemas/InlineContainer" }
- { type: object, properties: { type: { type: string, const: EMPHASIS } } } - { type: object, properties: { type: { type: string, enum: [EMPHASIS] } } }
InlineStrong: InlineStrong:
unevaluatedProperties: false unevaluatedProperties: false
allOf: allOf:
- { $ref: "#/components/schemas/InlineContainer" } - { $ref: "#/components/schemas/InlineContainer" }
- { type: object, properties: { type: { type: string, const: STRONG } } } - { type: object, properties: { type: { type: string, enum: [STRONG] } } }
HeadingBlock: HeadingBlock:
type: object type: object
additionalProperties: false additionalProperties: false
required: [type, id, level, content] required: [type, id, level, content]
properties: properties:
type: { type: string, const: HEADING } type: { type: string, enum: [HEADING] }
id: { type: string, minLength: 1, maxLength: 200 } id: { type: string, minLength: 1, maxLength: 200 }
level: { type: integer, minimum: 2, maximum: 4 } level: { type: integer, minimum: 2, maximum: 4 }
content: { type: array, maxItems: 1000, items: { $ref: "#/components/schemas/Inline" } } content: { type: array, maxItems: 1000, items: { $ref: "#/components/schemas/Inline" } }
@@ -1134,14 +1323,14 @@ components:
additionalProperties: false additionalProperties: false
required: [type, content] required: [type, content]
properties: properties:
type: { type: string, const: PARAGRAPH } type: { type: string, enum: [PARAGRAPH] }
content: { type: array, maxItems: 1000, items: { $ref: "#/components/schemas/Inline" } } content: { type: array, maxItems: 1000, items: { $ref: "#/components/schemas/Inline" } }
BlockquoteBlock: BlockquoteBlock:
type: object type: object
additionalProperties: false additionalProperties: false
required: [type, content] required: [type, content]
properties: properties:
type: { type: string, const: BLOCKQUOTE } type: { type: string, enum: [BLOCKQUOTE] }
content: { type: array, maxItems: 1000, items: { $ref: "#/components/schemas/Inline" } } content: { type: array, maxItems: 1000, items: { $ref: "#/components/schemas/Inline" } }
ListItem: ListItem:
type: object type: object
@@ -1160,18 +1349,18 @@ components:
unevaluatedProperties: false unevaluatedProperties: false
allOf: allOf:
- { $ref: "#/components/schemas/ListBlockBase" } - { $ref: "#/components/schemas/ListBlockBase" }
- { type: object, properties: { type: { type: string, const: UNORDERED_LIST } } } - { type: object, properties: { type: { type: string, enum: [UNORDERED_LIST] } } }
OrderedListBlock: OrderedListBlock:
unevaluatedProperties: false unevaluatedProperties: false
allOf: allOf:
- { $ref: "#/components/schemas/ListBlockBase" } - { $ref: "#/components/schemas/ListBlockBase" }
- { type: object, properties: { type: { type: string, const: ORDERED_LIST } } } - { type: object, properties: { type: { type: string, enum: [ORDERED_LIST] } } }
CodeBlock: CodeBlock:
type: object type: object
additionalProperties: false additionalProperties: false
required: [type, code, language, label] required: [type, code, language, label]
properties: properties:
type: { type: string, const: CODE_BLOCK } type: { type: string, enum: [CODE_BLOCK] }
code: { type: string, maxLength: 100000 } code: { type: string, maxLength: 100000 }
language: { type: [string, "null"], maxLength: 100 } language: { type: [string, "null"], maxLength: 100 }
label: { type: [string, "null"], maxLength: 200 } label: { type: [string, "null"], maxLength: 200 }
@@ -1202,7 +1391,7 @@ components:
additionalProperties: false additionalProperties: false
required: [type, id, caption, rowHeaderColumn, columns, rows] required: [type, id, caption, rowHeaderColumn, columns, rows]
properties: properties:
type: { type: string, const: DATA_TABLE } type: { type: string, enum: [DATA_TABLE] }
id: { type: string, minLength: 1, maxLength: 200 } id: { type: string, minLength: 1, maxLength: 200 }
caption: { type: string, maxLength: 1000 } caption: { type: string, maxLength: 1000 }
rowHeaderColumn: { type: [integer, "null"], minimum: 1 } rowHeaderColumn: { type: [integer, "null"], minimum: 1 }
@@ -1213,7 +1402,7 @@ components:
additionalProperties: false additionalProperties: false
required: [type, tone, label, content] required: [type, tone, label, content]
properties: properties:
type: { type: string, const: CALLOUT } type: { type: string, enum: [CALLOUT] }
tone: { type: string, enum: [warning, info] } tone: { type: string, enum: [warning, info] }
label: { type: string, maxLength: 200 } label: { type: string, maxLength: 200 }
content: { type: array, maxItems: 1000, items: { $ref: "#/components/schemas/Inline" } } content: { type: array, maxItems: 1000, items: { $ref: "#/components/schemas/Inline" } }
@@ -1236,7 +1425,7 @@ components:
Asset decorative=true → alt="" 허용 Asset decorative=true → alt="" 허용
``` ```
properties: properties:
type: { type: string, const: EVIDENCE_FIGURE } type: { type: string, enum: [EVIDENCE_FIGURE] }
key: { type: string, minLength: 1, maxLength: 200 } key: { type: string, minLength: 1, maxLength: 200 }
alt: { type: string, maxLength: 1000 } alt: { type: string, maxLength: 1000 }
caption: { type: string, maxLength: 1000 } caption: { type: string, maxLength: 1000 }
@@ -1288,7 +1477,7 @@ components:
- type: object - type: object
required: [kind, problem, conclusion, environment, reproduction, lastVerifiedOn, bodyBlocks] required: [kind, problem, conclusion, environment, reproduction, lastVerifiedOn, bodyBlocks]
properties: properties:
kind: { type: string, const: CASE } kind: { type: string, enum: [CASE] }
problem: { type: string, minLength: 1, maxLength: 100000 } problem: { type: string, minLength: 1, maxLength: 100000 }
conclusion: { type: string, minLength: 1, maxLength: 100000 } conclusion: { type: string, minLength: 1, maxLength: 100000 }
environment: { type: string, maxLength: 100000 } environment: { type: string, maxLength: 100000 }
@@ -1302,7 +1491,7 @@ components:
- type: object - type: object
required: [kind, purpose, rules, applyWhen, exceptions, examples, verifiedOn] required: [kind, purpose, rules, applyWhen, exceptions, examples, verifiedOn]
properties: properties:
kind: { type: string, const: REFERENCE } kind: { type: string, enum: [REFERENCE] }
purpose: { type: string, minLength: 1, maxLength: 100000 } purpose: { type: string, minLength: 1, maxLength: 100000 }
rules: { type: array, minItems: 1, maxItems: 50, items: { $ref: "#/components/schemas/ReferenceRule" } } rules: { type: array, minItems: 1, maxItems: 50, items: { $ref: "#/components/schemas/ReferenceRule" } }
applyWhen: { type: array, minItems: 1, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } } applyWhen: { type: array, minItems: 1, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } }
@@ -1324,7 +1513,7 @@ components:
- type: object - type: object
required: [kind, status, facts, assumptions, unknowns, constraints, options, nextValidation, resolution] required: [kind, status, facts, assumptions, unknowns, constraints, options, nextValidation, resolution]
properties: properties:
kind: { type: string, const: QUESTION } kind: { type: string, enum: [QUESTION] }
status: status:
type: string type: string
enum: [OPEN, RESOLVED] enum: [OPEN, RESOLVED]
@@ -1346,7 +1535,7 @@ components:
- type: object - type: object
required: [kind, status, decidedOn, statement, rationale, consequences] required: [kind, status, decidedOn, statement, rationale, consequences]
properties: properties:
kind: { type: string, const: PROJECT_DECISION } kind: { type: string, enum: [PROJECT_DECISION] }
status: { type: string, enum: [PROPOSED, ADOPTED] } status: { type: string, enum: [PROPOSED, ADOPTED] }
decidedOn: { type: string, format: date } decidedOn: { type: string, format: date }
statement: { type: string, minLength: 1, maxLength: 100000 } statement: { type: string, minLength: 1, maxLength: 100000 }
@@ -1645,51 +1834,3 @@ components:
pattern: "^(?:/(?:[^~/]|~0|~1)*)*$" pattern: "^(?:/(?:[^~/]|~0|~1)*)*$"
description: JSON Pointer to the invalid field description: JSON Pointer to the invalid field
message: { type: string, minLength: 1, maxLength: 1000 } message: { type: string, minLength: 1, maxLength: 1000 }
ProblemDetails:
type: object
additionalProperties: true
required: [type, title, status, detail, code]
properties:
type: { type: string, format: uri-reference }
title: { type: string, minLength: 1, maxLength: 200 }
status: { type: integer, minimum: 400, maximum: 599 }
detail: { type: string, minLength: 1, maxLength: 5000 }
code:
type: string
enum:
- AUTHENTICATION_REQUIRED
- STUDIO_ACCESS_DENIED
- DOCUMENT_NOT_FOUND
- VERSION_CONFLICT
- REQUEST_VALIDATION_FAILED
- VALIDATION_FAILED
- VALIDATION_STALE
- PREVIEW_NOT_FOUND
- PREVIEW_STALE
- PREVIEW_EXPIRED
- PUBLICATION_NOT_FOUND
- PUBLICATION_CONFLICT
- PUBLICATION_EVENT_NOT_FOUND
- PUBLICATION_SNAPSHOT_NOT_FOUND
- WARNING_ACKNOWLEDGEMENT_REQUIRED
- IDEMPOTENCY_KEY_REUSED
- ASSET_NOT_FOUND
- ASSET_NOT_READY
- ASSET_IN_USE
- ASSET_QUARANTINED
- PAYLOAD_TOO_LARGE
- UNSUPPORTED_MEDIA_TYPE
- STUDIO_UNAVAILABLE
instance: { type: string, format: uri-reference }
traceId: { type: string, maxLength: 200 }
fieldErrors: { type: array, maxItems: 200, items: { $ref: "#/components/schemas/FieldError" } }
latestDocument: { $ref: "#/components/schemas/WorkingCopyDetail" }
latestPublication: { $ref: "#/components/schemas/PublicationAggregate" }
conflictingFields:
type: array
uniqueItems: true
maxItems: 200
items:
type: string
pattern: "^(?:/(?:[^~/]|~0|~1)*)*$"
retryable: { type: boolean }
@@ -8,6 +8,7 @@ import type {
} from "../../../contracts/external-contract-runtime.ts"; } from "../../../contracts/external-contract-runtime.ts";
import { TECH_LOG_FEATURE_ID } from "../application/tech-log-feature-input.ts"; import { TECH_LOG_FEATURE_ID } from "../application/tech-log-feature-input.ts";
import canonicalSource from "./studio/canonical-source.json" with { type: "json" }; 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 { STUDIO_ERROR_CODES } from "../adapters/http/studio-error-mapping.ts";
import { import {
assertExactlyOneTechLogStudioBootstrapOperation, assertExactlyOneTechLogStudioBootstrapOperation,
@@ -51,21 +52,70 @@ function zodValidator<T>(schemaId: string, schema: z.ZodType<T>): RuntimeValidat
const passthrough = <T>(schemaId: string) => const passthrough = <T>(schemaId: string) =>
zodValidator<T>(schemaId, z.unknown() as unknown as z.ZodType<T>); zodValidator<T>(schemaId, z.unknown() as unknown as z.ZodType<T>);
const problemSchema = z /**
* wire format은 봉투다 (ADR-006). 전송 계층은 봉투 뼈대만 검증하고 payload는
* 통과시킨다 — generated 타입이 컴파일 시점 계약이고, 런타임 재검증은 계약 갱신
* 때마다 두 곳을 고치게 만든다. 다만 봉투 자체는 반드시 검증한다: 여기서 통과시키면
* 잘못된 모양이 앱 계층까지 조용히 흘러간다.
*/
const metaSchema = z
.object({ requestId: z.string().min(1), traceId: z.string().min(1) })
.loose();
export const envelopeData = <T>(schemaId: string): RuntimeValidator<T> =>
zodValidator<T>(
schemaId,
z
.object({ success: z.literal(true), data: z.unknown(), meta: metaSchema })
.loose()
.transform((envelope) => envelope.data as T) as unknown as z.ZodType<T>,
);
const apiErrorSchema = z
.object({ .object({
// canonical: `format: uri-reference` only, no length bound.
type: z.string().min(1),
title: z.string().min(1).max(200),
status: z.int().min(400).max(599),
detail: z.string().min(1).max(5000),
code: z.enum(STUDIO_ERROR_CODES as unknown as [string, ...string[]]), code: z.enum(STUDIO_ERROR_CODES as unknown as [string, ...string[]]),
category: z.string().min(1),
message: z.string().min(1).max(5000),
retryable: z.boolean(),
}) })
.loose(); .loose();
const PROBLEM = zodValidator("StudioProblemDetails", problemSchema); /**
* 봉투 오류를 기존 ProblemDetails 형태로 옮긴다. 앱 계층(`StudioGatewayError`)은
* 그 모양을 계속 쓰므로 매핑을 여기서 끝내면 아래 계층이 무변경이다.
* `status`는 봉투에 없다 — 전송 계층이 실제 HTTP status를 따로 들고 있으므로
* 0으로 두고 `toStudioGatewayError`가 outcome의 status로 덮는다.
*
* (Task 3 fix round 1) `ProblemDetails`는 `contract.ts`에서 가져온다 — 이
* transform이 실제로 만드는 모양과 `contract.ts`가 선언하는 모양이 서로 다른
* 파일에서 독립적으로 정의되면(원래 상태) 둘이 갈라져도 아무 게이트도 못
* 잡는다. `details`는 wire 그대로 통째로 옮긴다 — `fieldErrors`/
* `latestDocument`/`conflictingFields`/`latestPublication`으로 분해하지
* 않는다. 지금 그 필드들을 평평하게 읽는 소비자가 없고, 분해는 실제 소비자가
* 생겼을 때 추가할 투기적 작업이다.
*/
export const envelopeError = (): RuntimeValidator<ProblemDetails> =>
zodValidator<ProblemDetails>(
"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?: ProblemDetails["details"] }).details ?? null,
})) as unknown as z.ZodType<ProblemDetails>,
);
const PROBLEM = envelopeError();
/** 4xx 도메인 거절은 적용되지 않았음이 확정이다. 5xx/네트워크는 불확정이다. */ /** 4xx 도메인 거절은 적용되지 않았음이 확정이다. 5xx/네트워크는 불확정이다. */
const COMMAND_EFFECT: CommandEffectDescriptor<z.output<typeof problemSchema>> = const COMMAND_EFFECT: CommandEffectDescriptor<ProblemDetails> =
Object.freeze({ Object.freeze({
successEffect: "APPLIED_CONFIRMED" as const, successEffect: "APPLIED_CONFIRMED" as const,
classifyProblem({ status }: Readonly<{ status: number; problem: unknown }>) { classifyProblem({ status }: Readonly<{ status: number; problem: unknown }>) {
@@ -93,7 +143,7 @@ function safeOperation(
method: "GET" as const, method: "GET" as const,
pathTemplate, pathTemplate,
inputValidator: passthrough(`${operationId}Input`), inputValidator: passthrough(`${operationId}Input`),
outputValidator: passthrough(`${operationId}Output`), outputValidator: envelopeData(`${operationId}Output`),
problemValidator: PROBLEM, problemValidator: PROBLEM,
acceptedStatuses: Object.freeze([200]), acceptedStatuses: Object.freeze([200]),
emptyBodyStatuses: Object.freeze([]), emptyBodyStatuses: Object.freeze([]),
@@ -141,7 +191,7 @@ function keyedOperation(
method, method,
pathTemplate, pathTemplate,
inputValidator: passthrough(`${operationId}Input`), inputValidator: passthrough(`${operationId}Input`),
outputValidator: passthrough(`${operationId}Output`), outputValidator: envelopeData(`${operationId}Output`),
problemValidator: PROBLEM, problemValidator: PROBLEM,
acceptedStatuses: Object.freeze([options.acceptedStatus]), acceptedStatuses: Object.freeze([options.acceptedStatus]),
emptyBodyStatuses: Object.freeze(options.acceptedStatus === 204 ? [204] : []), emptyBodyStatuses: Object.freeze(options.acceptedStatus === 204 ? [204] : []),
@@ -20,6 +20,14 @@ const transport = () =>
const svg = () => new File(["<svg/>"], "b.svg", { type: "image/svg+xml" }); const svg = () => new File(["<svg/>"], "b.svg", { type: "image/svg+xml" });
// wire format은 봉투다 (ADR-006) — `uploadStudioAsset`도 다른 operation과
// 같은 `AssetEnvelope`/`ErrorEnvelope`를 쓴다.
const META = { requestId: "r", traceId: "t", correlationId: null, page: null };
const dataEnvelope = (data: unknown) => ({ success: true, data, meta: META });
const errorEnvelope = (
error: Readonly<{ code: string; category: string; message: string; retryable: boolean }>,
) => ({ success: false, error, meta: META });
test("posts multipart form data with the supplied headers", async () => { test("posts multipart form data with the supplied headers", async () => {
let seen: { kind: unknown; alt: unknown; csrf: string | null; key: string | null } | null = null; let seen: { kind: unknown; alt: unknown; csrf: string | null; key: string | null } | null = null;
@@ -32,7 +40,7 @@ test("posts multipart form data with the supplied headers", async () => {
csrf: request.headers.get("X-CSRF-TOKEN"), csrf: request.headers.get("X-CSRF-TOKEN"),
key: request.headers.get("Idempotency-Key"), key: request.headers.get("Idempotency-Key"),
}; };
return HttpResponse.json({ id: "a", managementStatus: "READY" }, { status: 201 }); return HttpResponse.json(dataEnvelope({ id: "a", managementStatus: "READY" }), { status: 201 });
}), }),
); );
@@ -53,7 +61,7 @@ test("does not set content-type itself so the boundary survives", async () => {
server.use( server.use(
http.post(`${BASE}/api/v1/studio/assets`, ({ request }) => { http.post(`${BASE}/api/v1/studio/assets`, ({ request }) => {
contentType = request.headers.get("content-type"); contentType = request.headers.get("content-type");
return HttpResponse.json({ id: "a" }, { status: 201 }); return HttpResponse.json(dataEnvelope({ id: "a" }), { status: 201 });
}), }),
); );
@@ -66,14 +74,13 @@ test("maps 413 onto PAYLOAD_TOO_LARGE", async () => {
server.use( server.use(
http.post(`${BASE}/api/v1/studio/assets`, () => http.post(`${BASE}/api/v1/studio/assets`, () =>
HttpResponse.json( HttpResponse.json(
{ errorEnvelope({
type: "https://techlog.local/problems/payload-too-large",
title: "PAYLOAD_TOO_LARGE",
status: 413,
detail: "파일이 너무 큽니다.",
code: "PAYLOAD_TOO_LARGE", code: "PAYLOAD_TOO_LARGE",
}, category: "VALIDATION",
{ status: 413, headers: { "content-type": "application/problem+json" } }, message: "파일이 너무 큽니다.",
retryable: false,
}),
{ status: 413 },
), ),
), ),
); );
@@ -92,14 +99,13 @@ test("maps 415 onto UNSUPPORTED_MEDIA_TYPE", async () => {
server.use( server.use(
http.post(`${BASE}/api/v1/studio/assets`, () => http.post(`${BASE}/api/v1/studio/assets`, () =>
HttpResponse.json( HttpResponse.json(
{ errorEnvelope({
type: "https://techlog.local/problems/unsupported-media-type",
title: "UNSUPPORTED_MEDIA_TYPE",
status: 415,
detail: "지원하지 않는 형식입니다.",
code: "UNSUPPORTED_MEDIA_TYPE", code: "UNSUPPORTED_MEDIA_TYPE",
}, category: "VALIDATION",
{ status: 415, headers: { "content-type": "application/problem+json" } }, message: "지원하지 않는 형식입니다.",
retryable: false,
}),
{ status: 415 },
), ),
), ),
); );
@@ -128,14 +134,15 @@ test("falls back to STUDIO_UNAVAILABLE for an uncontracted problem code instead
server.use( server.use(
http.post(`${BASE}/api/v1/studio/assets`, () => http.post(`${BASE}/api/v1/studio/assets`, () =>
HttpResponse.json( HttpResponse.json(
{ // 봉투 뼈대는 정상이지만 `code`가 계약 밖이다 — envelope 검증은
type: "https://techlog.local/problems/teapot", // 통과하되(구조는 맞음) `apiErrorSchema`의 code enum에서 걸린다.
title: "IM_A_TEAPOT", errorEnvelope({
status: 418,
detail: "이 서버는 커피를 내릴 수 없습니다.",
code: "IM_A_TEAPOT", code: "IM_A_TEAPOT",
}, category: "INTERNAL",
{ status: 418, headers: { "content-type": "application/problem+json" } }, message: "이 서버는 커피를 내릴 수 없습니다.",
retryable: false,
}),
{ status: 418 },
), ),
), ),
); );
@@ -187,7 +194,7 @@ test("maps an aborted upload onto a non-retryable STUDIO_UNAVAILABLE", async ()
server.use( server.use(
http.post(`${BASE}/api/v1/studio/assets`, async () => { http.post(`${BASE}/api/v1/studio/assets`, async () => {
await new Promise((resolve) => setTimeout(resolve, 50)); await new Promise((resolve) => setTimeout(resolve, 50));
return HttpResponse.json({ id: "a" }, { status: 201 }); return HttpResponse.json(dataEnvelope({ id: "a" }), { status: 201 });
}), }),
); );
@@ -18,7 +18,7 @@ test("vendored contract matches the recorded canonical digest", () => {
test("canonical source records the pinned revision and version", () => { test("canonical source records the pinned revision and version", () => {
assert.equal(canonicalSource.packageId, "@tech-log/studio-contract"); assert.equal(canonicalSource.packageId, "@tech-log/studio-contract");
assert.equal(canonicalSource.version, "2.0.0"); assert.equal(canonicalSource.version, "3.0.0");
// revision은 생성 시점에 기록된다. canonical 저장소는 활발히 편집 중이므로 // revision은 생성 시점에 기록된다. canonical 저장소는 활발히 편집 중이므로
// 특정 값을 박아두면 계약이 그대로인데도 테스트가 깨진다. 형식만 고정한다. // 특정 값을 박아두면 계약이 그대로인데도 테스트가 깨진다. 형식만 고정한다.
assert.match(canonicalSource.sourceRevision, /^[0-9a-f]{7,64}$/); assert.match(canonicalSource.sourceRevision, /^[0-9a-f]{7,64}$/);
@@ -3,6 +3,8 @@ import { test } from "vitest";
import { isStudioGatewayError } from "../../../src/features/tech-log/application/ports/studio-gateway-error.ts"; import { isStudioGatewayError } from "../../../src/features/tech-log/application/ports/studio-gateway-error.ts";
import type { import type {
ValidationErrorDetails,
VersionConflictDetails,
WorkingCopy, WorkingCopy,
WorkingCopyInput, WorkingCopyInput,
} from "../../../src/features/tech-log/contracts/studio/contract.ts"; } 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.status, status);
assert.equal(error.code, code); assert.equal(error.code, code);
if (paths) { if (paths) {
// wire와 같은 자리: `REQUEST_VALIDATION_FAILED`의 detail은
// `ValidationErrorDetails`로 `details`에 있다 (Task 3 fix round 1).
const details = error.problem.details as ValidationErrorDetails;
assert.deepEqual( assert.deepEqual(
error.problem.fieldErrors?.map(({ path }) => path), details.fieldErrors.map(({ path }) => path),
paths, paths,
); );
} }
@@ -203,9 +208,8 @@ test("runtime OpenAPI validation enforces discriminators, extras, dates, UUIDs a
}), }),
(error) => { (error) => {
assert.ok(isStudioGatewayError(error)); assert.ok(isStudioGatewayError(error));
assert.ok( const details = error.problem.details as ValidationErrorDetails;
error.problem.fieldErrors?.some(({ path }) => path === pointer), assert.ok(details.fieldErrors.some(({ path }) => path === pointer));
);
return true; return true;
}, },
); );
@@ -575,12 +579,10 @@ test("publication history, immutable snapshots, conflict fixtures and 404 bodies
(error) => { (error) => {
assert.ok(isStudioGatewayError(error)); assert.ok(isStudioGatewayError(error));
assert.equal(error.code, "VERSION_CONFLICT"); assert.equal(error.code, "VERSION_CONFLICT");
assert.deepEqual(error.problem.conflictingFields, [ const details = error.problem.details as VersionConflictDetails;
"/title", assert.deepEqual(details.conflictingFields, ["/title", "/summary"]);
"/summary",
]);
assert.equal( assert.equal(
error.problem.latestDocument?.document.version, details.latestDocument.document.version,
conflict.document.version + 1, conflict.document.version + 1,
); );
return true; return true;
@@ -43,6 +43,16 @@ beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => server.resetHandlers()); afterEach(() => server.resetHandlers());
afterAll(() => server.close()); afterAll(() => server.close());
// wire format은 봉투다 (ADR-006) — 이 파일은 실제 platform 계약 런타임
// (`createContractHttpExecutor`)을 조립하므로 `outputValidator`
// (`envelopeData`)가 그대로 걸린다. 봉투가 아닌 본문은 SUCCESS_SCHEMA_INVALID로
// 거절된다.
const dataEnvelope = (data: unknown) => ({
success: true,
data,
meta: { requestId: "r", traceId: "t", correlationId: null, page: null },
});
function scopeSnapshot() { function scopeSnapshot() {
return Object.freeze({ return Object.freeze({
generation: 1, generation: 1,
@@ -152,13 +162,15 @@ test(
server.use( server.use(
http.get(`${BASE}/api/v1/studio/session`, () => { http.get(`${BASE}/api/v1/studio/session`, () => {
sessionCalls += 1; sessionCalls += 1;
return HttpResponse.json({ return HttpResponse.json(
authenticated: true, dataEnvelope({
displayName: "테스터", authenticated: true,
roles: ["editor"], displayName: "테스터",
csrfToken: "csrf-token-1", roles: ["editor"],
csrfHeaderName: "X-CSRF-TOKEN", csrfToken: "csrf-token-1",
}); csrfHeaderName: "X-CSRF-TOKEN",
}),
);
}), }),
); );
@@ -166,10 +178,7 @@ test(
server.use( server.use(
http.get(`${BASE}/api/v1/studio/dashboard`, ({ request }) => { http.get(`${BASE}/api/v1/studio/dashboard`, ({ request }) => {
jsonRequestHeader = request.headers.get("x-csrf-token"); jsonRequestHeader = request.headers.get("x-csrf-token");
return HttpResponse.json({ return HttpResponse.json(dataEnvelope({ documentTotals: {}, workflowSections: [] }));
documentTotals: {},
workflowSections: [],
});
}), }),
); );
@@ -177,7 +186,10 @@ test(
server.use( server.use(
http.post(`${BASE}/api/v1/studio/assets`, ({ request }) => { http.post(`${BASE}/api/v1/studio/assets`, ({ request }) => {
uploadRequestHeader = request.headers.get("X-CSRF-TOKEN"); uploadRequestHeader = request.headers.get("X-CSRF-TOKEN");
return HttpResponse.json({ id: "a", managementStatus: "READY" }, { status: 201 }); return HttpResponse.json(
dataEnvelope({ id: "a", managementStatus: "READY" }),
{ status: 201 },
);
}), }),
); );
@@ -232,13 +244,15 @@ test(
server.use( server.use(
http.get(`${BASE}/api/v1/studio/session`, () => { http.get(`${BASE}/api/v1/studio/session`, () => {
sessionCalls += 1; sessionCalls += 1;
return HttpResponse.json({ return HttpResponse.json(
authenticated: true, dataEnvelope({
displayName: "테스터", authenticated: true,
roles: ["editor"], displayName: "테스터",
csrfToken: `csrf-token-${sessionCalls}`, roles: ["editor"],
csrfHeaderName: "X-CSRF-TOKEN", csrfToken: `csrf-token-${sessionCalls}`,
}); csrfHeaderName: "X-CSRF-TOKEN",
}),
);
}), }),
); );
@@ -250,7 +264,7 @@ test(
// Second call: succeeds with whatever token is presented. // Second call: succeeds with whatever token is presented.
return dashboardHeaders.length === 1 return dashboardHeaders.length === 1
? new HttpResponse(null, { status: 403 }) ? new HttpResponse(null, { status: 403 })
: HttpResponse.json({ documentTotals: {}, workflowSections: [] }); : HttpResponse.json(dataEnvelope({ documentTotals: {}, workflowSections: [] }));
}), }),
); );
@@ -294,13 +308,15 @@ test(
server.use( server.use(
http.get(`${BASE}/api/v1/studio/session`, () => { http.get(`${BASE}/api/v1/studio/session`, () => {
sessionCalls += 1; sessionCalls += 1;
return HttpResponse.json({ return HttpResponse.json(
authenticated: true, dataEnvelope({
displayName: "테스터", authenticated: true,
roles: ["editor"], displayName: "테스터",
csrfToken: `csrf-token-${sessionCalls}`, roles: ["editor"],
csrfHeaderName: "X-CSRF-TOKEN", csrfToken: `csrf-token-${sessionCalls}`,
}); csrfHeaderName: "X-CSRF-TOKEN",
}),
);
}), }),
); );
@@ -308,7 +324,7 @@ test(
server.use( server.use(
http.get(`${BASE}/api/v1/studio/dashboard`, ({ request }) => { http.get(`${BASE}/api/v1/studio/dashboard`, ({ request }) => {
dashboardHeaders.push(request.headers.get("x-csrf-token")); dashboardHeaders.push(request.headers.get("x-csrf-token"));
return HttpResponse.json({ documentTotals: {}, workflowSections: [] }); return HttpResponse.json(dataEnvelope({ documentTotals: {}, workflowSections: [] }));
}), }),
); );
server.use( server.use(
@@ -0,0 +1,42 @@
import { describe, expect, it } from "vitest";
import { envelopeData, envelopeError } from "../../../src/features/tech-log/contracts/tech-log-studio-contract-contribution.ts";
describe("studio 봉투 언랩", () => {
it("성공 봉투에서 data를 꺼낸다", () => {
const result = envelopeData("getStudioSessionOutput").safeParse({
success: true,
data: { authenticated: true, displayName: "d", roles: [], csrfToken: "t", csrfHeaderName: "X-CSRF-TOKEN" },
meta: { requestId: "r", traceId: "t", correlationId: null, page: null },
});
expect(result.success).toBe(true);
if (result.success) expect(result.data).toMatchObject({ displayName: "d" });
});
it("봉투가 아닌 본문을 거절한다", () => {
const result = envelopeData("getStudioSessionOutput").safeParse({ displayName: "d" });
expect(result.success).toBe(false);
});
it("오류 봉투를 ProblemDetails 형태로 옮긴다", () => {
const result = envelopeError().safeParse({
success: false,
error: { code: "VERSION_CONFLICT", category: "CONFLICT", message: "conflict", retryable: false, details: null },
meta: { requestId: "r", traceId: "tr", correlationId: null, page: null },
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.code).toBe("VERSION_CONFLICT");
expect(result.data.status).toBe(0);
expect(result.data.title).toBe("VERSION_CONFLICT");
}
});
it("계약 밖 코드를 거절한다", () => {
const result = envelopeError().safeParse({
success: false,
error: { code: "NOT_A_STUDIO_CODE", category: "INTERNAL", message: "x", retryable: false, details: null },
meta: { requestId: "r", traceId: "tr", correlationId: null, page: null },
});
expect(result.success).toBe(false);
});
});