feat: add the TechLog Studio asset gateway port and JSON adapter
This commit is contained in:
@@ -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"];
|
||||
|
||||
Reference in New Issue
Block a user