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:
@@ -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(
|
||||||
|
dataEnvelope({
|
||||||
authenticated: true,
|
authenticated: true,
|
||||||
displayName: "테스터",
|
displayName: "테스터",
|
||||||
roles: ["editor"],
|
roles: ["editor"],
|
||||||
csrfToken: "csrf-token-1",
|
csrfToken: "csrf-token-1",
|
||||||
csrfHeaderName: "X-CSRF-TOKEN",
|
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(
|
||||||
|
dataEnvelope({
|
||||||
authenticated: true,
|
authenticated: true,
|
||||||
displayName: "테스터",
|
displayName: "테스터",
|
||||||
roles: ["editor"],
|
roles: ["editor"],
|
||||||
csrfToken: `csrf-token-${sessionCalls}`,
|
csrfToken: `csrf-token-${sessionCalls}`,
|
||||||
csrfHeaderName: "X-CSRF-TOKEN",
|
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(
|
||||||
|
dataEnvelope({
|
||||||
authenticated: true,
|
authenticated: true,
|
||||||
displayName: "테스터",
|
displayName: "테스터",
|
||||||
roles: ["editor"],
|
roles: ["editor"],
|
||||||
csrfToken: `csrf-token-${sessionCalls}`,
|
csrfToken: `csrf-token-${sessionCalls}`,
|
||||||
csrfHeaderName: "X-CSRF-TOKEN",
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user