import { StudioGatewayError } from "../../application/ports/studio-gateway-error.ts"; import type { UploadAssetForm } from "../../application/ports/studio-asset-gateway.ts"; import type { Asset, ProblemDetails } from "../../contracts/studio/contract.ts"; import { envelopeData, envelopeError } from "../../contracts/tech-log-studio-contract-contribution.ts"; import type { StudioAssetUploadTransport } from "./http-studio-asset-gateway.ts"; import { STUDIO_ERROR_CODES } from "./studio-error-mapping.ts"; const CODES = new Set(STUDIO_ERROR_CODES); /** * canonical `uploadStudioAsset`도 다른 18개 operation과 같은 봉투(ADR-006)를 * 쓴다 — 이 seam만 일반 계약 런타임을 안 거칠 뿐이지 wire format은 같다. * 그래서 `tech-log-studio-contract-contribution.ts`의 언랩 validator를 그대로 * 재사용한다: 봉투 뼈대 검증 로직이 두 곳에서 따로 드리프트하는 것을 막는다. */ const UPLOAD_DATA = envelopeData("uploadStudioAssetOutput"); const UPLOAD_PROBLEM = envelopeError(); /** * The contract runtime can only express `requestBody: "NONE" | "JSON"` and the * low-level client always serializes the body as JSON (see * `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, }); }, }); }