Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a9a7db0231 | ||
|
|
3e126c0ddd |
@@ -3,7 +3,9 @@ import { getApiOperation } from "../../contracts/api-operations.js";
|
|||||||
import {
|
import {
|
||||||
createFailure as failure,
|
createFailure as failure,
|
||||||
kindForStatus as statusKind,
|
kindForStatus as statusKind,
|
||||||
|
normalizeUnknownFailure,
|
||||||
} from "../../contracts/errors.js";
|
} from "../../contracts/errors.js";
|
||||||
|
import { mapOperationPayload } from "./resource-mapper.js";
|
||||||
import { retryDelay, shouldRetry } from "./retry-policy.js";
|
import { retryDelay, shouldRetry } from "./retry-policy.js";
|
||||||
import {
|
import {
|
||||||
validateEnvelope,
|
validateEnvelope,
|
||||||
@@ -49,6 +51,7 @@ const noAuthSession =
|
|||||||
* random?: () => number,
|
* random?: () => number,
|
||||||
* validatePayload?: (schemaId: string, value: unknown) =>
|
* validatePayload?: (schemaId: string, value: unknown) =>
|
||||||
* { success: true, data: unknown } | { success: false },
|
* { success: true, data: unknown } | { success: false },
|
||||||
|
* mapPayload?: (operationId: string, payload: unknown) => unknown,
|
||||||
* idempotencyKeyFactory?: () => string
|
* idempotencyKeyFactory?: () => string
|
||||||
* }} dependencies
|
* }} dependencies
|
||||||
*/
|
*/
|
||||||
@@ -59,6 +62,7 @@ export function createHttpClient(dependencies) {
|
|||||||
const random = dependencies.random ?? Math.random;
|
const random = dependencies.random ?? Math.random;
|
||||||
const validatePayload =
|
const validatePayload =
|
||||||
dependencies.validatePayload ?? validateOperationPayload;
|
dependencies.validatePayload ?? validateOperationPayload;
|
||||||
|
const mapPayload = dependencies.mapPayload ?? mapOperationPayload;
|
||||||
const idempotencyKeyFactory =
|
const idempotencyKeyFactory =
|
||||||
dependencies.idempotencyKeyFactory ?? (() => crypto.randomUUID());
|
dependencies.idempotencyKeyFactory ?? (() => crypto.randomUUID());
|
||||||
|
|
||||||
@@ -195,7 +199,13 @@ export function createHttpClient(dependencies) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetcher(request);
|
const response = await fetcher(request);
|
||||||
return await parseResponse(response, operation, attempt, validatePayload);
|
return await parseResponse(
|
||||||
|
response,
|
||||||
|
operation,
|
||||||
|
attempt,
|
||||||
|
validatePayload,
|
||||||
|
mapPayload,
|
||||||
|
);
|
||||||
} catch {
|
} catch {
|
||||||
if (timedOut) {
|
if (timedOut) {
|
||||||
return {
|
return {
|
||||||
@@ -254,9 +264,16 @@ export function createHttpClient(dependencies) {
|
|||||||
* @param {number} attempt
|
* @param {number} attempt
|
||||||
* @param {(schemaId: string, value: unknown) =>
|
* @param {(schemaId: string, value: unknown) =>
|
||||||
* { success: true, data: unknown } | { success: false }} validatePayload
|
* { success: true, data: unknown } | { success: false }} validatePayload
|
||||||
|
* @param {(operationId: string, payload: unknown) => unknown} mapPayload
|
||||||
* @returns {Promise<HttpResult>}
|
* @returns {Promise<HttpResult>}
|
||||||
*/
|
*/
|
||||||
async function parseResponse(response, operation, attempt, validatePayload) {
|
async function parseResponse(
|
||||||
|
response,
|
||||||
|
operation,
|
||||||
|
attempt,
|
||||||
|
validatePayload,
|
||||||
|
mapPayload,
|
||||||
|
) {
|
||||||
const contentType = response.headers.get("content-type") ?? "";
|
const contentType = response.headers.get("content-type") ?? "";
|
||||||
if (!contentType.toLowerCase().includes("application/json")) {
|
if (!contentType.toLowerCase().includes("application/json")) {
|
||||||
return {
|
return {
|
||||||
@@ -311,11 +328,21 @@ async function parseResponse(response, operation, attempt, validatePayload) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
try {
|
||||||
ok: true,
|
return {
|
||||||
value: structuredClone(payload.data),
|
ok: true,
|
||||||
meta: safeMeta(envelopeRecord.meta),
|
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);
|
const kind = statusKind(response.status);
|
||||||
|
|||||||
@@ -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<string, unknown>} */ (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}`);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
/**
|
||||||
|
* @typedef {{
|
||||||
|
* id: string,
|
||||||
|
* displayName: string,
|
||||||
|
* createdAt: string | null
|
||||||
|
* }} Resource
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** @param {Resource} values @returns {Readonly<Resource>} */
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -46,7 +46,7 @@ describe("shared HTTP client", () => {
|
|||||||
client.execute("LIST_SAMPLE_RESOURCES", { routeId: "SAMPLE_RESOURCE_LIST" }),
|
client.execute("LIST_SAMPLE_RESOURCES", { routeId: "SAMPLE_RESOURCE_LIST" }),
|
||||||
).resolves.toMatchObject({
|
).resolves.toMatchObject({
|
||||||
ok: true,
|
ok: true,
|
||||||
value: [{ id: "resource-1" }],
|
value: [{ id: "resource-1", displayName: "Example" }],
|
||||||
meta: { requestId: "request-1" },
|
meta: { requestId: "request-1" },
|
||||||
});
|
});
|
||||||
expect(attempts).toBe(3);
|
expect(attempts).toBe(3);
|
||||||
@@ -99,4 +99,30 @@ describe("shared HTTP client", () => {
|
|||||||
error: { kind: "SCHEMA_MISMATCH" },
|
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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user