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 CatalogPage = Schemas["CatalogPage"];
|
||||||
export type ProblemDetails = Schemas["ProblemDetails"];
|
export type ProblemDetails = Schemas["ProblemDetails"];
|
||||||
export type PublicRenderModel = Schemas["PublicRenderModel"];
|
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"];
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { test } from "vitest";
|
||||||
|
|
||||||
|
import { createHttpStudioAssetGateway } from "../../../src/features/tech-log/adapters/http/http-studio-asset-gateway.ts";
|
||||||
|
import { createCsrfTokenProvider } from "../../../src/features/tech-log/adapters/http/studio-session-csrf.ts";
|
||||||
|
import { isStudioGatewayError } from "../../../src/features/tech-log/application/ports/studio-gateway-error.ts";
|
||||||
|
|
||||||
|
const READY_ASSET = {
|
||||||
|
id: "11111111-1111-4111-8111-111111111111",
|
||||||
|
assetKey: "fetch-strategy-boundary",
|
||||||
|
kind: "DIAGRAM",
|
||||||
|
mediaType: "image/svg+xml",
|
||||||
|
originalFilename: "boundary.svg",
|
||||||
|
byteSize: 4096,
|
||||||
|
width: 1080,
|
||||||
|
height: 420,
|
||||||
|
altText: "Fetch Join과 Batch Fetch 비교",
|
||||||
|
decorative: false,
|
||||||
|
managementStatus: "READY",
|
||||||
|
publicPath: "/media/fetch-strategy-boundary.svg",
|
||||||
|
usageCount: 1,
|
||||||
|
version: 1,
|
||||||
|
createdAt: "2026-08-14T01:00:00.000Z",
|
||||||
|
updatedAt: "2026-08-14T01:00:00.000Z",
|
||||||
|
};
|
||||||
|
|
||||||
|
function deps(outcomes: Record<string, unknown>) {
|
||||||
|
const calls: { operationId: string; input: unknown }[] = [];
|
||||||
|
return {
|
||||||
|
calls,
|
||||||
|
dependencies: {
|
||||||
|
operations: {
|
||||||
|
async execute(operationId: string, input: unknown) {
|
||||||
|
calls.push({ operationId, input });
|
||||||
|
const outcome = outcomes[operationId];
|
||||||
|
if (!outcome) throw new Error(`no outcome for ${operationId}`);
|
||||||
|
return outcome as never;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
csrf: createCsrfTokenProvider({
|
||||||
|
async execute() {
|
||||||
|
return { csrfToken: "csrf", csrfHeaderName: "X-CSRF-TOKEN" };
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
upload: {
|
||||||
|
async upload() {
|
||||||
|
return READY_ASSET as never;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test("lists assets through the canonical operation", async () => {
|
||||||
|
const { calls, dependencies } = deps({
|
||||||
|
listStudioAssets: {
|
||||||
|
kind: "SUCCESS",
|
||||||
|
value: { items: [READY_ASSET], nextCursor: null },
|
||||||
|
effect: "APPLIED_CONFIRMED",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const gateway = createHttpStudioAssetGateway(dependencies as never);
|
||||||
|
|
||||||
|
const page = await gateway.listAssets({ kind: "DIAGRAM", limit: 20 });
|
||||||
|
|
||||||
|
assert.equal(page.items.length, 1);
|
||||||
|
assert.equal(calls[0]!.operationId, "listStudioAssets");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("delegates upload to the transport with CSRF and idempotency headers", async () => {
|
||||||
|
let received: Record<string, string> = {};
|
||||||
|
const { dependencies } = deps({});
|
||||||
|
const gateway = createHttpStudioAssetGateway({
|
||||||
|
...dependencies,
|
||||||
|
upload: {
|
||||||
|
async upload(_form: unknown, headers: Record<string, string>) {
|
||||||
|
received = headers;
|
||||||
|
return READY_ASSET as never;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
const asset = await gateway.uploadAsset(
|
||||||
|
{ file: new File(["<svg/>"], "boundary.svg", { type: "image/svg+xml" }), kind: "DIAGRAM" },
|
||||||
|
{ idempotencyKey: "upload-1" },
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(asset.managementStatus, "READY");
|
||||||
|
assert.equal(received["X-CSRF-TOKEN"], "csrf");
|
||||||
|
assert.equal(received["Idempotency-Key"], "upload-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("surfaces ASSET_IN_USE from a rejected delete", async () => {
|
||||||
|
const { dependencies } = deps({
|
||||||
|
deleteStudioAsset: {
|
||||||
|
kind: "PROBLEM",
|
||||||
|
problem: {
|
||||||
|
type: "https://techlog.local/problems/asset-in-use",
|
||||||
|
title: "ASSET_IN_USE",
|
||||||
|
status: 409,
|
||||||
|
detail: "사용 중인 Asset은 삭제할 수 없습니다.",
|
||||||
|
code: "ASSET_IN_USE",
|
||||||
|
},
|
||||||
|
metadata: { status: 409 },
|
||||||
|
effect: "NOT_APPLIED",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const gateway = createHttpStudioAssetGateway(dependencies as never);
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
gateway.deleteAsset(READY_ASSET.id, { idempotencyKey: "delete-1" }),
|
||||||
|
(error: unknown) => {
|
||||||
|
assert.ok(isStudioGatewayError(error));
|
||||||
|
assert.equal(error.code, "ASSET_IN_USE");
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sends expectedVersion when updating metadata", async () => {
|
||||||
|
const { calls, dependencies } = deps({
|
||||||
|
updateStudioAsset: {
|
||||||
|
kind: "SUCCESS",
|
||||||
|
value: { ...READY_ASSET, version: 2, decorative: true, altText: null },
|
||||||
|
effect: "APPLIED_CONFIRMED",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const gateway = createHttpStudioAssetGateway(dependencies as never);
|
||||||
|
|
||||||
|
const updated = await gateway.updateAssetMetadata(
|
||||||
|
READY_ASSET.id,
|
||||||
|
{ expectedVersion: 1, decorative: true, altText: null },
|
||||||
|
{ idempotencyKey: "update-1" },
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(updated.version, 2);
|
||||||
|
const input = calls[0]!.input as Record<string, unknown>;
|
||||||
|
assert.equal(input["expectedVersion"], 1);
|
||||||
|
assert.equal(input["assetId"], READY_ASSET.id);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user