studio-v1.yaml v3.0.0(ADR-006)에 맞춰 계약을 재생성하고, 성공은
{success,data,meta}, 실패는 {success,error,meta} 봉투를 전송 경계에서
언랩하는 envelopeData/envelopeError validator를 도입한다. 앱·도메인
계층은 기존과 같은 payload/ProblemDetails 모양을 계속 받고,
StudioGateway 포트 시그니처는 무변경이다.
- tech-log-studio-contract-contribution.ts: envelopeData/envelopeError
도입, 18개 operation의 outputValidator를 passthrough에서 envelopeData로
교체
- studio-error-mapping.ts: 봉투 오류의 status(항상 0)를
outcome.metadata.status로 덮는다. SafeResponseMetadata.status가
실제 필드명이며(httpStatus 아님) PROBLEM outcome에서 필수 필드다
- contract.ts: 삭제된 ProblemDetails 생성 스키마를 손으로 유지 — 앱
계층·mock 게이트웨이가 그 모양을 계속 소비한다
- asset-upload-transport.ts: multipart 업로드는 일반 계약 런타임을
거치지 않는 별도 seam이지만 같은 wire 봉투를 쓴다 — envelopeData/
envelopeError를 재사용해 이 경로도 언랩한다 (브리프 파일 목록 밖의
발견, report에 기록)
- 테스트: 신규 studio-envelope-unwrap.test.ts(TDD) + 봉투 뼈대를 직접
만드는 기존 테스트(asset-upload-transport, studio-csrf-composition,
contract-generation)를 봉투 형태로 갱신
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
143 lines
6.1 KiB
TypeScript
143 lines
6.1 KiB
TypeScript
import { StudioGatewayError } from "../../application/ports/studio-gateway-error.ts";
|
|
import type { UploadAssetForm } from "../../application/ports/studio-asset-gateway.ts";
|
|
import type { Asset, ProblemDetails } from "../../contracts/studio/contract.ts";
|
|
import { envelopeData, envelopeError } from "../../contracts/tech-log-studio-contract-contribution.ts";
|
|
import type { StudioAssetUploadTransport } from "./http-studio-asset-gateway.ts";
|
|
import { STUDIO_ERROR_CODES } from "./studio-error-mapping.ts";
|
|
|
|
const CODES = new Set<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
|
|
* low-level client always serializes the body as JSON (see
|
|
* `external-contract-runtime.ts` and `client.ts:719`). The canonical
|
|
* `POST /assets` is multipart, so this one operation is split into a narrow
|
|
* seam instead. If upload moves to presigned/resumable transfer, or the
|
|
* platform grows a MULTIPART mode, only this file is replaced.
|
|
*/
|
|
export function createAssetUploadTransport(
|
|
deps: Readonly<{
|
|
baseUrl: string;
|
|
timeoutMs: number;
|
|
fetch?: typeof globalThis.fetch;
|
|
}>,
|
|
): StudioAssetUploadTransport {
|
|
const doFetch = deps.fetch ?? globalThis.fetch.bind(globalThis);
|
|
const endpoint = new URL("api/v1/studio/assets", deps.baseUrl).href;
|
|
|
|
function unavailable(
|
|
detail: string,
|
|
options?: Readonly<{ status?: number; retryable?: boolean }>,
|
|
): StudioGatewayError {
|
|
return new StudioGatewayError({
|
|
type: "https://techlog.local/problems/studio-unavailable",
|
|
title: "STUDIO_UNAVAILABLE",
|
|
status: options?.status ?? 503,
|
|
detail,
|
|
code: "STUDIO_UNAVAILABLE",
|
|
retryable: options?.retryable ?? true,
|
|
});
|
|
}
|
|
|
|
return Object.freeze({
|
|
async upload(form: UploadAssetForm, headers, options) {
|
|
const body = new FormData();
|
|
body.append("file", form.file, form.file.name);
|
|
body.append("kind", form.kind);
|
|
if (form.altText !== undefined) body.append("altText", form.altText);
|
|
if (form.decorative !== undefined) body.append("decorative", String(form.decorative));
|
|
|
|
const timeout = AbortSignal.timeout(deps.timeoutMs);
|
|
const signal = options?.signal
|
|
? AbortSignal.any([options.signal, timeout])
|
|
: timeout;
|
|
|
|
let response: Response;
|
|
try {
|
|
// content-type is never set by hand here. fetch generates the
|
|
// multipart boundary; setting it manually produces a body the server
|
|
// cannot parse.
|
|
response = await doFetch(endpoint, {
|
|
method: "POST",
|
|
headers: { ...headers },
|
|
body,
|
|
signal,
|
|
credentials: "include",
|
|
});
|
|
} catch (error) {
|
|
if (signal.aborted) {
|
|
// M3 (fix round 1). Align with the JSON path's `CANCELLED` mapping
|
|
// (`toStudioGatewayError` maps it to `STUDIO_UNAVAILABLE` / 499 /
|
|
// `retryable: false`) — a caller-cancelled or deadline-exceeded
|
|
// upload is not a retryable outage, so it must not carry
|
|
// `retryable: true` the way a genuine transport failure does.
|
|
throw unavailable("Upload was aborted before it completed.", {
|
|
status: 499,
|
|
retryable: false,
|
|
});
|
|
}
|
|
throw unavailable(
|
|
error instanceof Error ? `Upload transport failed: ${error.message}` : "Upload transport failed.",
|
|
);
|
|
}
|
|
|
|
if (response.status === 201 || response.status === 200) {
|
|
let body: unknown;
|
|
try {
|
|
body = await response.json();
|
|
} catch {
|
|
// M1 (fix round 1). This port's contract is `StudioGatewayError`;
|
|
// a malformed success body must not throw a raw `SyntaxError` out
|
|
// of it.
|
|
throw unavailable(
|
|
`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 problemBody: unknown = null;
|
|
try {
|
|
problemBody = await response.json();
|
|
} catch {
|
|
problemBody = null;
|
|
}
|
|
const parsedProblem = problemBody === null ? null : UPLOAD_PROBLEM.safeParse(problemBody);
|
|
if (parsedProblem && parsedProblem.success && CODES.has(parsedProblem.data.code)) {
|
|
// `studio-error-mapping.ts`의 PROBLEM 분기와 같은 캐스트: 봉투는 이미
|
|
// `code`를 `apiErrorSchema`의 enum으로 검증했으므로(`CODES.has` 확인도
|
|
// 통과) `ProblemDetails["code"]`로 좁혀도 안전하다. envelope에는 HTTP
|
|
// status가 없다 (`envelopeError`가 0으로 둔다) — 이 seam은 실제 status를
|
|
// 이미 들고 있으므로 바로 덮는다.
|
|
const problem = parsedProblem.data as unknown as ProblemDetails;
|
|
throw new StudioGatewayError({ ...problem, status: response.status });
|
|
}
|
|
// Fix round 2, item 2. The real status is passed through (not the
|
|
// hardcoded 503 default) so `http-studio-asset-gateway.ts`'s
|
|
// `error.status === 401 || error.status === 403` check can still act on
|
|
// an uncontracted 401/403 body and invalidate the cached CSRF token.
|
|
throw unavailable(`Upload returned an uncontracted status ${response.status}.`, {
|
|
status: response.status,
|
|
});
|
|
},
|
|
});
|
|
}
|