From a9a7db0231121e908a9c47f739c955f7e2420dc9 Mon Sep 17 00:00:00 2001 From: donghyeon-ka Date: Sat, 25 Jul 2026 21:02:06 +0900 Subject: [PATCH] feat: contain DTO mapping at the HTTP boundary --- src/adapters/http/client.js | 41 ++++++++++++--- src/adapters/http/resource-mapper.js | 30 +++++++++++ .../view-models/resource-view-model.js | 16 ++++++ src/domain/models/resource.js | 19 +++++++ tests/integration/http-client.test.js | 28 +++++++++- tests/unit/boundary-mapper.test.js | 52 +++++++++++++++++++ 6 files changed, 178 insertions(+), 8 deletions(-) create mode 100644 src/adapters/http/resource-mapper.js create mode 100644 src/application/view-models/resource-view-model.js create mode 100644 src/domain/models/resource.js create mode 100644 tests/unit/boundary-mapper.test.js diff --git a/src/adapters/http/client.js b/src/adapters/http/client.js index e1bc9e7..7d0b8b2 100644 --- a/src/adapters/http/client.js +++ b/src/adapters/http/client.js @@ -3,7 +3,9 @@ import { getApiOperation } from "../../contracts/api-operations.js"; import { createFailure as failure, kindForStatus as statusKind, + normalizeUnknownFailure, } from "../../contracts/errors.js"; +import { mapOperationPayload } from "./resource-mapper.js"; import { retryDelay, shouldRetry } from "./retry-policy.js"; import { validateEnvelope, @@ -49,6 +51,7 @@ const noAuthSession = * random?: () => number, * validatePayload?: (schemaId: string, value: unknown) => * { success: true, data: unknown } | { success: false }, + * mapPayload?: (operationId: string, payload: unknown) => unknown, * idempotencyKeyFactory?: () => string * }} dependencies */ @@ -59,6 +62,7 @@ export function createHttpClient(dependencies) { const random = dependencies.random ?? Math.random; const validatePayload = dependencies.validatePayload ?? validateOperationPayload; + const mapPayload = dependencies.mapPayload ?? mapOperationPayload; const idempotencyKeyFactory = dependencies.idempotencyKeyFactory ?? (() => crypto.randomUUID()); @@ -195,7 +199,13 @@ export function createHttpClient(dependencies) { } const response = await fetcher(request); - return await parseResponse(response, operation, attempt, validatePayload); + return await parseResponse( + response, + operation, + attempt, + validatePayload, + mapPayload, + ); } catch { if (timedOut) { return { @@ -254,9 +264,16 @@ export function createHttpClient(dependencies) { * @param {number} attempt * @param {(schemaId: string, value: unknown) => * { success: true, data: unknown } | { success: false }} validatePayload + * @param {(operationId: string, payload: unknown) => unknown} mapPayload * @returns {Promise} */ -async function parseResponse(response, operation, attempt, validatePayload) { +async function parseResponse( + response, + operation, + attempt, + validatePayload, + mapPayload, +) { const contentType = response.headers.get("content-type") ?? ""; if (!contentType.toLowerCase().includes("application/json")) { return { @@ -311,11 +328,21 @@ async function parseResponse(response, operation, attempt, validatePayload) { }; } - return { - ok: true, - value: structuredClone(payload.data), - meta: safeMeta(envelopeRecord.meta), - }; + try { + return { + ok: true, + value: mapPayload(operation.operationId, payload.data), + meta: safeMeta(envelopeRecord.meta), + }; + } catch (error) { + return { + ok: false, + error: normalizeUnknownFailure(error, { + operationId: operation.operationId, + attempt, + }), + }; + } } const kind = statusKind(response.status); diff --git a/src/adapters/http/resource-mapper.js b/src/adapters/http/resource-mapper.js new file mode 100644 index 0000000..2a8e636 --- /dev/null +++ b/src/adapters/http/resource-mapper.js @@ -0,0 +1,30 @@ +import { createResource } from "../../domain/models/resource.js"; + +/** @param {unknown} value */ +export function mapResourceDto(value) { + if (!value || typeof value !== "object") { + throw new TypeError("Validated resource DTO is required"); + } + const dto = /** @type {Record} */ (value); + if (typeof dto.id !== "string" || typeof dto.name !== "string") { + throw new TypeError("Validated resource DTO invariants were breached"); + } + + return createResource({ + id: dto.id, + displayName: dto.name, + createdAt: typeof dto.createdAt === "string" ? dto.createdAt : null, + }); +} + +/** @param {string} operationId @param {unknown} payload */ +export function mapOperationPayload(operationId, payload) { + if (operationId === "LIST_SAMPLE_RESOURCES") { + if (!Array.isArray(payload)) throw new TypeError("Expected a resource list"); + return payload.map(mapResourceDto); + } + if (operationId === "CREATE_SAMPLE_RESOURCE") { + return mapResourceDto(payload); + } + throw new TypeError(`No boundary mapper registered for ${operationId}`); +} diff --git a/src/application/view-models/resource-view-model.js b/src/application/view-models/resource-view-model.js new file mode 100644 index 0000000..963f25d --- /dev/null +++ b/src/application/view-models/resource-view-model.js @@ -0,0 +1,16 @@ +/** + * @param {import("../../domain/models/resource.js").Resource} resource + * @param {(value: Date) => string} [formatDate] + */ +export function toResourceViewModel( + resource, + formatDate = (value) => new Intl.DateTimeFormat("ko-KR").format(value), +) { + return Object.freeze({ + resourceId: resource.id, + title: resource.displayName, + createdAtLabel: resource.createdAt + ? formatDate(new Date(resource.createdAt)) + : null, + }); +} diff --git a/src/domain/models/resource.js b/src/domain/models/resource.js new file mode 100644 index 0000000..7a41ed2 --- /dev/null +++ b/src/domain/models/resource.js @@ -0,0 +1,19 @@ +/** + * @typedef {{ + * id: string, + * displayName: string, + * createdAt: string | null + * }} Resource + */ + +/** @param {Resource} values @returns {Readonly} */ +export function createResource(values) { + if (!values.id || !values.displayName) { + throw new TypeError("Resource invariants require id and displayName"); + } + return Object.freeze({ + id: values.id, + displayName: values.displayName, + createdAt: values.createdAt, + }); +} diff --git a/tests/integration/http-client.test.js b/tests/integration/http-client.test.js index b3633c4..6ca6640 100644 --- a/tests/integration/http-client.test.js +++ b/tests/integration/http-client.test.js @@ -46,7 +46,7 @@ describe("shared HTTP client", () => { client.execute("LIST_SAMPLE_RESOURCES", { routeId: "SAMPLE_RESOURCE_LIST" }), ).resolves.toMatchObject({ ok: true, - value: [{ id: "resource-1" }], + value: [{ id: "resource-1", displayName: "Example" }], meta: { requestId: "request-1" }, }); expect(attempts).toBe(3); @@ -99,4 +99,30 @@ describe("shared HTTP client", () => { error: { kind: "SCHEMA_MISMATCH" }, }); }); + + it("guards mapper exceptions as UNKNOWN_FAILURE", async () => { + server.use( + http.get("https://api.test/api/sample/resources", () => + HttpResponse.json({ + success: true, + data: [{ id: "resource-1", name: "Example" }], + meta: { requestId: "request-1", traceId: "trace-1" }, + }), + ), + ); + const client = createHttpClient({ + baseUrl: "https://api.test", + clock, + mapPayload: () => { + throw new Error("raw mapper detail"); + }, + }); + + const result = await client.execute("LIST_SAMPLE_RESOURCES"); + expect(result).toMatchObject({ + ok: false, + error: { kind: "UNKNOWN_FAILURE" }, + }); + expect(JSON.stringify(result)).not.toContain("raw mapper detail"); + }); }); diff --git a/tests/unit/boundary-mapper.test.js b/tests/unit/boundary-mapper.test.js new file mode 100644 index 0000000..838b1c0 --- /dev/null +++ b/tests/unit/boundary-mapper.test.js @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; + +import { + mapOperationPayload, + mapResourceDto, +} from "../../src/adapters/http/resource-mapper.js"; +import { toResourceViewModel } from "../../src/application/view-models/resource-view-model.js"; + +describe("DTO to model to view-model mapping", () => { + it("contains raw DTO names at the HTTP boundary", () => { + const model = mapResourceDto({ + id: "resource-1", + name: "Example", + createdAt: "2026-07-25T00:00:00.000Z", + backendOnly: "not propagated", + }); + + expect(model).toEqual({ + id: "resource-1", + displayName: "Example", + createdAt: "2026-07-25T00:00:00.000Z", + }); + expect(model).not.toHaveProperty("name"); + expect(model).not.toHaveProperty("backendOnly"); + }); + + it("maps operation payloads and rejects missing mappers", () => { + expect( + mapOperationPayload("LIST_SAMPLE_RESOURCES", [ + { id: "resource-1", name: "Example" }, + ]), + ).toEqual([ + { id: "resource-1", displayName: "Example", createdAt: null }, + ]); + expect(() => mapOperationPayload("UNKNOWN", {})).toThrow( + "No boundary mapper registered", + ); + }); + + it("projects an application-owned render-ready shape", () => { + const model = mapResourceDto({ + id: "resource-1", + name: "Example", + createdAt: null, + }); + expect(toResourceViewModel(model)).toEqual({ + resourceId: "resource-1", + title: "Example", + createdAtLabel: null, + }); + }); +});