From 1d01a522de71bc224978746f82c626688fa0fb98 Mon Sep 17 00:00:00 2001 From: DongHyeonka Date: Tue, 18 Aug 2026 01:30:24 +0900 Subject: [PATCH] feat: implement the TechLog Studio HTTP gateway Adds createHttpStudioGateway, the adapter the Studio UI calls in production. It drives every mutation through mutationIntent() (defineMutationIntent/defineIdempotencyKey, not createBrowserMutationIntentFactory, so the caller-supplied idempotency key is preserved rather than regenerated) and leaves header construction to the executor/credential collaborator entirely - the gateway never sees or sets Idempotency-Key or x-csrf-token itself. Also moves stable-stringify.ts out of adapters/mock/ so the mock and http adapters share one pure function without production code depending on the mock directory, and fixes the resulting import in cursor.ts, mock-studio-gateway.ts, and mock-studio-gateway.test.ts. Adds MSW handlers (tests/mocks/handlers/tech-log-studio.ts) that wrap the reference mock-studio-gateway implementation, and a contract test suite that drives the gateway through a thin fetch-based executor built from the contract's own projectRequest, proving canonical path/body/header construction without assembling the full platform transport. Co-Authored-By: Claude Opus 5 (1M context) --- .../adapters/http/http-studio-gateway.ts | 139 ++++++++++++ src/features/tech-log/adapters/mock/cursor.ts | 2 +- .../adapters/mock/mock-studio-gateway.ts | 2 +- .../adapters/{mock => }/stable-stringify.ts | 0 .../tech-log/http-studio-gateway.test.ts | 199 ++++++++++++++++++ .../tech-log/mock-studio-gateway.test.ts | 2 +- tests/mocks/handlers/tech-log-studio.ts | 147 +++++++++++++ 7 files changed, 488 insertions(+), 3 deletions(-) create mode 100644 src/features/tech-log/adapters/http/http-studio-gateway.ts rename src/features/tech-log/adapters/{mock => }/stable-stringify.ts (100%) create mode 100644 tests/features/tech-log/http-studio-gateway.test.ts create mode 100644 tests/mocks/handlers/tech-log-studio.ts diff --git a/src/features/tech-log/adapters/http/http-studio-gateway.ts b/src/features/tech-log/adapters/http/http-studio-gateway.ts new file mode 100644 index 0000000..0d0e5dd --- /dev/null +++ b/src/features/tech-log/adapters/http/http-studio-gateway.ts @@ -0,0 +1,139 @@ +import { + defineIdempotencyKey, + defineMutationIntent, + MUTATION_INTENT_BOUNDS, + type MutationIntent, +} from "../../../../contracts/mutation-intent.ts"; +import type { HttpExecutionOutcome } from "../../../../adapters/http/http-execution-v3.ts"; +import { stableStringify } from "../stable-stringify.ts"; +import type { + IdempotentOptions, + RequestOptions, + StudioGateway, +} from "../../application/ports/studio-gateway.ts"; +import { toStudioGatewayError } from "./studio-error-mapping.ts"; + +export type StudioOperationExecutor = Readonly<{ + execute( + operationId: string, + input: unknown, + context: Readonly<{ + routeId: string; + signal?: AbortSignal; + intent?: MutationIntent; + }>, + ): Promise>; +}>; + +export type HttpStudioGatewayDependencies = Readonly<{ + operations: StudioOperationExecutor; +}>; + +const ROUTE_ID = "TECH_LOG_STUDIO"; + +const IDENTITY_ENCODER = new TextEncoder(); +const IDENTITY_DECODER = new TextDecoder(); + +/** + * `createBrowserMutationIntentFactory`는 쓰지 않는다. 그 factory는 + * `requiresIdempotencyKey`일 때 키를 **스스로 생성**하는데, 이 포트의 키는 + * 호출자가 만들어 안전한 재시도에 재사용하는 값이다. `defineMutationIntent`가 + * 호출자 공급 키를 검증하며 받아주는 정식 경로다(`mutation-intent.ts`의 + * "A caller-supplied value is never trimmed, regenerated or silently dropped"). + */ +export function mutationIntent( + operationId: string, + idempotencyKey: string, + input: unknown, +): MutationIntent { + return defineMutationIntent({ + intentId: globalThis.crypto.randomUUID(), + operationId, + canonicalInputIdentity: canonicalIdentity(input), + idempotencyKey: defineIdempotencyKey(idempotencyKey), + createdAtMonotonicMs: globalThis.performance.now(), + }); +} + +/** + * 같은 명령의 재시도가 같은 신원을 갖도록 키 순서를 고정해 직렬화한다. + * `canonicalInputIdentityMaxBytes`는 바이트 한도다 — 문자 수로 자르면 한글 + * 위주의 본문에서 문자 수 기준 한도보다 훨씬 먼저 바이트 한도를 넘는다. + * 원본이 유효한 UTF-8이므로 바이트 경계에서 잘라낸 뒤 끝에 남는 불완전한 + * 시퀀스만 제거하면(디코더가 U+FFFD로 바꾼 꼬리만 잘라내면) 항상 한도 + * 이하가 되고, 같은 입력은 항상 같은 결과를 낸다. + */ +function canonicalIdentity(input: unknown): string { + const identity = stableStringify(input); + const bytes = IDENTITY_ENCODER.encode(identity); + if (bytes.byteLength <= MUTATION_INTENT_BOUNDS.canonicalInputIdentityMaxBytes) { + return identity; + } + const truncated = bytes.subarray( + 0, + MUTATION_INTENT_BOUNDS.canonicalInputIdentityMaxBytes, + ); + return IDENTITY_DECODER.decode(truncated).replace(/�+$/u, ""); +} + +/** + * 헤더는 gateway가 만들지 않는다. `Idempotency-Key`는 실행 context의 intent에서, + * `x-csrf-token`은 credential collaborator에서 온다. gateway가 입력 본문에 + * 넣으면 계약 본문이 오염되고 계약 소유 헤더는 거절된다. + */ +export function createHttpStudioGateway( + deps: HttpStudioGatewayDependencies, +): StudioGateway { + async function read( + operationId: string, + input: unknown, + options?: RequestOptions, + ): Promise { + 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( + operationId: string, + input: unknown, + options: IdempotentOptions, + ): Promise { + 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: StudioGateway = { + getDashboard: (options) => read("getStudioDashboard", {}, options), + listDocuments: (query, options) => read("listStudioDocuments", query, options), + createDocument: (input, options) => + command("createStudioDocument", { document: input }, options), + getDocument: (documentId, options) => + read("getStudioDocument", { documentId }, options), + saveDocument: (documentId, cmd, options) => + command("saveStudioDocument", { documentId, ...cmd }, options), + validateDocument: (documentId, cmd, options) => + command("validateStudioDocument", { documentId, ...cmd }, options), + createPreview: (documentId, cmd, options) => + command("createStudioPreview", { documentId, ...cmd }, options), + getCurrentPreview: (documentId, options) => + read("getCurrentStudioPreview", { documentId }, options), + publishDocument: (documentId, cmd, options) => + command("publishStudioDocument", { documentId, ...cmd }, options), + unpublishPublication: (publicationId, cmd, options) => + command("unpublishStudioPublication", { publicationId, ...cmd }, options), + listPublications: (query, options) => read("listStudioPublications", query, options), + getPublicationSnapshot: (publicationEventId, options) => + read("getStudioPublicationSnapshot", { publicationEventId }, options), + getCatalog: (query, options) => read("listStudioCatalog", query, options), + }; + return Object.freeze(gateway); +} diff --git a/src/features/tech-log/adapters/mock/cursor.ts b/src/features/tech-log/adapters/mock/cursor.ts index 55551fe..cdba9b1 100644 --- a/src/features/tech-log/adapters/mock/cursor.ts +++ b/src/features/tech-log/adapters/mock/cursor.ts @@ -1,6 +1,6 @@ import { StudioGatewayError } from "../../application/ports/studio-gateway-error.ts"; import type { ProblemDetails } from "../../contracts/studio/contract.ts"; -import { stableStringify } from "./stable-stringify.ts"; +import { stableStringify } from "../stable-stringify.ts"; export type CursorPayload = { binding: string; lastValue: string; lastId: string }; const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; diff --git a/src/features/tech-log/adapters/mock/mock-studio-gateway.ts b/src/features/tech-log/adapters/mock/mock-studio-gateway.ts index bba9a1e..9f9d236 100644 --- a/src/features/tech-log/adapters/mock/mock-studio-gateway.ts +++ b/src/features/tech-log/adapters/mock/mock-studio-gateway.ts @@ -6,7 +6,7 @@ import { deriveDocumentState, derivePreviewState } from "../../domain/studio/doc import { cursorBinding, decodeCursor, encodeCursor } from "./cursor.ts"; import { createMockStudioState, MockStudioState } from "./mock-state.ts"; import { projectWorkingCopy } from "./project-public-render-model.ts"; -import { stableStringify } from "./stable-stringify.ts"; +import { stableStringify } from "../stable-stringify.ts"; import { validateWorkingCopy, validateWorkingCopyInputStructure } from "./validate-working-copy.ts"; export { createMockStudioState } from "./mock-state.ts"; diff --git a/src/features/tech-log/adapters/mock/stable-stringify.ts b/src/features/tech-log/adapters/stable-stringify.ts similarity index 100% rename from src/features/tech-log/adapters/mock/stable-stringify.ts rename to src/features/tech-log/adapters/stable-stringify.ts diff --git a/tests/features/tech-log/http-studio-gateway.test.ts b/tests/features/tech-log/http-studio-gateway.test.ts new file mode 100644 index 0000000..b702536 --- /dev/null +++ b/tests/features/tech-log/http-studio-gateway.test.ts @@ -0,0 +1,199 @@ +import assert from "node:assert/strict"; +import { afterAll, afterEach, beforeAll, test } from "vitest"; +import { setupServer } from "msw/node"; + +import { createHttpStudioGateway } from "../../../src/features/tech-log/adapters/http/http-studio-gateway.ts"; +import { createCsrfTokenProvider } from "../../../src/features/tech-log/adapters/http/studio-session-csrf.ts"; +import { createTechLogStudioHandlers } from "../../mocks/handlers/tech-log-studio.ts"; +import { FIXTURE_IDS } from "../../../src/features/tech-log/adapters/mock/fixtures.ts"; +import { isStudioGatewayError } from "../../../src/features/tech-log/application/ports/studio-gateway-error.ts"; +import { TECH_LOG_STUDIO_CONTRIBUTION } from "../../../src/features/tech-log/contracts/tech-log-studio-contract-contribution.ts"; + +const { handlers } = createTechLogStudioHandlers(); +const server = setupServer(...handlers); + +beforeAll(() => server.listen({ onUnhandledRequest: "error" })); +afterEach(() => server.resetHandlers()); +afterAll(() => server.close()); + +const BASE = "http://api.test"; + +/** + * 전송 계층을 얇게 세운다. 이 테스트가 증명하는 것은 gateway가 canonical + * 경로/본문/헤더를 정확히 만들고 응답을 포트 계약으로 되돌린다는 것이다. + * 플랫폼 계약 런타임을 실제로 조립하지 않고, 계약 기여의 `projectRequest`로 + * 요청을 만들어 `fetch`로 보낸다 — 이 테스트의 대상은 gateway이지 플랫폼 + * 전송이 아니다. + */ +async function realDependencies() { + const byId = new Map( + TECH_LOG_STUDIO_CONTRIBUTION.http.map((entry) => [entry.contract.operationId, entry]), + ); + + const csrf = createCsrfTokenProvider({ + async execute() { + const response = await fetch(`${BASE}/api/v1/studio/session`); + const body = (await response.json()) as { + csrfToken: string; + csrfHeaderName: string; + }; + return { csrfToken: body.csrfToken, csrfHeaderName: body.csrfHeaderName }; + }, + }); + + const operations = { + async execute( + operationId: string, + input: unknown, + context: { signal?: AbortSignal; intent?: unknown }, + ) { + const entry = byId.get(operationId); + if (!entry) throw new Error(`unregistered operation: ${operationId}`); + const { contract } = entry; + const projected = contract.projectRequest(input as never); + + let path = contract.pathTemplate; + for (const [name, value] of Object.entries(projected.pathValues)) { + path = path.replace(`{${name}}`, encodeURIComponent(value)); + } + const url = new URL(`${BASE}${path}`); + for (const [name, value] of projected.queryEntries) { + url.searchParams.append(name, value); + } + + // 실행기가 헤더를 만드는 두 경로를 그대로 재현한다: intent → Idempotency-Key, + // credential collaborator → x-csrf-token. + const headers: Record = {}; + if (contract.retrySemantics === "KEYED") { + const intent = (context as { intent?: { idempotencyKey?: string } }).intent; + if (intent?.idempotencyKey) headers["Idempotency-Key"] = intent.idempotencyKey; + headers["X-CSRF-TOKEN"] = await csrf.token(); + } + if (contract.requestBody === "JSON") headers["content-type"] = "application/json"; + + const response = await fetch(url, { + method: contract.method, + headers, + ...(contract.requestBody === "JSON" + ? { body: JSON.stringify(projected.body) } + : {}), + ...(context.signal ? { signal: context.signal } : {}), + }); + + if (contract.acceptedStatuses.includes(response.status)) { + const value = + contract.responseBody === "NONE" ? null : await response.json(); + return { kind: "SUCCESS" as const, value, effect: "APPLIED_CONFIRMED" as const }; + } + return { + kind: "PROBLEM" as const, + problem: await response.json(), + metadata: { status: response.status }, + effect: "NOT_APPLIED" as const, + }; + }, + }; + + return { operations } as never; +} + +/** + * `CreateDocumentInput` IS the `WorkingCopyInput` (no `{ document }` wrapper) + * per the port signature, but `CREATE_STUDIO_DOCUMENT`'s `projectRequest` + * reads `value.document` off its operation input to build the request body. + * The gateway must bridge that mismatch by wrapping the port's raw input as + * `{ document: input }` before calling the operation — otherwise the wire + * body would be `undefined` instead of the document. + */ +test("wraps the raw port input into the operation's { document } shape", async () => { + const gateway = createHttpStudioGateway(await realDependencies()); + const before = await gateway.getDocument(FIXTURE_IDS.fetchJoinCase); + const { id, version, updatedAt, ...input } = before.document; + void id; + void version; + void updatedAt; + + const created = await gateway.createDocument( + { ...input, title: "HTTP 경로로 생성" }, + { idempotencyKey: "create-1" }, + ); + + assert.equal(created.title, "HTTP 경로로 생성"); + assert.equal(created.version, 1); + assert.equal(typeof created.id, "string"); +}); + +test("reads the dashboard through the canonical path", async () => { + const gateway = createHttpStudioGateway(await realDependencies()); + const dashboard = await gateway.getDashboard(); + assert.equal(typeof dashboard.totals.documents, "number"); + assert.ok(dashboard.totals.documents > 0); +}); + +test("saves with expectedVersion and returns the new version", async () => { + const gateway = createHttpStudioGateway(await realDependencies()); + const before = await gateway.getDocument(FIXTURE_IDS.fetchJoinCase); + const { id, version, updatedAt, ...input } = before.document; + void id; + void updatedAt; + + const saved = await gateway.saveDocument( + before.document.id, + { expectedVersion: version, document: { ...input, title: "HTTP 경로로 저장" } }, + { idempotencyKey: "save-1" }, + ); + + assert.equal(saved.document.title, "HTTP 경로로 저장"); + assert.equal(saved.document.version, version + 1); +}); + +test("surfaces VERSION_CONFLICT as the port error, not a transport error", async () => { + const gateway = createHttpStudioGateway(await realDependencies()); + const before = await gateway.getDocument(FIXTURE_IDS.fetchJoinCase); + const { id, version, updatedAt, ...input } = before.document; + void id; + void updatedAt; + + await assert.rejects( + gateway.saveDocument( + before.document.id, + { expectedVersion: version + 99, document: input }, + { idempotencyKey: "conflict-1" }, + ), + (error: unknown) => { + assert.ok(isStudioGatewayError(error)); + assert.equal(error.code, "VERSION_CONFLICT"); + assert.equal(error.status, 409); + return true; + }, + ); +}); + +test("sends the CSRF header and an Idempotency-Key on every mutation", async () => { + const captured: { csrf: string | null; key: string | null }[] = []; + server.use( + ...createTechLogStudioHandlers().handlers, + ); + server.events.on("request:start", ({ request }) => { + if (request.method === "GET") return; + captured.push({ + csrf: request.headers.get("X-CSRF-TOKEN"), + key: request.headers.get("Idempotency-Key"), + }); + }); + + const gateway = createHttpStudioGateway(await realDependencies()); + const before = await gateway.getDocument(FIXTURE_IDS.fetchJoinCase); + const { id, version, updatedAt, ...input } = before.document; + void id; + void updatedAt; + await gateway.saveDocument( + before.document.id, + { expectedVersion: version, document: input }, + { idempotencyKey: "csrf-1" }, + ); + + assert.equal(captured.length, 1); + assert.equal(captured[0]!.csrf, "csrf-test-token"); + assert.equal(captured[0]!.key, "csrf-1"); +}); diff --git a/tests/features/tech-log/mock-studio-gateway.test.ts b/tests/features/tech-log/mock-studio-gateway.test.ts index 8d3b668..1cca943 100644 --- a/tests/features/tech-log/mock-studio-gateway.test.ts +++ b/tests/features/tech-log/mock-studio-gateway.test.ts @@ -16,7 +16,7 @@ import { createMockStudioGateway, createMockStudioState, } from "../../../src/features/tech-log/adapters/mock/mock-studio-gateway.ts"; -import { stableStringify } from "../../../src/features/tech-log/adapters/mock/stable-stringify.ts"; +import { stableStringify } from "../../../src/features/tech-log/adapters/stable-stringify.ts"; const NOW = "2026-08-14T01:00:00.000Z"; diff --git a/tests/mocks/handlers/tech-log-studio.ts b/tests/mocks/handlers/tech-log-studio.ts new file mode 100644 index 0000000..12b50a7 --- /dev/null +++ b/tests/mocks/handlers/tech-log-studio.ts @@ -0,0 +1,147 @@ +import { http, HttpResponse } from "msw"; + +import { createMockStudioGateway } from "../../../src/features/tech-log/adapters/mock/mock-studio-gateway.ts"; +import { isStudioGatewayError } from "../../../src/features/tech-log/application/ports/studio-gateway-error.ts"; + +const BASE = "http://api.test"; + +function problemResponse(error: unknown) { + if (isStudioGatewayError(error)) { + return HttpResponse.json(error.problem, { + status: error.status, + headers: { "content-type": "application/problem+json" }, + }); + } + throw error; +} + +async function json(work: () => Promise, status = 200) { + try { + return HttpResponse.json((await work()) as never, { status }); + } catch (error) { + return problemResponse(error); + } +} + +export function createTechLogStudioHandlers( + gateway = createMockStudioGateway(), + baseUrl = BASE, +) { + const key = (request: Request) => + request.headers.get("Idempotency-Key") ?? "missing-key"; + + return { + gateway, + handlers: [ + http.get(`${baseUrl}/api/v1/studio/session`, () => + HttpResponse.json({ + authenticated: true, + displayName: "테스트 편집자", + roles: ["STUDIO_EDITOR"], + csrfToken: "csrf-test-token", + csrfHeaderName: "X-CSRF-TOKEN", + }), + ), + http.get(`${baseUrl}/api/v1/studio/dashboard`, () => + json(() => gateway.getDashboard()), + ), + http.get(`${baseUrl}/api/v1/studio/documents`, ({ request }) => { + const url = new URL(request.url); + const limit = url.searchParams.get("limit"); + return json(() => + gateway.listDocuments({ + ...(url.searchParams.get("q") ? { q: url.searchParams.get("q")! } : {}), + ...(limit ? { limit: Number(limit) } : {}), + } as never), + ); + }), + http.post(`${baseUrl}/api/v1/studio/documents`, async ({ request }) => + json( + async () => + gateway.createDocument((await request.json()) as never, { + idempotencyKey: key(request), + }), + 201, + ), + ), + http.get(`${baseUrl}/api/v1/studio/documents/:documentId`, ({ params }) => + json(() => gateway.getDocument(String(params.documentId))), + ), + http.put( + `${baseUrl}/api/v1/studio/documents/:documentId`, + async ({ request, params }) => + json(async () => + gateway.saveDocument( + String(params.documentId), + (await request.json()) as never, + { idempotencyKey: key(request) }, + ), + ), + ), + http.post( + `${baseUrl}/api/v1/studio/documents/:documentId/validate`, + async ({ request, params }) => + json(async () => + gateway.validateDocument( + String(params.documentId), + (await request.json()) as never, + { idempotencyKey: key(request) }, + ), + ), + ), + http.get( + `${baseUrl}/api/v1/studio/documents/:documentId/preview`, + ({ params }) => json(() => gateway.getCurrentPreview(String(params.documentId))), + ), + http.post( + `${baseUrl}/api/v1/studio/documents/:documentId/preview`, + async ({ request, params }) => + json( + async () => + gateway.createPreview( + String(params.documentId), + (await request.json()) as never, + { idempotencyKey: key(request) }, + ), + 201, + ), + ), + http.post( + `${baseUrl}/api/v1/studio/documents/:documentId/publish`, + async ({ request, params }) => + json(async () => + gateway.publishDocument( + String(params.documentId), + (await request.json()) as never, + { idempotencyKey: key(request) }, + ), + ), + ), + http.get(`${baseUrl}/api/v1/studio/publications`, () => + json(() => gateway.listPublications({})), + ), + http.post( + `${baseUrl}/api/v1/studio/publications/:publicationId/unpublish`, + async ({ request, params }) => + json(async () => + gateway.unpublishPublication( + String(params.publicationId), + (await request.json()) as never, + { idempotencyKey: key(request) }, + ), + ), + ), + http.get( + `${baseUrl}/api/v1/studio/publications/:publicationEventId/preview`, + ({ params }) => + json(() => gateway.getPublicationSnapshot(String(params.publicationEventId))), + ), + http.get(`${baseUrl}/api/v1/studio/catalog`, ({ request }) => { + const url = new URL(request.url); + return json(() => + gateway.getCatalog({ type: url.searchParams.get("type") as never }), + ); + }), + ], + }; +}