feat: add the TechLog asset multipart upload transport
Wires the whole Asset capability into the running application: the multipart upload transport (the contract runtime can only express JSON bodies), a single composition-root-owned CSRF provider shared between the platform's credential collaborator (18 JSON operations) and the upload transport (1 multipart operation), and Studio/StudioShell exposure of the Asset gateway alongside the existing document gateway. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
d9c2d8bc5e
commit
c9c832c365
@@ -0,0 +1,85 @@
|
||||
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 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);
|
||||
|
||||
/**
|
||||
* 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): StudioGatewayError {
|
||||
return new StudioGatewayError({
|
||||
type: "https://techlog.local/problems/studio-unavailable",
|
||||
title: "STUDIO_UNAVAILABLE",
|
||||
status: 503,
|
||||
detail,
|
||||
code: "STUDIO_UNAVAILABLE",
|
||||
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) {
|
||||
throw unavailable(
|
||||
error instanceof Error ? `Upload transport failed: ${error.message}` : "Upload transport failed.",
|
||||
);
|
||||
}
|
||||
|
||||
if (response.status === 201 || response.status === 200) {
|
||||
return (await response.json()) as Asset;
|
||||
}
|
||||
|
||||
let problem: ProblemDetails | null;
|
||||
try {
|
||||
problem = (await response.json()) as ProblemDetails;
|
||||
} catch {
|
||||
problem = null;
|
||||
}
|
||||
if (problem && typeof problem.code === "string" && CODES.has(problem.code)) {
|
||||
throw new StudioGatewayError(problem);
|
||||
}
|
||||
throw unavailable(`Upload returned an uncontracted status ${response.status}.`);
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user