feat: add the TechLog Studio asset gateway port and JSON adapter

This commit is contained in:
DongHyeonka
2026-08-18 02:16:51 +09:00
parent 7724a8720c
commit d9c2d8bc5e
4 changed files with 270 additions and 0 deletions
@@ -0,0 +1,81 @@
import type {
Asset,
AssetDetail,
AssetPage,
UpdateAssetCommand,
} from "../../contracts/studio/contract.ts";
import type {
ListAssetsQuery,
StudioAssetGateway,
UploadAssetForm,
} from "../../application/ports/studio-asset-gateway.ts";
import type { IdempotentOptions, RequestOptions } from "../../application/ports/studio-gateway.ts";
import { toStudioGatewayError } from "./studio-error-mapping.ts";
import { mutationIntent, type StudioOperationExecutor } from "./http-studio-gateway.ts";
import type { CsrfTokenProvider } from "./studio-session-csrf.ts";
export type StudioAssetUploadTransport = Readonly<{
upload(
form: UploadAssetForm,
headers: Readonly<Record<string, string>>,
options?: Readonly<{ signal?: AbortSignal }>,
): Promise<Asset>;
}>;
export type HttpStudioAssetGatewayDependencies = Readonly<{
operations: StudioOperationExecutor;
csrf: CsrfTokenProvider;
upload: StudioAssetUploadTransport;
}>;
const ROUTE_ID = "TECH_LOG_STUDIO_ASSETS";
export function createHttpStudioAssetGateway(
deps: HttpStudioAssetGatewayDependencies,
): StudioAssetGateway {
async function read<T>(operationId: string, input: unknown, options?: RequestOptions) {
const outcome = await deps.operations.execute(operationId, input, {
routeId: ROUTE_ID,
...(options?.signal ? { signal: options.signal } : {}),
});
if (outcome.kind !== "SUCCESS") throw toStudioGatewayError(outcome, operationId);
return outcome.value as T;
}
async function command<T>(operationId: string, input: unknown, options: IdempotentOptions) {
// Task 4와 동일한 규칙: 헤더는 gateway가 만들지 않는다. `Idempotency-Key`는
// intent에서, `x-csrf-token`은 credential collaborator에서 온다.
const outcome = await deps.operations.execute(operationId, input, {
routeId: ROUTE_ID,
intent: mutationIntent(operationId, options.idempotencyKey, input),
...(options.signal ? { signal: options.signal } : {}),
});
if (outcome.kind === "SUCCESS") return outcome.value as T;
throw toStudioGatewayError(outcome, operationId);
}
const gateway: StudioAssetGateway = {
listAssets: (query, options) => read<AssetPage>("listStudioAssets", query, options),
getAsset: (assetId, options) => read<AssetDetail>("getStudioAsset", { assetId }, options),
async uploadAsset(form, options) {
const [token, headerName] = await Promise.all([
deps.csrf.token(options.signal ? { signal: options.signal } : undefined),
deps.csrf.headerName(options.signal ? { signal: options.signal } : undefined),
]);
return deps.upload.upload(
form,
Object.freeze({
[headerName]: token,
"Idempotency-Key": options.idempotencyKey,
}),
options.signal ? { signal: options.signal } : undefined,
);
},
updateAssetMetadata: (assetId, cmd: UpdateAssetCommand, options) =>
command<Asset>("updateStudioAsset", { assetId, ...cmd }, options),
async deleteAsset(assetId, options) {
await command<null>("deleteStudioAsset", { assetId }, options);
},
};
return Object.freeze(gateway);
}
@@ -0,0 +1,41 @@
import type {
Asset,
AssetDetail,
AssetKind,
AssetManagementStatus,
AssetPage,
UpdateAssetCommand,
} from "../../contracts/studio/contract.ts";
import type { IdempotentOptions, RequestOptions } from "./studio-gateway.ts";
export type ListAssetsQuery = Readonly<{
q?: string;
kind?: AssetKind;
managementStatus?: AssetManagementStatus;
cursor?: string;
limit?: number;
}>;
export type UploadAssetForm = Readonly<{
file: File;
kind: AssetKind;
altText?: string;
decorative?: boolean;
}>;
/**
* Asset은 `StudioGateway`와 별도 포트다. 파일 전송과 JSON orchestration은
* 실패 모델이 다르고, 업로드 구현을 presigned/resumable로 바꿀 때 교체 범위가
* 이 포트 뒤에서 끝나야 한다.
*/
export interface StudioAssetGateway {
listAssets(query: ListAssetsQuery, options?: RequestOptions): Promise<AssetPage>;
uploadAsset(form: UploadAssetForm, options: IdempotentOptions): Promise<Asset>;
getAsset(assetId: string, options?: RequestOptions): Promise<AssetDetail>;
updateAssetMetadata(
assetId: string,
command: UpdateAssetCommand,
options: IdempotentOptions,
): Promise<Asset>;
deleteAsset(assetId: string, options: IdempotentOptions): Promise<void>;
}
@@ -25,3 +25,11 @@ export type PublicationSnapshot = Schemas["PublicationSnapshot"];
export type CatalogPage = Schemas["CatalogPage"];
export type ProblemDetails = Schemas["ProblemDetails"];
export type PublicRenderModel = Schemas["PublicRenderModel"];
export type Asset = Schemas["Asset"];
export type AssetDetail = Schemas["AssetDetail"];
export type AssetPage = Schemas["AssetPage"];
export type AssetUsage = Schemas["AssetUsage"];
export type AssetKind = Schemas["AssetKind"];
export type AssetManagementStatus = Schemas["AssetManagementStatus"];
export type UpdateAssetCommand = Schemas["UpdateAssetCommand"];
export type StudioSession = Schemas["StudioSession"];